meta-data-mcp 2.2.0__tar.gz → 2.3.2__tar.gz
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.
- {meta_data_mcp-2.2.0 → meta_data_mcp-2.3.2}/PKG-INFO +3 -2
- {meta_data_mcp-2.2.0 → meta_data_mcp-2.3.2}/README.md +1 -0
- meta_data_mcp-2.3.2/meta_data_mcp/__init__.py +1 -0
- meta_data_mcp-2.3.2/meta_data_mcp/oauth_provider.py +270 -0
- {meta_data_mcp-2.2.0 → meta_data_mcp-2.3.2}/meta_data_mcp/sdk/remote.py +37 -12
- {meta_data_mcp-2.2.0 → meta_data_mcp-2.3.2}/meta_data_mcp/server.py +246 -22
- {meta_data_mcp-2.2.0 → meta_data_mcp-2.3.2}/meta_data_mcp/ui_resources/app_news_tone_v1.html +9 -4
- {meta_data_mcp-2.2.0 → meta_data_mcp-2.3.2}/pyproject.toml +1 -1
- meta_data_mcp-2.2.0/meta_data_mcp/__init__.py +0 -1
- meta_data_mcp-2.2.0/meta_data_mcp/sdk.py +0 -268
- {meta_data_mcp-2.2.0 → meta_data_mcp-2.3.2}/.gitignore +0 -0
- {meta_data_mcp-2.2.0 → meta_data_mcp-2.3.2}/AUTHORS +0 -0
- {meta_data_mcp-2.2.0 → meta_data_mcp-2.3.2}/LICENSE +0 -0
- {meta_data_mcp-2.2.0 → meta_data_mcp-2.3.2}/meta_data_mcp/cli.py +0 -0
- {meta_data_mcp-2.2.0 → meta_data_mcp-2.3.2}/meta_data_mcp/client.py +0 -0
- {meta_data_mcp-2.2.0 → meta_data_mcp-2.3.2}/meta_data_mcp/discovery/__init__.py +0 -0
- {meta_data_mcp-2.2.0 → meta_data_mcp-2.3.2}/meta_data_mcp/discovery/loader.py +0 -0
- {meta_data_mcp-2.2.0 → meta_data_mcp-2.3.2}/meta_data_mcp/discovery/state.py +0 -0
- {meta_data_mcp-2.2.0 → meta_data_mcp-2.3.2}/meta_data_mcp/errors.py +0 -0
- {meta_data_mcp-2.2.0 → meta_data_mcp-2.3.2}/meta_data_mcp/fields.py +0 -0
- {meta_data_mcp-2.2.0 → meta_data_mcp-2.3.2}/meta_data_mcp/health.py +0 -0
- {meta_data_mcp-2.2.0 → meta_data_mcp-2.3.2}/meta_data_mcp/provenance.py +0 -0
- {meta_data_mcp-2.2.0 → meta_data_mcp-2.3.2}/meta_data_mcp/provider_config.py +0 -0
- {meta_data_mcp-2.2.0 → meta_data_mcp-2.3.2}/meta_data_mcp/providers/__init__.py +0 -0
- {meta_data_mcp-2.2.0 → meta_data_mcp-2.3.2}/meta_data_mcp/providers/__template__.py +0 -0
- {meta_data_mcp-2.2.0 → meta_data_mcp-2.3.2}/meta_data_mcp/providers/au_data_gov.py +0 -0
- {meta_data_mcp-2.2.0 → meta_data_mcp-2.3.2}/meta_data_mcp/providers/ca_open_gov.py +0 -0
- {meta_data_mcp-2.2.0 → meta_data_mcp-2.3.2}/meta_data_mcp/providers/cern_opendata.py +0 -0
- {meta_data_mcp-2.2.0 → meta_data_mcp-2.3.2}/meta_data_mcp/providers/ch_sbb.py +0 -0
- {meta_data_mcp-2.2.0 → meta_data_mcp-2.3.2}/meta_data_mcp/providers/de_db.py +0 -0
- {meta_data_mcp-2.2.0 → meta_data_mcp-2.3.2}/meta_data_mcp/providers/eu_copernicus.py +0 -0
- {meta_data_mcp-2.2.0 → meta_data_mcp-2.3.2}/meta_data_mcp/providers/eu_ecb.py +0 -0
- {meta_data_mcp-2.2.0 → meta_data_mcp-2.3.2}/meta_data_mcp/providers/eu_eurostat.py +0 -0
- {meta_data_mcp-2.2.0 → meta_data_mcp-2.3.2}/meta_data_mcp/providers/fr_data_gouv.py +0 -0
- {meta_data_mcp-2.2.0 → meta_data_mcp-2.3.2}/meta_data_mcp/providers/global_arxiv.py +0 -0
- {meta_data_mcp-2.2.0 → meta_data_mcp-2.3.2}/meta_data_mcp/providers/global_bgpview.py +0 -0
- {meta_data_mcp-2.2.0 → meta_data_mcp-2.3.2}/meta_data_mcp/providers/global_coingecko.py +0 -0
- {meta_data_mcp-2.2.0 → meta_data_mcp-2.3.2}/meta_data_mcp/providers/global_crossref.py +0 -0
- {meta_data_mcp-2.2.0 → meta_data_mcp-2.3.2}/meta_data_mcp/providers/global_dbnomics.py +0 -0
- {meta_data_mcp-2.2.0 → meta_data_mcp-2.3.2}/meta_data_mcp/providers/global_disease_sh.py +0 -0
- {meta_data_mcp-2.2.0 → meta_data_mcp-2.3.2}/meta_data_mcp/providers/global_epss.py +0 -0
- {meta_data_mcp-2.2.0 → meta_data_mcp-2.3.2}/meta_data_mcp/providers/global_europepmc.py +0 -0
- {meta_data_mcp-2.2.0 → meta_data_mcp-2.3.2}/meta_data_mcp/providers/global_faostat.py +0 -0
- {meta_data_mcp-2.2.0 → meta_data_mcp-2.3.2}/meta_data_mcp/providers/global_frankfurter.py +0 -0
- {meta_data_mcp-2.2.0 → meta_data_mcp-2.3.2}/meta_data_mcp/providers/global_gbif.py +0 -0
- {meta_data_mcp-2.2.0 → meta_data_mcp-2.3.2}/meta_data_mcp/providers/global_gdelt.py +0 -0
- {meta_data_mcp-2.2.0 → meta_data_mcp-2.3.2}/meta_data_mcp/providers/global_imf.py +0 -0
- {meta_data_mcp-2.2.0 → meta_data_mcp-2.3.2}/meta_data_mcp/providers/global_inaturalist.py +0 -0
- {meta_data_mcp-2.2.0 → meta_data_mcp-2.3.2}/meta_data_mcp/providers/global_met_museum.py +0 -0
- {meta_data_mcp-2.2.0 → meta_data_mcp-2.3.2}/meta_data_mcp/providers/global_nvd_cve.py +0 -0
- {meta_data_mcp-2.2.0 → meta_data_mcp-2.3.2}/meta_data_mcp/providers/global_oecd.py +0 -0
- {meta_data_mcp-2.2.0 → meta_data_mcp-2.3.2}/meta_data_mcp/providers/global_open_library.py +0 -0
- {meta_data_mcp-2.2.0 → meta_data_mcp-2.3.2}/meta_data_mcp/providers/global_open_meteo.py +0 -0
- {meta_data_mcp-2.2.0 → meta_data_mcp-2.3.2}/meta_data_mcp/providers/global_openalex.py +0 -0
- {meta_data_mcp-2.2.0 → meta_data_mcp-2.3.2}/meta_data_mcp/providers/global_openaq.py +0 -0
- {meta_data_mcp-2.2.0 → meta_data_mcp-2.3.2}/meta_data_mcp/providers/global_opensanctions.py +0 -0
- {meta_data_mcp-2.2.0 → meta_data_mcp-2.3.2}/meta_data_mcp/providers/global_opensky.py +0 -0
- {meta_data_mcp-2.2.0 → meta_data_mcp-2.3.2}/meta_data_mcp/providers/global_osm_nominatim.py +0 -0
- {meta_data_mcp-2.2.0 → meta_data_mcp-2.3.2}/meta_data_mcp/providers/global_osv_dev.py +0 -0
- {meta_data_mcp-2.2.0 → meta_data_mcp-2.3.2}/meta_data_mcp/providers/global_overpass.py +0 -0
- {meta_data_mcp-2.2.0 → meta_data_mcp-2.3.2}/meta_data_mcp/providers/global_pubchem.py +0 -0
- {meta_data_mcp-2.2.0 → meta_data_mcp-2.3.2}/meta_data_mcp/providers/global_rcsb_pdb.py +0 -0
- {meta_data_mcp-2.2.0 → meta_data_mcp-2.3.2}/meta_data_mcp/providers/global_ripe_stat.py +0 -0
- {meta_data_mcp-2.2.0 → meta_data_mcp-2.3.2}/meta_data_mcp/providers/global_un_comtrade.py +0 -0
- {meta_data_mcp-2.2.0 → meta_data_mcp-2.3.2}/meta_data_mcp/providers/global_unesco_heritage.py +0 -0
- {meta_data_mcp-2.2.0 → meta_data_mcp-2.3.2}/meta_data_mcp/providers/global_who_gho.py +0 -0
- {meta_data_mcp-2.2.0 → meta_data_mcp-2.3.2}/meta_data_mcp/providers/global_wikidata.py +0 -0
- {meta_data_mcp-2.2.0 → meta_data_mcp-2.3.2}/meta_data_mcp/providers/global_wikipedia.py +0 -0
- {meta_data_mcp-2.2.0 → meta_data_mcp-2.3.2}/meta_data_mcp/providers/global_world_bank.py +0 -0
- {meta_data_mcp-2.2.0 → meta_data_mcp-2.3.2}/meta_data_mcp/providers/meta_data_mcp.py +0 -0
- {meta_data_mcp-2.2.0 → meta_data_mcp-2.3.2}/meta_data_mcp/providers/nl_cbs.py +0 -0
- {meta_data_mcp-2.2.0 → meta_data_mcp-2.3.2}/meta_data_mcp/providers/nl_ndov.py +0 -0
- {meta_data_mcp-2.2.0 → meta_data_mcp-2.3.2}/meta_data_mcp/providers/nl_rechtspraak.py +0 -0
- {meta_data_mcp-2.2.0 → meta_data_mcp-2.3.2}/meta_data_mcp/providers/nl_tweedekamer.py +0 -0
- {meta_data_mcp-2.2.0 → meta_data_mcp-2.3.2}/meta_data_mcp/providers/sg_data_gov.py +0 -0
- {meta_data_mcp-2.2.0 → meta_data_mcp-2.3.2}/meta_data_mcp/providers/uk_gov.py +0 -0
- {meta_data_mcp-2.2.0 → meta_data_mcp-2.3.2}/meta_data_mcp/providers/uk_legislation.py +0 -0
- {meta_data_mcp-2.2.0 → meta_data_mcp-2.3.2}/meta_data_mcp/providers/uk_ons.py +0 -0
- {meta_data_mcp-2.2.0 → meta_data_mcp-2.3.2}/meta_data_mcp/providers/us_arcgis_item.py +0 -0
- {meta_data_mcp-2.2.0 → meta_data_mcp-2.3.2}/meta_data_mcp/providers/us_cary.py +0 -0
- {meta_data_mcp-2.2.0 → meta_data_mcp-2.3.2}/meta_data_mcp/providers/us_cdc_socrata.py +0 -0
- {meta_data_mcp-2.2.0 → meta_data_mcp-2.3.2}/meta_data_mcp/providers/us_census_geocoder.py +0 -0
- {meta_data_mcp-2.2.0 → meta_data_mcp-2.3.2}/meta_data_mcp/providers/us_cisa_kev.py +0 -0
- {meta_data_mcp-2.2.0 → meta_data_mcp-2.3.2}/meta_data_mcp/providers/us_clinicaltrials.py +0 -0
- {meta_data_mcp-2.2.0 → meta_data_mcp-2.3.2}/meta_data_mcp/providers/us_courtlistener.py +0 -0
- {meta_data_mcp-2.2.0 → meta_data_mcp-2.3.2}/meta_data_mcp/providers/us_data_gov.py +0 -0
- {meta_data_mcp-2.2.0 → meta_data_mcp-2.3.2}/meta_data_mcp/providers/us_faa_nasstatus.py +0 -0
- {meta_data_mcp-2.2.0 → meta_data_mcp-2.3.2}/meta_data_mcp/providers/us_fayetteville.py +0 -0
- {meta_data_mcp-2.2.0 → meta_data_mcp-2.3.2}/meta_data_mcp/providers/us_fda_openfda.py +0 -0
- {meta_data_mcp-2.2.0 → meta_data_mcp-2.3.2}/meta_data_mcp/providers/us_federal_register.py +0 -0
- {meta_data_mcp-2.2.0 → meta_data_mcp-2.3.2}/meta_data_mcp/providers/us_healthdata_gov.py +0 -0
- {meta_data_mcp-2.2.0 → meta_data_mcp-2.3.2}/meta_data_mcp/providers/us_nasa.py +0 -0
- {meta_data_mcp-2.2.0 → meta_data_mcp-2.3.2}/meta_data_mcp/providers/us_nc_onemap.py +0 -0
- {meta_data_mcp-2.2.0 → meta_data_mcp-2.3.2}/meta_data_mcp/providers/us_ncdeq_gis.py +0 -0
- {meta_data_mcp-2.2.0 → meta_data_mcp-2.3.2}/meta_data_mcp/providers/us_noaa_awc.py +0 -0
- {meta_data_mcp-2.2.0 → meta_data_mcp-2.3.2}/meta_data_mcp/providers/us_noaa_ncei.py +0 -0
- {meta_data_mcp-2.2.0 → meta_data_mcp-2.3.2}/meta_data_mcp/providers/us_noaa_tides.py +0 -0
- {meta_data_mcp-2.2.0 → meta_data_mcp-2.3.2}/meta_data_mcp/providers/us_raleigh.py +0 -0
- {meta_data_mcp-2.2.0 → meta_data_mcp-2.3.2}/meta_data_mcp/providers/us_sec_edgar.py +0 -0
- {meta_data_mcp-2.2.0 → meta_data_mcp-2.3.2}/meta_data_mcp/providers/us_treasury_fiscal.py +0 -0
- {meta_data_mcp-2.2.0 → meta_data_mcp-2.3.2}/meta_data_mcp/providers/us_usgs_earthquake.py +0 -0
- {meta_data_mcp-2.2.0 → meta_data_mcp-2.3.2}/meta_data_mcp/registry.py +0 -0
- {meta_data_mcp-2.2.0 → meta_data_mcp-2.3.2}/meta_data_mcp/routing.py +0 -0
- {meta_data_mcp-2.2.0 → meta_data_mcp-2.3.2}/meta_data_mcp/sdk/__init__.py +0 -0
- {meta_data_mcp-2.2.0 → meta_data_mcp-2.3.2}/meta_data_mcp/serialize.py +0 -0
- {meta_data_mcp-2.2.0 → meta_data_mcp-2.3.2}/meta_data_mcp/transport.py +0 -0
- {meta_data_mcp-2.2.0 → meta_data_mcp-2.3.2}/meta_data_mcp/ui_resources/__init__.py +0 -0
- {meta_data_mcp-2.2.0 → meta_data_mcp-2.3.2}/meta_data_mcp/ui_resources/app_discovery_v1.html +0 -0
- {meta_data_mcp-2.2.0 → meta_data_mcp-2.3.2}/meta_data_mcp/ui_resources/app_discovery_v1.py +0 -0
- {meta_data_mcp-2.2.0 → meta_data_mcp-2.3.2}/meta_data_mcp/ui_resources/app_entity_graph_v1.html +0 -0
- {meta_data_mcp-2.2.0 → meta_data_mcp-2.3.2}/meta_data_mcp/ui_resources/app_entity_graph_v1.py +0 -0
- {meta_data_mcp-2.2.0 → meta_data_mcp-2.3.2}/meta_data_mcp/ui_resources/app_molecular_v1.html +0 -0
- {meta_data_mcp-2.2.0 → meta_data_mcp-2.3.2}/meta_data_mcp/ui_resources/app_molecular_v1.py +0 -0
- {meta_data_mcp-2.2.0 → meta_data_mcp-2.3.2}/meta_data_mcp/ui_resources/app_museum_v1.html +0 -0
- {meta_data_mcp-2.2.0 → meta_data_mcp-2.3.2}/meta_data_mcp/ui_resources/app_museum_v1.py +0 -0
- {meta_data_mcp-2.2.0 → meta_data_mcp-2.3.2}/meta_data_mcp/ui_resources/app_network_topology_v1.html +0 -0
- {meta_data_mcp-2.2.0 → meta_data_mcp-2.3.2}/meta_data_mcp/ui_resources/app_network_topology_v1.py +0 -0
- {meta_data_mcp-2.2.0 → meta_data_mcp-2.3.2}/meta_data_mcp/ui_resources/app_news_tone_v1.py +0 -0
- {meta_data_mcp-2.2.0 → meta_data_mcp-2.3.2}/meta_data_mcp/ui_resources/app_trade_flows_v1.html +0 -0
- {meta_data_mcp-2.2.0 → meta_data_mcp-2.3.2}/meta_data_mcp/ui_resources/app_trade_flows_v1.py +0 -0
- {meta_data_mcp-2.2.0 → meta_data_mcp-2.3.2}/meta_data_mcp/ui_resources/app_vulnerability_v1.html +0 -0
- {meta_data_mcp-2.2.0 → meta_data_mcp-2.3.2}/meta_data_mcp/ui_resources/app_vulnerability_v1.py +0 -0
- {meta_data_mcp-2.2.0 → meta_data_mcp-2.3.2}/meta_data_mcp/ui_resources/shape_geofeatures_v1.html +0 -0
- {meta_data_mcp-2.2.0 → meta_data_mcp-2.3.2}/meta_data_mcp/ui_resources/shape_geofeatures_v1.py +0 -0
- {meta_data_mcp-2.2.0 → meta_data_mcp-2.3.2}/meta_data_mcp/ui_resources/shape_records_v1.html +0 -0
- {meta_data_mcp-2.2.0 → meta_data_mcp-2.3.2}/meta_data_mcp/ui_resources/shape_records_v1.py +0 -0
- {meta_data_mcp-2.2.0 → meta_data_mcp-2.3.2}/meta_data_mcp/ui_resources/shape_timeseries_v1.html +0 -0
- {meta_data_mcp-2.2.0 → meta_data_mcp-2.3.2}/meta_data_mcp/ui_resources/shape_timeseries_v1.py +0 -0
- {meta_data_mcp-2.2.0 → meta_data_mcp-2.3.2}/meta_data_mcp/utils.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: meta-data-mcp
|
|
3
|
-
Version: 2.2
|
|
3
|
+
Version: 2.3.2
|
|
4
4
|
Summary: MCP server exposing 60+ open data providers to LLM clients via intelligent metadata routing.
|
|
5
5
|
Project-URL: Homepage, https://github.com/derekslinz/meta-data-mcp
|
|
6
6
|
Project-URL: Repository, https://github.com/derekslinz/meta-data-mcp
|
|
@@ -41,7 +41,7 @@ Requires-Python: >=3.12
|
|
|
41
41
|
Requires-Dist: anyio>=4.0.0
|
|
42
42
|
Requires-Dist: click>=8.1.7
|
|
43
43
|
Requires-Dist: httpx>=0.28.0
|
|
44
|
-
Requires-Dist: mcp>=1.
|
|
44
|
+
Requires-Dist: mcp>=1.9.0
|
|
45
45
|
Requires-Dist: pydantic>=2.0.0
|
|
46
46
|
Requires-Dist: pyyaml>=6.0
|
|
47
47
|
Requires-Dist: starlette>=1.0.0
|
|
@@ -373,6 +373,7 @@ A few bundled plugins accept optional API keys for higher rate limits. Set these
|
|
|
373
373
|
|---|---|
|
|
374
374
|
| `META_DATA_MCP_PRELOAD` | Comma-separated plugin ids to activate at startup, or `*` for all. Default unset = discovery-only (~13 meta tools). |
|
|
375
375
|
| `META_DATA_MCP_AUTH_TOKEN` | When set on the SSE transport, requires `Authorization: Bearer <token>` on `/sse` and `/messages`. |
|
|
376
|
+
| `META_DATA_MCP_OAUTH_ISSUER` | Enable OAuth 2.0 Authorization Code + PKCE. Set to the server's public base URL (e.g. `http://localhost:8000`). Mounts `/.well-known/oauth-authorization-server`, `/register`, `/authorize`, `/token`, `/revoke`, and a consent page at `/oauth/consent`. Coexists with `META_DATA_MCP_AUTH_TOKEN` — both auth methods remain valid simultaneously. |
|
|
376
377
|
| `META_DATA_MCP_PROVENANCE` | Truthy (`1`, `true`, `yes`, `on`) enables a `meta-data-mcp/provenance` entry on every tool-call result's first content block's `_meta`, carrying `sha256` and `timestamp` (ISO 8601 UTC, ms precision). The digest covers the canonical `(tool, arguments, content)` envelope — content blocks dumped via `model_dump(mode="json", by_alias=True, exclude_none=True)` with `_meta` stripped, JSON-serialized with `sort_keys=True, separators=(",",":"), ensure_ascii=True`. Binding tool name + arguments into the hash means audit logs can distinguish "tool A returned X" from "tool B returned X". Default off — opt in when you need tamper-evidence. See `meta_data_mcp/provenance.py` module docstring for the verbatim receiver recipe. |
|
|
377
378
|
|
|
378
379
|
## Transports
|
|
@@ -323,6 +323,7 @@ A few bundled plugins accept optional API keys for higher rate limits. Set these
|
|
|
323
323
|
|---|---|
|
|
324
324
|
| `META_DATA_MCP_PRELOAD` | Comma-separated plugin ids to activate at startup, or `*` for all. Default unset = discovery-only (~13 meta tools). |
|
|
325
325
|
| `META_DATA_MCP_AUTH_TOKEN` | When set on the SSE transport, requires `Authorization: Bearer <token>` on `/sse` and `/messages`. |
|
|
326
|
+
| `META_DATA_MCP_OAUTH_ISSUER` | Enable OAuth 2.0 Authorization Code + PKCE. Set to the server's public base URL (e.g. `http://localhost:8000`). Mounts `/.well-known/oauth-authorization-server`, `/register`, `/authorize`, `/token`, `/revoke`, and a consent page at `/oauth/consent`. Coexists with `META_DATA_MCP_AUTH_TOKEN` — both auth methods remain valid simultaneously. |
|
|
326
327
|
| `META_DATA_MCP_PROVENANCE` | Truthy (`1`, `true`, `yes`, `on`) enables a `meta-data-mcp/provenance` entry on every tool-call result's first content block's `_meta`, carrying `sha256` and `timestamp` (ISO 8601 UTC, ms precision). The digest covers the canonical `(tool, arguments, content)` envelope — content blocks dumped via `model_dump(mode="json", by_alias=True, exclude_none=True)` with `_meta` stripped, JSON-serialized with `sort_keys=True, separators=(",",":"), ensure_ascii=True`. Binding tool name + arguments into the hash means audit logs can distinguish "tool A returned X" from "tool B returned X". Default off — opt in when you need tamper-evidence. See `meta_data_mcp/provenance.py` module docstring for the verbatim receiver recipe. |
|
|
327
328
|
|
|
328
329
|
## Transports
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "2.3.2"
|
|
@@ -0,0 +1,270 @@
|
|
|
1
|
+
"""In-memory OAuth 2.0 Authorization Server provider for meta-data-mcp.
|
|
2
|
+
|
|
3
|
+
Implements the MCP SDK's ``OAuthAuthorizationServerProvider`` Protocol with
|
|
4
|
+
full in-memory storage (tokens are lost on server restart). Suitable for
|
|
5
|
+
development, single-node deployments, and operator-managed SSE servers.
|
|
6
|
+
|
|
7
|
+
Enable OAuth by setting ``META_DATA_MCP_OAUTH_ISSUER`` (e.g.
|
|
8
|
+
``http://localhost:8000``). This coexists with the existing bearer-token
|
|
9
|
+
auth (``META_DATA_MCP_AUTH_TOKEN``) — both remain valid simultaneously.
|
|
10
|
+
|
|
11
|
+
Flow (Authorization Code + PKCE + Dynamic Client Registration):
|
|
12
|
+
1. Client POSTs to ``/register`` → receives ``client_id`` + ``client_secret``
|
|
13
|
+
2. Client opens ``/authorize?client_id=…&code_challenge=…&redirect_uri=…``
|
|
14
|
+
3. User is redirected to ``/oauth/consent?session=…``
|
|
15
|
+
4. User approves → ``/oauth/consent/approve`` creates an auth code and
|
|
16
|
+
redirects the browser to the client's ``redirect_uri?code=…&state=…``
|
|
17
|
+
5. Client POSTs to ``/token`` with ``code`` + ``code_verifier`` → tokens
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
from __future__ import annotations
|
|
21
|
+
|
|
22
|
+
import base64
|
|
23
|
+
import hashlib
|
|
24
|
+
import secrets
|
|
25
|
+
import time
|
|
26
|
+
from typing import Any
|
|
27
|
+
|
|
28
|
+
from mcp.server.auth.provider import (
|
|
29
|
+
AccessToken,
|
|
30
|
+
AuthorizationCode,
|
|
31
|
+
AuthorizationParams,
|
|
32
|
+
OAuthAuthorizationServerProvider,
|
|
33
|
+
RefreshToken,
|
|
34
|
+
)
|
|
35
|
+
from mcp.shared.auth import OAuthClientInformationFull, OAuthToken
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class InMemoryOAuthProvider(
|
|
39
|
+
OAuthAuthorizationServerProvider[AuthorizationCode, RefreshToken, AccessToken]
|
|
40
|
+
):
|
|
41
|
+
"""Stateful in-memory OAuth provider.
|
|
42
|
+
|
|
43
|
+
All state is local to the process — tokens are lost on restart.
|
|
44
|
+
Thread-safe for asyncio (single-threaded event loop) but not safe for
|
|
45
|
+
multi-process deployments; use a shared cache (Redis/Postgres) for HA.
|
|
46
|
+
"""
|
|
47
|
+
|
|
48
|
+
def __init__(self, issuer_url: str) -> None:
|
|
49
|
+
self.issuer_url = issuer_url.rstrip("/")
|
|
50
|
+
# Storage maps: key → object
|
|
51
|
+
self._clients: dict[str, OAuthClientInformationFull] = {}
|
|
52
|
+
self._auth_sessions: dict[str, dict[str, Any]] = {} # consent-page sessions
|
|
53
|
+
self._auth_codes: dict[str, AuthorizationCode] = {}
|
|
54
|
+
self._access_tokens: dict[str, AccessToken] = {}
|
|
55
|
+
self._refresh_tokens: dict[str, RefreshToken] = {}
|
|
56
|
+
|
|
57
|
+
# ------------------------------------------------------------------
|
|
58
|
+
# Client management
|
|
59
|
+
# ------------------------------------------------------------------
|
|
60
|
+
|
|
61
|
+
async def get_client(self, client_id: str) -> OAuthClientInformationFull | None:
|
|
62
|
+
return self._clients.get(client_id)
|
|
63
|
+
|
|
64
|
+
async def register_client(self, client_info: OAuthClientInformationFull) -> None:
|
|
65
|
+
if client_info.client_id is None:
|
|
66
|
+
raise ValueError("client_id is required")
|
|
67
|
+
self._clients[client_info.client_id] = client_info
|
|
68
|
+
|
|
69
|
+
# ------------------------------------------------------------------
|
|
70
|
+
# Authorization (consent page redirect)
|
|
71
|
+
# ------------------------------------------------------------------
|
|
72
|
+
|
|
73
|
+
async def authorize(
|
|
74
|
+
self, client: OAuthClientInformationFull, params: AuthorizationParams
|
|
75
|
+
) -> str:
|
|
76
|
+
"""Create a short-lived consent session and return the consent page URL."""
|
|
77
|
+
session_token = secrets.token_urlsafe(32)
|
|
78
|
+
self._auth_sessions[session_token] = {
|
|
79
|
+
"client_id": client.client_id,
|
|
80
|
+
"client_name": getattr(client, "client_name", client.client_id),
|
|
81
|
+
"code_challenge": params.code_challenge,
|
|
82
|
+
"redirect_uri": str(params.redirect_uri),
|
|
83
|
+
"redirect_uri_provided_explicitly": params.redirect_uri_provided_explicitly,
|
|
84
|
+
"scopes": params.scopes or [],
|
|
85
|
+
"state": params.state,
|
|
86
|
+
"expires_at": time.time() + 600, # 10-minute consent window
|
|
87
|
+
}
|
|
88
|
+
return f"{self.issuer_url}/oauth/consent?session={session_token}"
|
|
89
|
+
|
|
90
|
+
def peek_session(self, session_token: str) -> dict[str, Any] | None:
|
|
91
|
+
"""Return a shallow copy of a pending consent session without consuming it.
|
|
92
|
+
|
|
93
|
+
Used by the GET consent page to display session details while leaving
|
|
94
|
+
the session intact for the POST approval step. Returns a shallow copy
|
|
95
|
+
so callers cannot accidentally mutate internal provider state.
|
|
96
|
+
Returns None for unknown or expired sessions.
|
|
97
|
+
"""
|
|
98
|
+
session = self._auth_sessions.get(session_token)
|
|
99
|
+
if session is None:
|
|
100
|
+
return None
|
|
101
|
+
if time.time() > session["expires_at"]:
|
|
102
|
+
self._auth_sessions.pop(session_token, None)
|
|
103
|
+
return None
|
|
104
|
+
return dict(session) # shallow copy — callers cannot mutate stored state
|
|
105
|
+
|
|
106
|
+
def consume_session(self, session_token: str) -> dict[str, Any] | None:
|
|
107
|
+
"""Retrieve and remove a pending consent session (one-shot)."""
|
|
108
|
+
session = self._auth_sessions.pop(session_token, None)
|
|
109
|
+
if session is None:
|
|
110
|
+
return None
|
|
111
|
+
if time.time() > session["expires_at"]:
|
|
112
|
+
return None
|
|
113
|
+
return session
|
|
114
|
+
|
|
115
|
+
def create_authorization_code(self, session: dict[str, Any]) -> str:
|
|
116
|
+
"""Issue an authorization code from an approved consent session."""
|
|
117
|
+
code = secrets.token_urlsafe(32)
|
|
118
|
+
self._auth_codes[code] = AuthorizationCode(
|
|
119
|
+
code=code,
|
|
120
|
+
scopes=session["scopes"],
|
|
121
|
+
expires_at=time.time() + 300, # 5-minute code lifetime
|
|
122
|
+
client_id=session["client_id"],
|
|
123
|
+
code_challenge=session["code_challenge"],
|
|
124
|
+
redirect_uri=session["redirect_uri"], # type: ignore[arg-type]
|
|
125
|
+
redirect_uri_provided_explicitly=session[
|
|
126
|
+
"redirect_uri_provided_explicitly"
|
|
127
|
+
],
|
|
128
|
+
)
|
|
129
|
+
return code
|
|
130
|
+
|
|
131
|
+
# ------------------------------------------------------------------
|
|
132
|
+
# Authorization code exchange
|
|
133
|
+
# ------------------------------------------------------------------
|
|
134
|
+
|
|
135
|
+
async def load_authorization_code(
|
|
136
|
+
self,
|
|
137
|
+
client: OAuthClientInformationFull,
|
|
138
|
+
authorization_code: str,
|
|
139
|
+
) -> AuthorizationCode | None:
|
|
140
|
+
code = self._auth_codes.get(authorization_code)
|
|
141
|
+
if code is None:
|
|
142
|
+
return None
|
|
143
|
+
if time.time() > code.expires_at:
|
|
144
|
+
del self._auth_codes[authorization_code]
|
|
145
|
+
return None
|
|
146
|
+
if code.client_id != client.client_id:
|
|
147
|
+
return None
|
|
148
|
+
return code
|
|
149
|
+
|
|
150
|
+
async def exchange_authorization_code(
|
|
151
|
+
self,
|
|
152
|
+
client: OAuthClientInformationFull,
|
|
153
|
+
authorization_code: AuthorizationCode,
|
|
154
|
+
) -> OAuthToken:
|
|
155
|
+
"""Consume the authorization code and issue access + refresh tokens.
|
|
156
|
+
|
|
157
|
+
PKCE validation (code_verifier vs code_challenge) is performed by the
|
|
158
|
+
MCP SDK's ``TokenHandler`` before calling this method — this provider
|
|
159
|
+
does not repeat that check.
|
|
160
|
+
"""
|
|
161
|
+
# Remove the used code (one-shot).
|
|
162
|
+
self._auth_codes.pop(authorization_code.code, None)
|
|
163
|
+
|
|
164
|
+
access_token_str = secrets.token_urlsafe(32)
|
|
165
|
+
refresh_token_str = secrets.token_urlsafe(32)
|
|
166
|
+
|
|
167
|
+
client_id = client.client_id or ""
|
|
168
|
+
self._access_tokens[access_token_str] = AccessToken(
|
|
169
|
+
token=access_token_str,
|
|
170
|
+
client_id=client_id,
|
|
171
|
+
scopes=authorization_code.scopes,
|
|
172
|
+
expires_at=int(time.time()) + 3600, # 1-hour access token
|
|
173
|
+
)
|
|
174
|
+
self._refresh_tokens[refresh_token_str] = RefreshToken(
|
|
175
|
+
token=refresh_token_str,
|
|
176
|
+
client_id=client_id,
|
|
177
|
+
scopes=authorization_code.scopes,
|
|
178
|
+
)
|
|
179
|
+
|
|
180
|
+
return OAuthToken(
|
|
181
|
+
access_token=access_token_str,
|
|
182
|
+
token_type="Bearer",
|
|
183
|
+
expires_in=3600,
|
|
184
|
+
refresh_token=refresh_token_str,
|
|
185
|
+
scope=" ".join(authorization_code.scopes),
|
|
186
|
+
)
|
|
187
|
+
|
|
188
|
+
# ------------------------------------------------------------------
|
|
189
|
+
# Refresh token exchange
|
|
190
|
+
# ------------------------------------------------------------------
|
|
191
|
+
|
|
192
|
+
async def load_refresh_token(
|
|
193
|
+
self,
|
|
194
|
+
client: OAuthClientInformationFull,
|
|
195
|
+
refresh_token: str,
|
|
196
|
+
) -> RefreshToken | None:
|
|
197
|
+
rt = self._refresh_tokens.get(refresh_token)
|
|
198
|
+
if rt is None or rt.client_id != client.client_id:
|
|
199
|
+
return None
|
|
200
|
+
return rt
|
|
201
|
+
|
|
202
|
+
async def exchange_refresh_token(
|
|
203
|
+
self,
|
|
204
|
+
client: OAuthClientInformationFull,
|
|
205
|
+
refresh_token: RefreshToken,
|
|
206
|
+
scopes: list[str],
|
|
207
|
+
) -> OAuthToken:
|
|
208
|
+
"""Issue a new access token (and rotate the refresh token)."""
|
|
209
|
+
# Invalidate the old refresh token.
|
|
210
|
+
self._refresh_tokens.pop(refresh_token.token, None)
|
|
211
|
+
|
|
212
|
+
effective_scopes = scopes or refresh_token.scopes
|
|
213
|
+
new_access = secrets.token_urlsafe(32)
|
|
214
|
+
new_refresh = secrets.token_urlsafe(32)
|
|
215
|
+
client_id = client.client_id or ""
|
|
216
|
+
|
|
217
|
+
self._access_tokens[new_access] = AccessToken(
|
|
218
|
+
token=new_access,
|
|
219
|
+
client_id=client_id,
|
|
220
|
+
scopes=effective_scopes,
|
|
221
|
+
expires_at=int(time.time()) + 3600,
|
|
222
|
+
)
|
|
223
|
+
self._refresh_tokens[new_refresh] = RefreshToken(
|
|
224
|
+
token=new_refresh,
|
|
225
|
+
client_id=client_id,
|
|
226
|
+
scopes=effective_scopes,
|
|
227
|
+
)
|
|
228
|
+
|
|
229
|
+
return OAuthToken(
|
|
230
|
+
access_token=new_access,
|
|
231
|
+
token_type="Bearer",
|
|
232
|
+
expires_in=3600,
|
|
233
|
+
refresh_token=new_refresh,
|
|
234
|
+
scope=" ".join(effective_scopes),
|
|
235
|
+
)
|
|
236
|
+
|
|
237
|
+
# ------------------------------------------------------------------
|
|
238
|
+
# Access token verification
|
|
239
|
+
# ------------------------------------------------------------------
|
|
240
|
+
|
|
241
|
+
async def verify_access_token(self, token: str) -> AccessToken | None:
|
|
242
|
+
at = self._access_tokens.get(token)
|
|
243
|
+
if at is None:
|
|
244
|
+
return None
|
|
245
|
+
if at.expires_at is not None and time.time() > at.expires_at:
|
|
246
|
+
del self._access_tokens[token]
|
|
247
|
+
return None
|
|
248
|
+
return at
|
|
249
|
+
|
|
250
|
+
# ------------------------------------------------------------------
|
|
251
|
+
# Token revocation
|
|
252
|
+
# ------------------------------------------------------------------
|
|
253
|
+
|
|
254
|
+
async def revoke_token(
|
|
255
|
+
self,
|
|
256
|
+
token: AccessToken | RefreshToken,
|
|
257
|
+
) -> None:
|
|
258
|
+
self._access_tokens.pop(token.token, None)
|
|
259
|
+
self._refresh_tokens.pop(token.token, None)
|
|
260
|
+
|
|
261
|
+
|
|
262
|
+
# ---------------------------------------------------------------------------
|
|
263
|
+
# PKCE helper (used by tests; not part of the Protocol)
|
|
264
|
+
# ---------------------------------------------------------------------------
|
|
265
|
+
|
|
266
|
+
|
|
267
|
+
def compute_pkce_challenge(code_verifier: str) -> str:
|
|
268
|
+
"""Return the S256 code_challenge for a given verifier."""
|
|
269
|
+
digest = hashlib.sha256(code_verifier.encode()).digest()
|
|
270
|
+
return base64.urlsafe_b64encode(digest).rstrip(b"=").decode()
|
|
@@ -73,11 +73,30 @@ class RemoteClient:
|
|
|
73
73
|
base_url: str,
|
|
74
74
|
token: str | None = None,
|
|
75
75
|
timeout: float = 30.0,
|
|
76
|
+
auth: Any = None,
|
|
76
77
|
) -> None:
|
|
78
|
+
"""
|
|
79
|
+
Args:
|
|
80
|
+
base_url: Root URL of the server (e.g. ``"https://mcp.example.com"``).
|
|
81
|
+
The ``/sse`` path is appended automatically.
|
|
82
|
+
token: Bearer token for the server's auth middleware.
|
|
83
|
+
Falls back to ``META_DATA_MCP_AUTH_TOKEN`` env var.
|
|
84
|
+
Mutually exclusive with ``auth``.
|
|
85
|
+
timeout: Connection timeout in seconds (default 30 s).
|
|
86
|
+
auth: MCP-compatible OAuth client provider for Authorization Code
|
|
87
|
+
+ PKCE flows. Accepts any object implementing the
|
|
88
|
+
``mcp.client.sse`` ``auth=`` interface (e.g. a custom
|
|
89
|
+
``OAuthClientProvider``). Mutually exclusive with ``token``.
|
|
90
|
+
"""
|
|
91
|
+
if token is not None and auth is not None:
|
|
92
|
+
raise ValueError(
|
|
93
|
+
"token and auth are mutually exclusive — use one or the other"
|
|
94
|
+
)
|
|
77
95
|
self._base_url = base_url.rstrip("/")
|
|
78
96
|
self._token = (
|
|
79
97
|
token if token is not None else os.getenv("META_DATA_MCP_AUTH_TOKEN")
|
|
80
98
|
)
|
|
99
|
+
self._auth = auth # mcp-compatible OAuthClientProvider, or None
|
|
81
100
|
self._timeout = timeout
|
|
82
101
|
self._session: Any = None # mcp.client.session.ClientSession when active
|
|
83
102
|
self._exit_stack: AsyncExitStack | None = None
|
|
@@ -91,13 +110,15 @@ class RemoteClient:
|
|
|
91
110
|
from mcp.client.sse import sse_client
|
|
92
111
|
|
|
93
112
|
self._exit_stack = AsyncExitStack()
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
113
|
+
sse_kwargs: dict[str, Any] = {
|
|
114
|
+
"url": f"{self._base_url}/sse",
|
|
115
|
+
"timeout": self._timeout,
|
|
116
|
+
}
|
|
117
|
+
if self._auth is not None:
|
|
118
|
+
sse_kwargs["auth"] = self._auth
|
|
119
|
+
else:
|
|
120
|
+
sse_kwargs["headers"] = self._headers()
|
|
121
|
+
streams = await self._exit_stack.enter_async_context(sse_client(**sse_kwargs))
|
|
101
122
|
self._session = await self._exit_stack.enter_async_context(
|
|
102
123
|
ClientSession(*streams)
|
|
103
124
|
)
|
|
@@ -131,11 +152,15 @@ class RemoteClient:
|
|
|
131
152
|
from mcp.client.session import ClientSession
|
|
132
153
|
from mcp.client.sse import sse_client
|
|
133
154
|
|
|
134
|
-
|
|
135
|
-
url
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
155
|
+
one_shot_kwargs: dict[str, Any] = {
|
|
156
|
+
"url": f"{self._base_url}/sse",
|
|
157
|
+
"timeout": self._timeout,
|
|
158
|
+
}
|
|
159
|
+
if self._auth is not None:
|
|
160
|
+
one_shot_kwargs["auth"] = self._auth
|
|
161
|
+
else:
|
|
162
|
+
one_shot_kwargs["headers"] = self._headers()
|
|
163
|
+
async with sse_client(**one_shot_kwargs) as streams:
|
|
139
164
|
async with ClientSession(*streams) as session:
|
|
140
165
|
await session.initialize()
|
|
141
166
|
result = await session.call_tool(name, arguments)
|