dataquery-sdk 1.2.5__tar.gz → 1.2.7__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.
- {dataquery_sdk-1.2.5 → dataquery_sdk-1.2.7}/CHANGELOG.md +6 -3
- {dataquery_sdk-1.2.5 → dataquery_sdk-1.2.7}/PKG-INFO +73 -5
- {dataquery_sdk-1.2.5 → dataquery_sdk-1.2.7}/README.md +70 -2
- {dataquery_sdk-1.2.5 → dataquery_sdk-1.2.7}/dataquery/__init__.py +1 -1
- {dataquery_sdk-1.2.5 → dataquery_sdk-1.2.7}/dataquery/cli.py +141 -0
- {dataquery_sdk-1.2.5 → dataquery_sdk-1.2.7}/dataquery/config/env.py +14 -3
- {dataquery_sdk-1.2.5 → dataquery_sdk-1.2.7}/dataquery/constants/__init__.py +2 -1
- dataquery_sdk-1.2.7/dataquery/constants/rate_limit.py +9 -0
- {dataquery_sdk-1.2.5 → dataquery_sdk-1.2.7}/dataquery/core/client.py +13 -6
- dataquery_sdk-1.2.7/dataquery/mcp_install.py +198 -0
- {dataquery_sdk-1.2.5 → dataquery_sdk-1.2.7}/dataquery/sse/client.py +3 -3
- {dataquery_sdk-1.2.5 → dataquery_sdk-1.2.7}/dataquery/transport/rate_limiter.py +11 -7
- {dataquery_sdk-1.2.5 → dataquery_sdk-1.2.7}/dataquery/types/models.py +48 -9
- {dataquery_sdk-1.2.5 → dataquery_sdk-1.2.7}/dataquery/utils.py +0 -3
- {dataquery_sdk-1.2.5 → dataquery_sdk-1.2.7}/dataquery_sdk.egg-info/PKG-INFO +73 -5
- {dataquery_sdk-1.2.5 → dataquery_sdk-1.2.7}/dataquery_sdk.egg-info/SOURCES.txt +1 -0
- {dataquery_sdk-1.2.5 → dataquery_sdk-1.2.7}/dataquery_sdk.egg-info/requires.txt +2 -2
- {dataquery_sdk-1.2.5 → dataquery_sdk-1.2.7}/pyproject.toml +2 -2
- dataquery_sdk-1.2.5/dataquery/constants/rate_limit.py +0 -5
- {dataquery_sdk-1.2.5 → dataquery_sdk-1.2.7}/CONTRIBUTING.md +0 -0
- {dataquery_sdk-1.2.5 → dataquery_sdk-1.2.7}/LICENSE +0 -0
- {dataquery_sdk-1.2.5 → dataquery_sdk-1.2.7}/MANIFEST.in +0 -0
- {dataquery_sdk-1.2.5 → dataquery_sdk-1.2.7}/dataquery/config/__init__.py +0 -0
- {dataquery_sdk-1.2.5 → dataquery_sdk-1.2.7}/dataquery/config/logging.py +0 -0
- {dataquery_sdk-1.2.5 → dataquery_sdk-1.2.7}/dataquery/constants/api.py +0 -0
- {dataquery_sdk-1.2.5 → dataquery_sdk-1.2.7}/dataquery/constants/download.py +0 -0
- {dataquery_sdk-1.2.5 → dataquery_sdk-1.2.7}/dataquery/constants/sse.py +0 -0
- {dataquery_sdk-1.2.5 → dataquery_sdk-1.2.7}/dataquery/core/__init__.py +0 -0
- {dataquery_sdk-1.2.5 → dataquery_sdk-1.2.7}/dataquery/core/_mixins.py +0 -0
- {dataquery_sdk-1.2.5 → dataquery_sdk-1.2.7}/dataquery/core/_sync.py +0 -0
- {dataquery_sdk-1.2.5 → dataquery_sdk-1.2.7}/dataquery/data/function.json +0 -0
- {dataquery_sdk-1.2.5 → dataquery_sdk-1.2.7}/dataquery/dataquery.py +0 -0
- {dataquery_sdk-1.2.5 → dataquery_sdk-1.2.7}/dataquery/download/__init__.py +0 -0
- {dataquery_sdk-1.2.5 → dataquery_sdk-1.2.7}/dataquery/download/parallel.py +0 -0
- {dataquery_sdk-1.2.5 → dataquery_sdk-1.2.7}/dataquery/download/utils.py +0 -0
- {dataquery_sdk-1.2.5 → dataquery_sdk-1.2.7}/dataquery/export.py +0 -0
- {dataquery_sdk-1.2.5 → dataquery_sdk-1.2.7}/dataquery/function_registry.py +0 -0
- {dataquery_sdk-1.2.5 → dataquery_sdk-1.2.7}/dataquery/mcp_proxy.py +0 -0
- {dataquery_sdk-1.2.5 → dataquery_sdk-1.2.7}/dataquery/py.typed +0 -0
- {dataquery_sdk-1.2.5 → dataquery_sdk-1.2.7}/dataquery/sse/__init__.py +0 -0
- {dataquery_sdk-1.2.5 → dataquery_sdk-1.2.7}/dataquery/sse/event_store.py +0 -0
- {dataquery_sdk-1.2.5 → dataquery_sdk-1.2.7}/dataquery/sse/subscriber.py +0 -0
- {dataquery_sdk-1.2.5 → dataquery_sdk-1.2.7}/dataquery/transport/__init__.py +0 -0
- {dataquery_sdk-1.2.5 → dataquery_sdk-1.2.7}/dataquery/transport/auth.py +0 -0
- {dataquery_sdk-1.2.5 → dataquery_sdk-1.2.7}/dataquery/transport/connection_pool.py +0 -0
- {dataquery_sdk-1.2.5 → dataquery_sdk-1.2.7}/dataquery/transport/retry.py +0 -0
- {dataquery_sdk-1.2.5 → dataquery_sdk-1.2.7}/dataquery/types/__init__.py +0 -0
- {dataquery_sdk-1.2.5 → dataquery_sdk-1.2.7}/dataquery/types/exceptions.py +0 -0
- {dataquery_sdk-1.2.5 → dataquery_sdk-1.2.7}/dataquery_sdk.egg-info/dependency_links.txt +0 -0
- {dataquery_sdk-1.2.5 → dataquery_sdk-1.2.7}/dataquery_sdk.egg-info/entry_points.txt +0 -0
- {dataquery_sdk-1.2.5 → dataquery_sdk-1.2.7}/dataquery_sdk.egg-info/top_level.txt +0 -0
- {dataquery_sdk-1.2.5 → dataquery_sdk-1.2.7}/setup.cfg +0 -0
|
@@ -54,6 +54,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
54
54
|
## [1.2.4] - 2026-08-12
|
|
55
55
|
- MCP: `mcp-connect` accepts `--client-id`, `--client-secret` and `--bearer-token` and exports them into the `DATAQUERY_*` environment of the process, so the MCP bridge and the SDK share one credential set instead of each needing its own setup
|
|
56
56
|
## [1.2.5] - 2026-08-21
|
|
57
|
-
- MCP: `mcp-connect --url` is now optional
|
|
58
|
-
|
|
59
|
-
|
|
57
|
+
- MCP: `mcp-connect --url` is now optional
|
|
58
|
+
## [1.2.6] - 2026-09-24
|
|
59
|
+
- Bug fixes
|
|
60
|
+
- MCP: capped the `mcp` extra to `fastmcp>=2.14,<4` — fastmcp 4.x repackages onto `httpx2` and drops `FastMCP.as_proxy()`, which broke `mcp-connect`; installs now resolve to the 3.x line
|
|
61
|
+
## [1.2.7] - 2026-09-25
|
|
62
|
+
- MCP: new `dataquery mcp-install` one-time setup — saves your credentials to `~/.dataquery/.env` (prompting on a terminal, the secret unechoed) and adds a secret-free server running this environment's `mcp-connect` to Claude Desktop (default) or, with `--app`, Claude Code (user scope), ChatGPT desktop / Codex (`~/.codex/config.toml`), Cursor or VS Code, or to any `mcpServers` JSON file (`--config-file`); re-running updates it
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: dataquery-sdk
|
|
3
|
-
Version: 1.2.
|
|
3
|
+
Version: 1.2.7
|
|
4
4
|
Summary: Python SDK for DATAQUERY Data API - Query, download, and check availability of economic data files
|
|
5
5
|
Author-email: DATAQUERY SDK Team <dataquery_support@jpmorgan.com>
|
|
6
6
|
Project-URL: Homepage, https://github.com/jpmorganchase/dataquery-sdk
|
|
@@ -50,7 +50,7 @@ Requires-Dist: pymdown-extensions>=11.0.0; extra == "docs"
|
|
|
50
50
|
Provides-Extra: pandas
|
|
51
51
|
Requires-Dist: pandas>=2.0.0; extra == "pandas"
|
|
52
52
|
Provides-Extra: mcp
|
|
53
|
-
Requires-Dist: fastmcp
|
|
53
|
+
Requires-Dist: fastmcp<4,>=2.14; extra == "mcp"
|
|
54
54
|
Provides-Extra: all
|
|
55
55
|
Requires-Dist: pytest>=7.0.0; extra == "all"
|
|
56
56
|
Requires-Dist: pytest-asyncio>=0.21.0; extra == "all"
|
|
@@ -63,7 +63,7 @@ Requires-Dist: mkdocs-material>=9.0.0; extra == "all"
|
|
|
63
63
|
Requires-Dist: mkdocstrings[python]>=0.24.0; extra == "all"
|
|
64
64
|
Requires-Dist: mike>=1.1.0; extra == "all"
|
|
65
65
|
Requires-Dist: pandas>=2.0.0; extra == "all"
|
|
66
|
-
Requires-Dist: fastmcp
|
|
66
|
+
Requires-Dist: fastmcp<4,>=2.14; extra == "all"
|
|
67
67
|
Requires-Dist: pip-audit>=2.7.0; extra == "all"
|
|
68
68
|
Requires-Dist: build>=1.3.0; extra == "all"
|
|
69
69
|
Requires-Dist: urllib3>=2.7.0; extra == "all"
|
|
@@ -467,8 +467,39 @@ pip install "dataquery-sdk[mcp]"
|
|
|
467
467
|
|
|
468
468
|
### Wire it into an MCP client
|
|
469
469
|
|
|
470
|
-
|
|
471
|
-
|
|
470
|
+
**One-time setup (recommended).** Install, then run `mcp-install` once:
|
|
471
|
+
|
|
472
|
+
```bash
|
|
473
|
+
pip install "dataquery-sdk[mcp]"
|
|
474
|
+
dataquery mcp-install # Claude Desktop (the default)
|
|
475
|
+
dataquery mcp-install --app <app> # one of the apps below
|
|
476
|
+
dataquery mcp-install --config-file ~/.some-app/mcp.json # any other app with an mcpServers JSON file
|
|
477
|
+
```
|
|
478
|
+
|
|
479
|
+
| `--app` | Where the server goes |
|
|
480
|
+
|---|---|
|
|
481
|
+
| `claude-desktop` | Claude Desktop's `claude_desktop_config.json` |
|
|
482
|
+
| `claude-code` | Claude Code, user scope (via `claude mcp add-json`) |
|
|
483
|
+
| `chatgpt` | `~/.codex/config.toml`, which the ChatGPT desktop app shares with the Codex CLI and IDE extension; ChatGPT on the web can't run local servers |
|
|
484
|
+
| `cursor` | `~/.cursor/mcp.json` |
|
|
485
|
+
| `vscode` | VS Code's user-profile `mcp.json` (default profile) |
|
|
486
|
+
|
|
487
|
+
It prompts for your client ID and secret (the secret without echo) and saves
|
|
488
|
+
them to `~/.dataquery/.env`, owner-only (see [Credentials](#credentials)). It
|
|
489
|
+
then adds a `dataquery` server to the app's user config. The entry runs this
|
|
490
|
+
environment's `dataquery mcp-connect` by absolute path, because GUI apps don't
|
|
491
|
+
see your shell's `PATH`, and it contains no secrets. Other servers and settings
|
|
492
|
+
in the file are kept. Restart the app to load it.
|
|
493
|
+
|
|
494
|
+
- Re-run it any time to update the credentials or the entry.
|
|
495
|
+
- `--client-id`/`--client-secret` or `--bearer-token` skip the prompt, but flags
|
|
496
|
+
are visible in the process list.
|
|
497
|
+
- `--url` and `--name` register another environment alongside the first.
|
|
498
|
+
- Run it from an environment you keep (pip or pipx), not a throwaway `uvx` one,
|
|
499
|
+
because the entry points at that environment's executable.
|
|
500
|
+
|
|
501
|
+
**Manual setup.** Or add the server to your client's MCP config yourself
|
|
502
|
+
(`claude_desktop_config.json`, `.mcp.json`, or the equivalent for your host):
|
|
472
503
|
|
|
473
504
|
```json
|
|
474
505
|
{
|
|
@@ -639,6 +670,43 @@ async with DataQuery(client_id="...", client_secret="...", timeout=60.0) as dq:
|
|
|
639
670
|
...
|
|
640
671
|
```
|
|
641
672
|
|
|
673
|
+
### Custom request headers
|
|
674
|
+
|
|
675
|
+
Headers are configured per client, on `ClientConfig` or as `DataQuery(...)`
|
|
676
|
+
kwargs, and are never read from the environment, so several clients in one
|
|
677
|
+
process can identify themselves differently. They are sent on every DataQuery
|
|
678
|
+
API request (JSON, file and SSE), but not on the OAuth token request.
|
|
679
|
+
|
|
680
|
+
```python
|
|
681
|
+
config = ClientConfig(
|
|
682
|
+
client_id="...",
|
|
683
|
+
client_secret="...",
|
|
684
|
+
custom_headers={
|
|
685
|
+
"X-User-Agent": "RiskEngine/2.1",
|
|
686
|
+
"X-Team": "rates",
|
|
687
|
+
"X-Request-Source": "nightly-batch",
|
|
688
|
+
},
|
|
689
|
+
)
|
|
690
|
+
|
|
691
|
+
async with DataQuery(config) as dq:
|
|
692
|
+
...
|
|
693
|
+
|
|
694
|
+
# Or as kwargs:
|
|
695
|
+
async with DataQuery(custom_headers={"X-User-Agent": "RiskEngine/2.1"}) as dq:
|
|
696
|
+
...
|
|
697
|
+
```
|
|
698
|
+
|
|
699
|
+
- A custom header replaces an SDK default of the same name (e.g. `User-Agent`),
|
|
700
|
+
but never `Authorization`, which comes from `client_id`/`client_secret` or
|
|
701
|
+
`bearer_token`, and never the SSE stream's `Accept` / `Last-Event-ID`.
|
|
702
|
+
- Invalid headers (a bad name, CR/LF in a value, a name repeated in another
|
|
703
|
+
case, `Authorization`) raise `ConfigurationError` before the first request.
|
|
704
|
+
The error names the header but never shows its value.
|
|
705
|
+
- Kwarg overrides are written onto the `ClientConfig` you pass in, so give each
|
|
706
|
+
client its own config instead of sharing one.
|
|
707
|
+
- The `x_user_agent` option and `DATAQUERY_X_USER_AGENT` are gone; send
|
|
708
|
+
`X-User-Agent` through `custom_headers` like any other header.
|
|
709
|
+
|
|
642
710
|
## Logging
|
|
643
711
|
|
|
644
712
|
The SDK logs through [structlog](https://www.structlog.org/) and emits
|
|
@@ -395,8 +395,39 @@ pip install "dataquery-sdk[mcp]"
|
|
|
395
395
|
|
|
396
396
|
### Wire it into an MCP client
|
|
397
397
|
|
|
398
|
-
|
|
399
|
-
|
|
398
|
+
**One-time setup (recommended).** Install, then run `mcp-install` once:
|
|
399
|
+
|
|
400
|
+
```bash
|
|
401
|
+
pip install "dataquery-sdk[mcp]"
|
|
402
|
+
dataquery mcp-install # Claude Desktop (the default)
|
|
403
|
+
dataquery mcp-install --app <app> # one of the apps below
|
|
404
|
+
dataquery mcp-install --config-file ~/.some-app/mcp.json # any other app with an mcpServers JSON file
|
|
405
|
+
```
|
|
406
|
+
|
|
407
|
+
| `--app` | Where the server goes |
|
|
408
|
+
|---|---|
|
|
409
|
+
| `claude-desktop` | Claude Desktop's `claude_desktop_config.json` |
|
|
410
|
+
| `claude-code` | Claude Code, user scope (via `claude mcp add-json`) |
|
|
411
|
+
| `chatgpt` | `~/.codex/config.toml`, which the ChatGPT desktop app shares with the Codex CLI and IDE extension; ChatGPT on the web can't run local servers |
|
|
412
|
+
| `cursor` | `~/.cursor/mcp.json` |
|
|
413
|
+
| `vscode` | VS Code's user-profile `mcp.json` (default profile) |
|
|
414
|
+
|
|
415
|
+
It prompts for your client ID and secret (the secret without echo) and saves
|
|
416
|
+
them to `~/.dataquery/.env`, owner-only (see [Credentials](#credentials)). It
|
|
417
|
+
then adds a `dataquery` server to the app's user config. The entry runs this
|
|
418
|
+
environment's `dataquery mcp-connect` by absolute path, because GUI apps don't
|
|
419
|
+
see your shell's `PATH`, and it contains no secrets. Other servers and settings
|
|
420
|
+
in the file are kept. Restart the app to load it.
|
|
421
|
+
|
|
422
|
+
- Re-run it any time to update the credentials or the entry.
|
|
423
|
+
- `--client-id`/`--client-secret` or `--bearer-token` skip the prompt, but flags
|
|
424
|
+
are visible in the process list.
|
|
425
|
+
- `--url` and `--name` register another environment alongside the first.
|
|
426
|
+
- Run it from an environment you keep (pip or pipx), not a throwaway `uvx` one,
|
|
427
|
+
because the entry points at that environment's executable.
|
|
428
|
+
|
|
429
|
+
**Manual setup.** Or add the server to your client's MCP config yourself
|
|
430
|
+
(`claude_desktop_config.json`, `.mcp.json`, or the equivalent for your host):
|
|
400
431
|
|
|
401
432
|
```json
|
|
402
433
|
{
|
|
@@ -567,6 +598,43 @@ async with DataQuery(client_id="...", client_secret="...", timeout=60.0) as dq:
|
|
|
567
598
|
...
|
|
568
599
|
```
|
|
569
600
|
|
|
601
|
+
### Custom request headers
|
|
602
|
+
|
|
603
|
+
Headers are configured per client, on `ClientConfig` or as `DataQuery(...)`
|
|
604
|
+
kwargs, and are never read from the environment, so several clients in one
|
|
605
|
+
process can identify themselves differently. They are sent on every DataQuery
|
|
606
|
+
API request (JSON, file and SSE), but not on the OAuth token request.
|
|
607
|
+
|
|
608
|
+
```python
|
|
609
|
+
config = ClientConfig(
|
|
610
|
+
client_id="...",
|
|
611
|
+
client_secret="...",
|
|
612
|
+
custom_headers={
|
|
613
|
+
"X-User-Agent": "RiskEngine/2.1",
|
|
614
|
+
"X-Team": "rates",
|
|
615
|
+
"X-Request-Source": "nightly-batch",
|
|
616
|
+
},
|
|
617
|
+
)
|
|
618
|
+
|
|
619
|
+
async with DataQuery(config) as dq:
|
|
620
|
+
...
|
|
621
|
+
|
|
622
|
+
# Or as kwargs:
|
|
623
|
+
async with DataQuery(custom_headers={"X-User-Agent": "RiskEngine/2.1"}) as dq:
|
|
624
|
+
...
|
|
625
|
+
```
|
|
626
|
+
|
|
627
|
+
- A custom header replaces an SDK default of the same name (e.g. `User-Agent`),
|
|
628
|
+
but never `Authorization`, which comes from `client_id`/`client_secret` or
|
|
629
|
+
`bearer_token`, and never the SSE stream's `Accept` / `Last-Event-ID`.
|
|
630
|
+
- Invalid headers (a bad name, CR/LF in a value, a name repeated in another
|
|
631
|
+
case, `Authorization`) raise `ConfigurationError` before the first request.
|
|
632
|
+
The error names the header but never shows its value.
|
|
633
|
+
- Kwarg overrides are written onto the `ClientConfig` you pass in, so give each
|
|
634
|
+
client its own config instead of sharing one.
|
|
635
|
+
- The `x_user_agent` option and `DATAQUERY_X_USER_AGENT` are gone; send
|
|
636
|
+
`X-User-Agent` through `custom_headers` like any other header.
|
|
637
|
+
|
|
570
638
|
## Logging
|
|
571
639
|
|
|
572
640
|
The SDK logs through [structlog](https://www.structlog.org/) and emits
|
|
@@ -2,6 +2,8 @@
|
|
|
2
2
|
|
|
3
3
|
import argparse
|
|
4
4
|
import asyncio
|
|
5
|
+
import getpass
|
|
6
|
+
import importlib.util
|
|
5
7
|
import json
|
|
6
8
|
import os
|
|
7
9
|
import sys
|
|
@@ -139,6 +141,44 @@ def _save_mcp_credentials(keys: List[str]) -> None:
|
|
|
139
141
|
print(f"Saved {saved} to {env_file} (owner-only)", file=sys.stderr)
|
|
140
142
|
|
|
141
143
|
|
|
144
|
+
def _has_mcp_credentials(keys: List[str]) -> bool:
|
|
145
|
+
"""A bearer token, or a complete OAuth client ID + secret pair."""
|
|
146
|
+
return "BEARER_TOKEN" in keys or {"CLIENT_ID", "CLIENT_SECRET"} <= set(keys)
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def _mcp_extra_installed() -> bool:
|
|
150
|
+
return importlib.util.find_spec("fastmcp") is not None
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
# `mcp-install --app` targets: the JSON-config apps, plus Claude Code (its CLI) and ChatGPT (the Codex TOML).
|
|
154
|
+
MCP_INSTALL_APPS = ("claude-desktop", "claude-code", "chatgpt", "cursor", "vscode")
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def _mcp_server_name(value: str) -> str:
|
|
158
|
+
from dataquery.mcp_install import SERVER_NAME_RE
|
|
159
|
+
|
|
160
|
+
if not SERVER_NAME_RE.fullmatch(value):
|
|
161
|
+
raise argparse.ArgumentTypeError(f"invalid server name {value!r}: use letters, digits, '-' and '_' only")
|
|
162
|
+
return value
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def _prompt_for_mcp_credentials() -> None:
|
|
166
|
+
"""On a terminal, ask for whichever OAuth client credential is still missing (the secret unechoed)."""
|
|
167
|
+
from dataquery.config import EnvConfig
|
|
168
|
+
|
|
169
|
+
if not sys.stdin.isatty():
|
|
170
|
+
return
|
|
171
|
+
for key, label, ask in (
|
|
172
|
+
("CLIENT_ID", "DataQuery client ID: ", input),
|
|
173
|
+
("CLIENT_SECRET", "DataQuery client secret: ", getpass.getpass),
|
|
174
|
+
):
|
|
175
|
+
env_key = f"{EnvConfig.PREFIX}{key}"
|
|
176
|
+
if not os.environ.get(env_key):
|
|
177
|
+
value = ask(label).strip()
|
|
178
|
+
if value:
|
|
179
|
+
os.environ[env_key] = value
|
|
180
|
+
|
|
181
|
+
|
|
142
182
|
def create_parser() -> argparse.ArgumentParser:
|
|
143
183
|
"""Create the top-level CLI parser with subcommands."""
|
|
144
184
|
parser = argparse.ArgumentParser(description="Command Line Interface for the DataQuery SDK")
|
|
@@ -390,6 +430,47 @@ def create_parser() -> argparse.ArgumentParser:
|
|
|
390
430
|
"needs no environment variables",
|
|
391
431
|
)
|
|
392
432
|
|
|
433
|
+
p_install = subparsers.add_parser(
|
|
434
|
+
"mcp-install",
|
|
435
|
+
help="One-time MCP setup: save your credentials and add the DataQuery server to your MCP app",
|
|
436
|
+
description=(
|
|
437
|
+
"Save your DataQuery credentials to ~/.dataquery/.env (owner-only) and add a\n"
|
|
438
|
+
"server that runs this environment's `dataquery mcp-connect` to your MCP\n"
|
|
439
|
+
"app's user config. No secrets go into that config. Missing credentials are\n"
|
|
440
|
+
"prompted for on a terminal, the secret without echo. Re-run any time to\n"
|
|
441
|
+
"update the credentials or the entry."
|
|
442
|
+
),
|
|
443
|
+
formatter_class=argparse.RawDescriptionHelpFormatter,
|
|
444
|
+
)
|
|
445
|
+
target = p_install.add_mutually_exclusive_group()
|
|
446
|
+
target.add_argument(
|
|
447
|
+
"--app",
|
|
448
|
+
choices=MCP_INSTALL_APPS,
|
|
449
|
+
default="claude-desktop",
|
|
450
|
+
help="MCP app to configure (default: claude-desktop); chatgpt is the ChatGPT desktop app, "
|
|
451
|
+
"whose config the Codex CLI and IDE extension share",
|
|
452
|
+
)
|
|
453
|
+
target.add_argument(
|
|
454
|
+
"--config-file",
|
|
455
|
+
type=Path,
|
|
456
|
+
default=None,
|
|
457
|
+
help="Add the server to this mcpServers JSON file instead, for any other app",
|
|
458
|
+
)
|
|
459
|
+
p_install.add_argument(
|
|
460
|
+
"--name",
|
|
461
|
+
type=_mcp_server_name,
|
|
462
|
+
default="dataquery",
|
|
463
|
+
help="Server name in the MCP config (default: dataquery)",
|
|
464
|
+
)
|
|
465
|
+
p_install.add_argument("--url", default=None, help="MCP endpoint for mcp-connect (default: the PROD endpoint)")
|
|
466
|
+
p_install.add_argument("--client-id", default=None, help="OAuth client ID (prompted for when missing)")
|
|
467
|
+
p_install.add_argument(
|
|
468
|
+
"--client-secret",
|
|
469
|
+
default=None,
|
|
470
|
+
help="OAuth client secret; visible in the process list, so prefer the prompt",
|
|
471
|
+
)
|
|
472
|
+
p_install.add_argument("--bearer-token", default=None, help="Use a bearer token instead of OAuth")
|
|
473
|
+
|
|
393
474
|
return parser
|
|
394
475
|
|
|
395
476
|
|
|
@@ -874,6 +955,64 @@ async def cmd_mcp_connect(args: argparse.Namespace) -> int:
|
|
|
874
955
|
return 0
|
|
875
956
|
|
|
876
957
|
|
|
958
|
+
def cmd_mcp_install(args: argparse.Namespace) -> int:
|
|
959
|
+
"""Save the credentials once and add the DataQuery MCP server to an MCP app's user config."""
|
|
960
|
+
from dataquery import mcp_install
|
|
961
|
+
from dataquery.config import EnvConfig
|
|
962
|
+
from dataquery.types.exceptions import ConfigurationError
|
|
963
|
+
|
|
964
|
+
if not _mcp_extra_installed():
|
|
965
|
+
print(
|
|
966
|
+
"The MCP bridge needs the 'mcp' extra. Install it, then re-run:\n pip install 'dataquery-sdk[mcp]'",
|
|
967
|
+
file=sys.stderr,
|
|
968
|
+
)
|
|
969
|
+
return 1
|
|
970
|
+
|
|
971
|
+
if getattr(args, "env_file", None):
|
|
972
|
+
EnvConfig.load_env_file(Path(args.env_file))
|
|
973
|
+
# Already-saved credentials count, so re-running only refreshes the entry.
|
|
974
|
+
EnvConfig.load_user_env_file()
|
|
975
|
+
keys = _export_mcp_credentials(args)
|
|
976
|
+
if not _has_mcp_credentials(keys):
|
|
977
|
+
_prompt_for_mcp_credentials()
|
|
978
|
+
keys = _export_mcp_credentials(args)
|
|
979
|
+
if not _has_mcp_credentials(keys):
|
|
980
|
+
print(
|
|
981
|
+
"No credentials: pass --client-id and --client-secret (or --bearer-token), set "
|
|
982
|
+
"DATAQUERY_CLIENT_ID and DATAQUERY_CLIENT_SECRET, or run in a terminal to be prompted.",
|
|
983
|
+
file=sys.stderr,
|
|
984
|
+
)
|
|
985
|
+
return 1
|
|
986
|
+
|
|
987
|
+
entry = mcp_install.server_entry(args.url)
|
|
988
|
+
try:
|
|
989
|
+
# Saved first: an entry without credentials behind it would fail on every launch.
|
|
990
|
+
env_file = EnvConfig.save_user_env({key: os.environ.get(f"{EnvConfig.PREFIX}{key}") for key in keys})
|
|
991
|
+
print(f"Saved {', '.join(f'{EnvConfig.PREFIX}{key}' for key in keys)} to {env_file} (owner-only)")
|
|
992
|
+
if args.config_file is not None:
|
|
993
|
+
replaced = mcp_install.add_to_config_file(args.config_file, args.name, entry)
|
|
994
|
+
print(f"{'Updated' if replaced else 'Added'} '{args.name}' in {args.config_file}")
|
|
995
|
+
print("Restart your MCP app to load it.")
|
|
996
|
+
elif args.app == "claude-code":
|
|
997
|
+
mcp_install.add_to_claude_code(args.name, entry)
|
|
998
|
+
print(f"Added '{args.name}' to Claude Code (user scope); check it with: claude mcp list")
|
|
999
|
+
elif args.app == "chatgpt":
|
|
1000
|
+
path, replaced = mcp_install.add_to_codex(args.name, entry)
|
|
1001
|
+
print(f"{'Updated' if replaced else 'Added'} '{args.name}' in {path}")
|
|
1002
|
+
print("Restart the ChatGPT desktop app (or Codex) to load it; ChatGPT on the web can't run local servers.")
|
|
1003
|
+
else:
|
|
1004
|
+
app = mcp_install.JSON_APPS[args.app]
|
|
1005
|
+
path, replaced = app.add(args.name, entry)
|
|
1006
|
+
print(f"{'Updated' if replaced else 'Added'} '{args.name}' in {path}")
|
|
1007
|
+
print(f"Restart {app.label} to load it.")
|
|
1008
|
+
except (ConfigurationError, OSError) as exc:
|
|
1009
|
+
print(f"mcp-install: {exc}", file=sys.stderr)
|
|
1010
|
+
return 1
|
|
1011
|
+
print("The server runs:", " ".join([entry["command"], *entry["args"]]))
|
|
1012
|
+
print("No secrets were written to the MCP config; mcp-connect reads them from the saved file.")
|
|
1013
|
+
return 0
|
|
1014
|
+
|
|
1015
|
+
|
|
877
1016
|
def main_sync(ns: argparse.Namespace) -> int:
|
|
878
1017
|
if ns.command == "config":
|
|
879
1018
|
if ns.config_command == "show":
|
|
@@ -919,6 +1058,8 @@ def main() -> int:
|
|
|
919
1058
|
return asyncio.run(cmd_auth_test(args))
|
|
920
1059
|
if args.command == "function-help":
|
|
921
1060
|
return cmd_function_help(args)
|
|
1061
|
+
if args.command == "mcp-install":
|
|
1062
|
+
return cmd_mcp_install(args)
|
|
922
1063
|
|
|
923
1064
|
handler = _ASYNC_COMMANDS.get(args.command)
|
|
924
1065
|
if handler is None:
|
|
@@ -21,6 +21,13 @@ _DEFAULT_OVERRIDES: Dict[str, str] = {
|
|
|
21
21
|
"TOKEN_STORAGE_DIR": ".tokens",
|
|
22
22
|
}
|
|
23
23
|
|
|
24
|
+
# Per-client settings: set on ClientConfig (or passed to DataQuery), never read
|
|
25
|
+
# from the environment, so clients in one process can send different headers.
|
|
26
|
+
_CLIENT_ONLY_FIELDS = frozenset({"custom_headers"})
|
|
27
|
+
|
|
28
|
+
# ClientConfig fields that map to a DATAQUERY_* env var.
|
|
29
|
+
_ENV_FIELDS = {name: field for name, field in ClientConfig.model_fields.items() if name not in _CLIENT_ONLY_FIELDS}
|
|
30
|
+
|
|
24
31
|
_SENSITIVE_FIELDS = frozenset(
|
|
25
32
|
{
|
|
26
33
|
"client_id",
|
|
@@ -80,7 +87,7 @@ def _unwrap_optional(annotation: Any) -> Any:
|
|
|
80
87
|
def _build_defaults() -> Dict[str, Optional[str]]:
|
|
81
88
|
"""Compute the ``EnvConfig.DEFAULTS`` table from the model."""
|
|
82
89
|
defaults: Dict[str, Optional[str]] = {}
|
|
83
|
-
for field_name, field in
|
|
90
|
+
for field_name, field in _ENV_FIELDS.items():
|
|
84
91
|
env_key = _env_name_for(field_name)
|
|
85
92
|
if field.default is PydanticUndefined or field.default is None:
|
|
86
93
|
defaults[env_key] = None
|
|
@@ -244,7 +251,7 @@ class EnvConfig:
|
|
|
244
251
|
raise ConfigurationError(f"{cls.PREFIX}BASE_URL environment variable is required")
|
|
245
252
|
|
|
246
253
|
kwargs: Dict[str, Any] = {}
|
|
247
|
-
for field_name, field in
|
|
254
|
+
for field_name, field in _ENV_FIELDS.items():
|
|
248
255
|
kwargs[field_name] = cls._read_field(field_name, field)
|
|
249
256
|
|
|
250
257
|
if kwargs.get("oauth_enabled") and not kwargs.get("oauth_token_url"):
|
|
@@ -352,6 +359,10 @@ class EnvConfig:
|
|
|
352
359
|
errors.append("REQUESTS_PER_MINUTE must be positive")
|
|
353
360
|
if config.burst_capacity <= 0:
|
|
354
361
|
errors.append("BURST_CAPACITY must be positive")
|
|
362
|
+
try:
|
|
363
|
+
config.get_custom_headers()
|
|
364
|
+
except ConfigurationError as exc:
|
|
365
|
+
errors.append(str(exc))
|
|
355
366
|
|
|
356
367
|
if errors:
|
|
357
368
|
raise ConfigurationError(f"Configuration validation failed: {'; '.join(errors)}")
|
|
@@ -370,7 +381,7 @@ class EnvConfig:
|
|
|
370
381
|
"# Defaults shown are the values used when the variable is unset.",
|
|
371
382
|
"",
|
|
372
383
|
]
|
|
373
|
-
for field_name, field in
|
|
384
|
+
for field_name, field in _ENV_FIELDS.items():
|
|
374
385
|
env_key = _env_name_for(field_name)
|
|
375
386
|
default = cls.DEFAULTS.get(env_key)
|
|
376
387
|
description = (field.description or "").strip()
|
|
@@ -34,7 +34,7 @@ from .download import (
|
|
|
34
34
|
SMALL_FILE_THRESHOLD,
|
|
35
35
|
TEMP_SUFFIX,
|
|
36
36
|
)
|
|
37
|
-
from .rate_limit import RATE_LIMIT_MIN_WAIT_SECONDS
|
|
37
|
+
from .rate_limit import RATE_LIMIT_MIN_WAIT_SECONDS, RATE_LIMIT_POLL_INTERVAL_SECONDS
|
|
38
38
|
from .sse import SSE_HEALTHY_CONNECTION_SECONDS, SSE_MAX_AUTH_RETRIES, SSE_READ_BUFSIZE
|
|
39
39
|
|
|
40
40
|
__all__ = [
|
|
@@ -67,6 +67,7 @@ __all__ = [
|
|
|
67
67
|
"SMALL_FILE_THRESHOLD",
|
|
68
68
|
"TEMP_SUFFIX",
|
|
69
69
|
"RATE_LIMIT_MIN_WAIT_SECONDS",
|
|
70
|
+
"RATE_LIMIT_POLL_INTERVAL_SECONDS",
|
|
70
71
|
"SSE_HEALTHY_CONNECTION_SECONDS",
|
|
71
72
|
"SSE_MAX_AUTH_RETRIES",
|
|
72
73
|
"SSE_READ_BUFSIZE",
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
"""Rate-limiter tunables."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
RATE_LIMIT_MIN_WAIT_SECONDS = 0.2
|
|
6
|
+
|
|
7
|
+
# Upper bound on a single sleep while waiting for a token. The lock is not held
|
|
8
|
+
# across the sleep, so waiters re-check the bucket at least this often.
|
|
9
|
+
RATE_LIMIT_POLL_INTERVAL_SECONDS = 0.1
|
|
@@ -52,6 +52,7 @@ from ..types.models import (
|
|
|
52
52
|
GroupList,
|
|
53
53
|
InstrumentsResponse,
|
|
54
54
|
TimeSeriesResponse,
|
|
55
|
+
merge_headers,
|
|
55
56
|
)
|
|
56
57
|
from ..utils import (
|
|
57
58
|
format_file_size,
|
|
@@ -260,6 +261,8 @@ class DataQueryClient(
|
|
|
260
261
|
async def connect(self):
|
|
261
262
|
"""Initialize HTTP session with optimized configuration."""
|
|
262
263
|
if self.session is None:
|
|
264
|
+
# Resolved first: a bad custom header fails here, before any connector is opened.
|
|
265
|
+
custom_headers = self.config.get_custom_headers()
|
|
263
266
|
timeout = aiohttp.ClientTimeout(total=self.config.timeout, connect=300.0, sock_read=self.config.timeout)
|
|
264
267
|
|
|
265
268
|
connector = aiohttp.TCPConnector(
|
|
@@ -286,12 +289,16 @@ class DataQueryClient(
|
|
|
286
289
|
session_kwargs = {
|
|
287
290
|
"timeout": timeout,
|
|
288
291
|
"connector": connector,
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
292
|
+
# Custom headers layer over these defaults, so a client may replace e.g. User-Agent;
|
|
293
|
+
# per-request headers (Authorization, Range) still win over both.
|
|
294
|
+
"headers": merge_headers(
|
|
295
|
+
{
|
|
296
|
+
"User-Agent": f"DATAQUERY-SDK/{version}",
|
|
297
|
+
"Connection": "keep-alive",
|
|
298
|
+
"Accept-Encoding": "gzip, deflate",
|
|
299
|
+
},
|
|
300
|
+
custom_headers,
|
|
301
|
+
),
|
|
295
302
|
"auto_decompress": True,
|
|
296
303
|
"raise_for_status": False,
|
|
297
304
|
}
|
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
"""One-time setup: register ``dataquery mcp-connect`` with an MCP client app."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import os
|
|
5
|
+
import re
|
|
6
|
+
import shutil
|
|
7
|
+
import subprocess
|
|
8
|
+
import sys
|
|
9
|
+
import tempfile
|
|
10
|
+
import tomllib
|
|
11
|
+
from dataclasses import dataclass
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
from typing import Any, Dict, List, Optional, Tuple
|
|
14
|
+
|
|
15
|
+
from .types.exceptions import ConfigurationError
|
|
16
|
+
|
|
17
|
+
# Server names this installer writes: safe as a JSON key, a TOML bare key and a CLI argument.
|
|
18
|
+
SERVER_NAME_RE = re.compile(r"[A-Za-z0-9_-]+")
|
|
19
|
+
|
|
20
|
+
_CLAUDE_CLI_TIMEOUT = 60.0
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def dataquery_command() -> List[str]:
|
|
24
|
+
"""The absolute command that runs this environment's ``dataquery`` CLI.
|
|
25
|
+
|
|
26
|
+
MCP apps are often GUI apps that don't inherit your shell's PATH, so the
|
|
27
|
+
config gets a full path rather than a bare ``dataquery``.
|
|
28
|
+
"""
|
|
29
|
+
script = Path(sys.executable).with_name("dataquery.exe" if os.name == "nt" else "dataquery")
|
|
30
|
+
if script.is_file():
|
|
31
|
+
return [str(script)]
|
|
32
|
+
# e.g. a --user install, whose scripts live apart from the interpreter
|
|
33
|
+
return [sys.executable, "-m", "dataquery.cli"]
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def server_entry(url: Optional[str] = None) -> Dict[str, Any]:
|
|
37
|
+
"""The server entry: this environment's ``mcp-connect``, with no secrets in it."""
|
|
38
|
+
command, *args = dataquery_command()
|
|
39
|
+
args.append("mcp-connect")
|
|
40
|
+
if url:
|
|
41
|
+
args += ["--url", url]
|
|
42
|
+
return {"command": command, "args": args}
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _app_data_dir() -> Path:
|
|
46
|
+
"""The per-user application data directory for this OS."""
|
|
47
|
+
if sys.platform == "darwin":
|
|
48
|
+
return Path.home() / "Library" / "Application Support"
|
|
49
|
+
if sys.platform == "win32":
|
|
50
|
+
return Path(os.environ.get("APPDATA") or Path.home() / "AppData" / "Roaming")
|
|
51
|
+
return Path(os.environ.get("XDG_CONFIG_HOME") or Path.home() / ".config")
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
@dataclass(frozen=True)
|
|
55
|
+
class JsonApp:
|
|
56
|
+
"""An MCP app that keeps its user-level servers in a JSON file."""
|
|
57
|
+
|
|
58
|
+
label: str
|
|
59
|
+
parts: Tuple[str, ...] # the file, relative to the app-data or home directory
|
|
60
|
+
in_app_data: bool
|
|
61
|
+
servers_key: str = "mcpServers"
|
|
62
|
+
stdio_type: bool = False # the app's schema names the transport: "type": "stdio"
|
|
63
|
+
|
|
64
|
+
def config_path(self) -> Path:
|
|
65
|
+
return (_app_data_dir() if self.in_app_data else Path.home()).joinpath(*self.parts)
|
|
66
|
+
|
|
67
|
+
def add(self, name: str, entry: Dict[str, Any]) -> Tuple[Path, bool]:
|
|
68
|
+
"""Add or replace the server in this app's config; returns ``(path, replaced)``."""
|
|
69
|
+
if self.stdio_type:
|
|
70
|
+
entry = {"type": "stdio", **entry}
|
|
71
|
+
path = self.config_path()
|
|
72
|
+
return path, add_to_config_file(path, name, entry, self.servers_key)
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
JSON_APPS: Dict[str, JsonApp] = {
|
|
76
|
+
"claude-desktop": JsonApp("Claude Desktop", ("Claude", "claude_desktop_config.json"), in_app_data=True),
|
|
77
|
+
"cursor": JsonApp("Cursor", (".cursor", "mcp.json"), in_app_data=False, stdio_type=True),
|
|
78
|
+
"vscode": JsonApp(
|
|
79
|
+
"VS Code", ("Code", "User", "mcp.json"), in_app_data=True, servers_key="servers", stdio_type=True
|
|
80
|
+
),
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def codex_config_path() -> Path:
|
|
85
|
+
"""The Codex config, shared by the ChatGPT desktop app, the Codex CLI and its IDE extension."""
|
|
86
|
+
return Path.home() / ".codex" / "config.toml"
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def _write_atomically(path: Path, text: str) -> None:
|
|
90
|
+
"""Replace ``path`` with ``text`` in one step, keeping the file's permissions."""
|
|
91
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
92
|
+
fd, tmp = tempfile.mkstemp(dir=path.parent, prefix=f".{path.name}.", suffix=".tmp") # created 0600
|
|
93
|
+
try:
|
|
94
|
+
with os.fdopen(fd, "w", encoding="utf-8") as handle:
|
|
95
|
+
handle.write(text)
|
|
96
|
+
if path.exists():
|
|
97
|
+
# Keep the file's own permissions: it may hold other servers' secrets.
|
|
98
|
+
shutil.copymode(path, tmp)
|
|
99
|
+
os.replace(tmp, path)
|
|
100
|
+
except BaseException:
|
|
101
|
+
Path(tmp).unlink(missing_ok=True)
|
|
102
|
+
raise
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def add_to_config_file(path: Path, name: str, entry: Dict[str, Any], servers_key: str = "mcpServers") -> bool:
|
|
106
|
+
"""Add or replace ``<servers_key>[name]`` in an MCP app's JSON config.
|
|
107
|
+
|
|
108
|
+
Everything else in the file is kept, and a file that isn't valid JSON is
|
|
109
|
+
left untouched. Returns ``True`` if an existing entry was replaced.
|
|
110
|
+
"""
|
|
111
|
+
path = path.expanduser().resolve() # through a symlink, so a dotfiles link survives
|
|
112
|
+
config: Any = {}
|
|
113
|
+
if path.exists():
|
|
114
|
+
text = path.read_text(encoding="utf-8")
|
|
115
|
+
if text.strip():
|
|
116
|
+
try:
|
|
117
|
+
config = json.loads(text)
|
|
118
|
+
except json.JSONDecodeError as exc:
|
|
119
|
+
raise ConfigurationError(f"{path} is not valid JSON ({exc}); fix it and re-run") from exc
|
|
120
|
+
servers = config.setdefault(servers_key, {}) if isinstance(config, dict) else None
|
|
121
|
+
if not isinstance(servers, dict):
|
|
122
|
+
raise ConfigurationError(f"{path} has no usable '{servers_key}' object; fix it and re-run")
|
|
123
|
+
replaced = name in servers
|
|
124
|
+
servers[name] = entry
|
|
125
|
+
_write_atomically(path, json.dumps(config, indent=2) + "\n")
|
|
126
|
+
return replaced
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def add_to_codex(name: str, entry: Dict[str, Any]) -> Tuple[Path, bool]:
|
|
130
|
+
"""Add or replace the server in the Codex config; returns ``(path, replaced)``.
|
|
131
|
+
|
|
132
|
+
The table goes between marker comments, so a re-run replaces exactly what an
|
|
133
|
+
earlier run wrote and the rest of the file stays byte for byte. A server of
|
|
134
|
+
the same name added some other way is left alone, and nothing is written
|
|
135
|
+
unless the result parses back to this entry.
|
|
136
|
+
"""
|
|
137
|
+
path = codex_config_path().resolve()
|
|
138
|
+
text = path.read_text(encoding="utf-8") if path.exists() else ""
|
|
139
|
+
try:
|
|
140
|
+
servers = tomllib.loads(text).get("mcp_servers", {})
|
|
141
|
+
except tomllib.TOMLDecodeError as exc:
|
|
142
|
+
raise ConfigurationError(f"{path} is not valid TOML ({exc}); fix it and re-run") from exc
|
|
143
|
+
|
|
144
|
+
begin, end = f"# >>> dataquery mcp-install: {name} >>>", f"# <<< dataquery mcp-install: {name} <<<"
|
|
145
|
+
# A JSON string (non-ASCII left as is) is also a valid TOML basic string.
|
|
146
|
+
args = ", ".join(json.dumps(arg, ensure_ascii=False) for arg in entry["args"])
|
|
147
|
+
block = (
|
|
148
|
+
f"{begin}\n[mcp_servers.{name}]\ncommand = {json.dumps(entry['command'], ensure_ascii=False)}\n"
|
|
149
|
+
f"args = [{args}]\n{end}\n"
|
|
150
|
+
)
|
|
151
|
+
ours = re.compile(rf"^{re.escape(begin)}$.*?^{re.escape(end)}$\n?", re.MULTILINE | re.DOTALL)
|
|
152
|
+
replaced = ours.search(text) is not None
|
|
153
|
+
if replaced:
|
|
154
|
+
new_text = ours.sub(lambda _: block, text, count=1)
|
|
155
|
+
elif isinstance(servers, dict) and name in servers:
|
|
156
|
+
raise ConfigurationError(
|
|
157
|
+
f"{path} already has a '{name}' server that mcp-install didn't add; remove its "
|
|
158
|
+
f"[mcp_servers.{name}] table and re-run, or pick another --name"
|
|
159
|
+
)
|
|
160
|
+
else:
|
|
161
|
+
separator = "" if not text.strip() else ("\n" if text.endswith("\n") else "\n\n")
|
|
162
|
+
new_text = text + separator + block
|
|
163
|
+
|
|
164
|
+
try:
|
|
165
|
+
written = tomllib.loads(new_text).get("mcp_servers", {}).get(name)
|
|
166
|
+
except (tomllib.TOMLDecodeError, AttributeError):
|
|
167
|
+
written = None
|
|
168
|
+
if written != entry:
|
|
169
|
+
raise ConfigurationError(f"Couldn't add '{name}' to {path} cleanly; add this to it by hand:\n{block}")
|
|
170
|
+
_write_atomically(path, new_text)
|
|
171
|
+
return path, replaced
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def add_to_claude_code(name: str, entry: Dict[str, Any]) -> None:
|
|
175
|
+
"""Register the server in Claude Code's user scope through its own CLI.
|
|
176
|
+
|
|
177
|
+
A user-scope server of the same name is replaced, so re-running updates it.
|
|
178
|
+
"""
|
|
179
|
+
spec = json.dumps({"type": "stdio", **entry})
|
|
180
|
+
claude = shutil.which("claude")
|
|
181
|
+
if claude is None:
|
|
182
|
+
raise ConfigurationError(
|
|
183
|
+
f"Claude Code CLI ('claude') not found on PATH. Add the server yourself with:\n"
|
|
184
|
+
f" claude mcp add-json -s user {name} '{spec}'"
|
|
185
|
+
)
|
|
186
|
+
|
|
187
|
+
def claude_mcp(*args: str) -> "subprocess.CompletedProcess[str]":
|
|
188
|
+
try:
|
|
189
|
+
return subprocess.run(
|
|
190
|
+
[claude, "mcp", *args], capture_output=True, text=True, timeout=_CLAUDE_CLI_TIMEOUT, check=False
|
|
191
|
+
)
|
|
192
|
+
except (OSError, subprocess.TimeoutExpired) as exc:
|
|
193
|
+
raise ConfigurationError(f"'claude mcp {args[0]}' failed: {exc}") from exc
|
|
194
|
+
|
|
195
|
+
claude_mcp("remove", "-s", "user", name) # fails harmlessly when there is none
|
|
196
|
+
result = claude_mcp("add-json", "-s", "user", name, spec)
|
|
197
|
+
if result.returncode != 0:
|
|
198
|
+
raise ConfigurationError(f"'claude mcp add-json' failed: {(result.stderr or result.stdout).strip()}")
|
|
@@ -12,7 +12,7 @@ import aiohttp
|
|
|
12
12
|
|
|
13
13
|
from .. import constants as C
|
|
14
14
|
from ..transport.auth import OAuthManager
|
|
15
|
-
from ..types.models import ClientConfig
|
|
15
|
+
from ..types.models import ClientConfig, merge_headers
|
|
16
16
|
from .event_store import SSEEventIdStore
|
|
17
17
|
|
|
18
18
|
logger = logging.getLogger(__name__)
|
|
@@ -157,10 +157,10 @@ class SSEClient:
|
|
|
157
157
|
async def _get_headers(self) -> dict:
|
|
158
158
|
headers = await self.auth_manager.get_headers()
|
|
159
159
|
headers["Accept"] = "text/event-stream"
|
|
160
|
-
headers.update(self.config.get_custom_headers())
|
|
161
160
|
if self._last_event_id is not None:
|
|
162
161
|
headers["Last-Event-ID"] = self._last_event_id
|
|
163
|
-
|
|
162
|
+
# Custom headers go underneath, so they can never replace the stream's own headers.
|
|
163
|
+
return merge_headers(self.config.get_custom_headers(), headers)
|
|
164
164
|
|
|
165
165
|
def _base_delay(self) -> float:
|
|
166
166
|
"""The reconnect floor: the server-supplied ``retry:`` hint, otherwise the configured ``reconnect_delay``."""
|
|
@@ -117,8 +117,12 @@ class EnhancedTokenBucketRateLimiter:
|
|
|
117
117
|
|
|
118
118
|
start_time = time.time()
|
|
119
119
|
|
|
120
|
-
|
|
121
|
-
|
|
120
|
+
while True:
|
|
121
|
+
# The lock guards only the bucket bookkeeping. It is deliberately released
|
|
122
|
+
# before sleeping: holding it across the await would let one waiting task
|
|
123
|
+
# monopolise the limiter, blocking every other acquire(), reset() and
|
|
124
|
+
# shutdown() for the whole wait even though no shared state is in use.
|
|
125
|
+
async with self._get_lock():
|
|
122
126
|
self._refill_tokens()
|
|
123
127
|
|
|
124
128
|
if self.state.tokens >= 1.0:
|
|
@@ -129,12 +133,12 @@ class EnhancedTokenBucketRateLimiter:
|
|
|
129
133
|
|
|
130
134
|
wait_time = self._calculate_wait_time()
|
|
131
135
|
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
+
if timeout is not None:
|
|
137
|
+
elapsed = time.time() - start_time
|
|
138
|
+
if elapsed + wait_time > timeout:
|
|
139
|
+
return False
|
|
136
140
|
|
|
137
|
-
|
|
141
|
+
await asyncio.sleep(min(wait_time, C.RATE_LIMIT_POLL_INTERVAL_SECONDS))
|
|
138
142
|
|
|
139
143
|
def _refill_tokens(self):
|
|
140
144
|
"""Refill tokens based on time elapsed."""
|
|
@@ -2,15 +2,23 @@
|
|
|
2
2
|
|
|
3
3
|
import asyncio
|
|
4
4
|
import base64
|
|
5
|
+
import re
|
|
5
6
|
import time
|
|
6
7
|
from datetime import date, datetime, timedelta, timezone
|
|
7
8
|
from enum import Enum
|
|
8
9
|
from pathlib import Path
|
|
9
|
-
from typing import Any, Dict, List, Literal, Optional, Union, cast
|
|
10
|
+
from typing import Any, Dict, List, Literal, Mapping, Optional, Tuple, Union, cast
|
|
10
11
|
|
|
11
12
|
from pydantic import BaseModel, ConfigDict, Field, SecretStr, field_validator
|
|
12
13
|
|
|
13
14
|
from .. import constants as C
|
|
15
|
+
from .exceptions import ConfigurationError
|
|
16
|
+
|
|
17
|
+
# RFC 9110 field-name token.
|
|
18
|
+
_HEADER_NAME_RE = re.compile(r"[!#$%&'*+\-.^_`|~0-9A-Za-z]+")
|
|
19
|
+
|
|
20
|
+
# Sent by the SDK on every request, so a custom value would never reach the server.
|
|
21
|
+
_SDK_OWNED_HEADERS = frozenset({"authorization"})
|
|
14
22
|
|
|
15
23
|
|
|
16
24
|
def _reveal_secret(value: Union[str, SecretStr, None]) -> Optional[str]:
|
|
@@ -22,6 +30,19 @@ def _reveal_secret(value: Union[str, SecretStr, None]) -> Optional[str]:
|
|
|
22
30
|
return value
|
|
23
31
|
|
|
24
32
|
|
|
33
|
+
def merge_headers(*layers: Mapping[str, str]) -> Dict[str, str]:
|
|
34
|
+
"""Merge header mappings left to right, later layers winning.
|
|
35
|
+
|
|
36
|
+
Names compare case-insensitively, as in HTTP, so ``user-agent`` replaces
|
|
37
|
+
``User-Agent`` instead of both going on the wire.
|
|
38
|
+
"""
|
|
39
|
+
merged: Dict[str, Tuple[str, str]] = {}
|
|
40
|
+
for layer in layers:
|
|
41
|
+
for name, value in layer.items():
|
|
42
|
+
merged[name.lower()] = (name, value)
|
|
43
|
+
return dict(merged.values())
|
|
44
|
+
|
|
45
|
+
|
|
25
46
|
class DownloadStatus(str, Enum):
|
|
26
47
|
"""Status of a download operation."""
|
|
27
48
|
|
|
@@ -51,9 +72,10 @@ class ClientConfig(BaseModel):
|
|
|
51
72
|
)
|
|
52
73
|
context_path: Optional[str] = Field(default="/research/dataquery-authe/api/v2", description="API context path")
|
|
53
74
|
api_version: str = Field(default="2.0.0", description="API version")
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
description="
|
|
75
|
+
custom_headers: Dict[str, str] = Field(
|
|
76
|
+
default_factory=dict,
|
|
77
|
+
description="Extra headers sent on every API request (e.g. X-User-Agent); "
|
|
78
|
+
"set per client, never read from the environment",
|
|
57
79
|
)
|
|
58
80
|
files_base_url: Optional[str] = Field(
|
|
59
81
|
default="https://api-dataquery.jpmchase.com",
|
|
@@ -251,11 +273,28 @@ class ClientConfig(BaseModel):
|
|
|
251
273
|
return kwargs
|
|
252
274
|
|
|
253
275
|
def get_custom_headers(self) -> Dict[str, str]:
|
|
254
|
-
"""Return extra headers
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
276
|
+
"""Return a validated copy of ``custom_headers``, the extra headers sent on every API request.
|
|
277
|
+
|
|
278
|
+
Raises :class:`ConfigurationError` for a header that cannot be sent as given. The message
|
|
279
|
+
names the header but never echoes its value, which may be a secret.
|
|
280
|
+
"""
|
|
281
|
+
seen: set[str] = set()
|
|
282
|
+
for name, value in self.custom_headers.items():
|
|
283
|
+
if not isinstance(name, str) or not _HEADER_NAME_RE.fullmatch(name):
|
|
284
|
+
raise ConfigurationError(f"Invalid HTTP header name: {name!r}")
|
|
285
|
+
if not isinstance(value, str) or any(ch in value for ch in "\r\n\0"):
|
|
286
|
+
raise ConfigurationError(f"Header {name!r} must have a string value without CR, LF or NUL characters")
|
|
287
|
+
key = name.lower()
|
|
288
|
+
if key in _SDK_OWNED_HEADERS:
|
|
289
|
+
raise ConfigurationError(
|
|
290
|
+
f"Header {name!r} is set by the SDK; configure client_id/client_secret or bearer_token instead"
|
|
291
|
+
)
|
|
292
|
+
if key in seen:
|
|
293
|
+
raise ConfigurationError(
|
|
294
|
+
f"Header {name!r} appears more than once in custom_headers (names are case-insensitive)"
|
|
295
|
+
)
|
|
296
|
+
seen.add(key)
|
|
297
|
+
return dict(self.custom_headers)
|
|
259
298
|
|
|
260
299
|
@property
|
|
261
300
|
def api_base_url(self) -> str:
|
|
@@ -175,9 +175,6 @@ DATAQUERY_DEFAULT_DIR=files
|
|
|
175
175
|
# Default: DATAQUERY-SDK/1.2.1
|
|
176
176
|
DATAQUERY_USER_AGENT=DATAQUERY-SDK/1.2.1
|
|
177
177
|
|
|
178
|
-
# Optional custom X-User-Agent header sent on every API request
|
|
179
|
-
# DATAQUERY_X_USER_AGENT=MyApp/1.0
|
|
180
|
-
|
|
181
178
|
# Enable HTTP/2 support (true/false)
|
|
182
179
|
# Default: true
|
|
183
180
|
DATAQUERY_ENABLE_HTTP2=true
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: dataquery-sdk
|
|
3
|
-
Version: 1.2.
|
|
3
|
+
Version: 1.2.7
|
|
4
4
|
Summary: Python SDK for DATAQUERY Data API - Query, download, and check availability of economic data files
|
|
5
5
|
Author-email: DATAQUERY SDK Team <dataquery_support@jpmorgan.com>
|
|
6
6
|
Project-URL: Homepage, https://github.com/jpmorganchase/dataquery-sdk
|
|
@@ -50,7 +50,7 @@ Requires-Dist: pymdown-extensions>=11.0.0; extra == "docs"
|
|
|
50
50
|
Provides-Extra: pandas
|
|
51
51
|
Requires-Dist: pandas>=2.0.0; extra == "pandas"
|
|
52
52
|
Provides-Extra: mcp
|
|
53
|
-
Requires-Dist: fastmcp
|
|
53
|
+
Requires-Dist: fastmcp<4,>=2.14; extra == "mcp"
|
|
54
54
|
Provides-Extra: all
|
|
55
55
|
Requires-Dist: pytest>=7.0.0; extra == "all"
|
|
56
56
|
Requires-Dist: pytest-asyncio>=0.21.0; extra == "all"
|
|
@@ -63,7 +63,7 @@ Requires-Dist: mkdocs-material>=9.0.0; extra == "all"
|
|
|
63
63
|
Requires-Dist: mkdocstrings[python]>=0.24.0; extra == "all"
|
|
64
64
|
Requires-Dist: mike>=1.1.0; extra == "all"
|
|
65
65
|
Requires-Dist: pandas>=2.0.0; extra == "all"
|
|
66
|
-
Requires-Dist: fastmcp
|
|
66
|
+
Requires-Dist: fastmcp<4,>=2.14; extra == "all"
|
|
67
67
|
Requires-Dist: pip-audit>=2.7.0; extra == "all"
|
|
68
68
|
Requires-Dist: build>=1.3.0; extra == "all"
|
|
69
69
|
Requires-Dist: urllib3>=2.7.0; extra == "all"
|
|
@@ -467,8 +467,39 @@ pip install "dataquery-sdk[mcp]"
|
|
|
467
467
|
|
|
468
468
|
### Wire it into an MCP client
|
|
469
469
|
|
|
470
|
-
|
|
471
|
-
|
|
470
|
+
**One-time setup (recommended).** Install, then run `mcp-install` once:
|
|
471
|
+
|
|
472
|
+
```bash
|
|
473
|
+
pip install "dataquery-sdk[mcp]"
|
|
474
|
+
dataquery mcp-install # Claude Desktop (the default)
|
|
475
|
+
dataquery mcp-install --app <app> # one of the apps below
|
|
476
|
+
dataquery mcp-install --config-file ~/.some-app/mcp.json # any other app with an mcpServers JSON file
|
|
477
|
+
```
|
|
478
|
+
|
|
479
|
+
| `--app` | Where the server goes |
|
|
480
|
+
|---|---|
|
|
481
|
+
| `claude-desktop` | Claude Desktop's `claude_desktop_config.json` |
|
|
482
|
+
| `claude-code` | Claude Code, user scope (via `claude mcp add-json`) |
|
|
483
|
+
| `chatgpt` | `~/.codex/config.toml`, which the ChatGPT desktop app shares with the Codex CLI and IDE extension; ChatGPT on the web can't run local servers |
|
|
484
|
+
| `cursor` | `~/.cursor/mcp.json` |
|
|
485
|
+
| `vscode` | VS Code's user-profile `mcp.json` (default profile) |
|
|
486
|
+
|
|
487
|
+
It prompts for your client ID and secret (the secret without echo) and saves
|
|
488
|
+
them to `~/.dataquery/.env`, owner-only (see [Credentials](#credentials)). It
|
|
489
|
+
then adds a `dataquery` server to the app's user config. The entry runs this
|
|
490
|
+
environment's `dataquery mcp-connect` by absolute path, because GUI apps don't
|
|
491
|
+
see your shell's `PATH`, and it contains no secrets. Other servers and settings
|
|
492
|
+
in the file are kept. Restart the app to load it.
|
|
493
|
+
|
|
494
|
+
- Re-run it any time to update the credentials or the entry.
|
|
495
|
+
- `--client-id`/`--client-secret` or `--bearer-token` skip the prompt, but flags
|
|
496
|
+
are visible in the process list.
|
|
497
|
+
- `--url` and `--name` register another environment alongside the first.
|
|
498
|
+
- Run it from an environment you keep (pip or pipx), not a throwaway `uvx` one,
|
|
499
|
+
because the entry points at that environment's executable.
|
|
500
|
+
|
|
501
|
+
**Manual setup.** Or add the server to your client's MCP config yourself
|
|
502
|
+
(`claude_desktop_config.json`, `.mcp.json`, or the equivalent for your host):
|
|
472
503
|
|
|
473
504
|
```json
|
|
474
505
|
{
|
|
@@ -639,6 +670,43 @@ async with DataQuery(client_id="...", client_secret="...", timeout=60.0) as dq:
|
|
|
639
670
|
...
|
|
640
671
|
```
|
|
641
672
|
|
|
673
|
+
### Custom request headers
|
|
674
|
+
|
|
675
|
+
Headers are configured per client, on `ClientConfig` or as `DataQuery(...)`
|
|
676
|
+
kwargs, and are never read from the environment, so several clients in one
|
|
677
|
+
process can identify themselves differently. They are sent on every DataQuery
|
|
678
|
+
API request (JSON, file and SSE), but not on the OAuth token request.
|
|
679
|
+
|
|
680
|
+
```python
|
|
681
|
+
config = ClientConfig(
|
|
682
|
+
client_id="...",
|
|
683
|
+
client_secret="...",
|
|
684
|
+
custom_headers={
|
|
685
|
+
"X-User-Agent": "RiskEngine/2.1",
|
|
686
|
+
"X-Team": "rates",
|
|
687
|
+
"X-Request-Source": "nightly-batch",
|
|
688
|
+
},
|
|
689
|
+
)
|
|
690
|
+
|
|
691
|
+
async with DataQuery(config) as dq:
|
|
692
|
+
...
|
|
693
|
+
|
|
694
|
+
# Or as kwargs:
|
|
695
|
+
async with DataQuery(custom_headers={"X-User-Agent": "RiskEngine/2.1"}) as dq:
|
|
696
|
+
...
|
|
697
|
+
```
|
|
698
|
+
|
|
699
|
+
- A custom header replaces an SDK default of the same name (e.g. `User-Agent`),
|
|
700
|
+
but never `Authorization`, which comes from `client_id`/`client_secret` or
|
|
701
|
+
`bearer_token`, and never the SSE stream's `Accept` / `Last-Event-ID`.
|
|
702
|
+
- Invalid headers (a bad name, CR/LF in a value, a name repeated in another
|
|
703
|
+
case, `Authorization`) raise `ConfigurationError` before the first request.
|
|
704
|
+
The error names the header but never shows its value.
|
|
705
|
+
- Kwarg overrides are written onto the `ClientConfig` you pass in, so give each
|
|
706
|
+
client its own config instead of sharing one.
|
|
707
|
+
- The `x_user_agent` option and `DATAQUERY_X_USER_AGENT` are gone; send
|
|
708
|
+
`X-User-Agent` through `custom_headers` like any other header.
|
|
709
|
+
|
|
642
710
|
## Logging
|
|
643
711
|
|
|
644
712
|
The SDK logs through [structlog](https://www.structlog.org/) and emits
|
|
@@ -16,7 +16,7 @@ mkdocs-material>=9.0.0
|
|
|
16
16
|
mkdocstrings[python]>=0.24.0
|
|
17
17
|
mike>=1.1.0
|
|
18
18
|
pandas>=2.0.0
|
|
19
|
-
fastmcp
|
|
19
|
+
fastmcp<4,>=2.14
|
|
20
20
|
pip-audit>=2.7.0
|
|
21
21
|
build>=1.3.0
|
|
22
22
|
urllib3>=2.7.0
|
|
@@ -42,7 +42,7 @@ mike>=1.1.0
|
|
|
42
42
|
pymdown-extensions>=11.0.0
|
|
43
43
|
|
|
44
44
|
[mcp]
|
|
45
|
-
fastmcp
|
|
45
|
+
fastmcp<4,>=2.14
|
|
46
46
|
|
|
47
47
|
[pandas]
|
|
48
48
|
pandas>=2.0.0
|
|
@@ -77,7 +77,7 @@ pandas = [
|
|
|
77
77
|
"pandas>=2.0.0",
|
|
78
78
|
]
|
|
79
79
|
mcp = [
|
|
80
|
-
"fastmcp>=2.14",
|
|
80
|
+
"fastmcp>=2.14,<4",
|
|
81
81
|
]
|
|
82
82
|
all = [
|
|
83
83
|
"pytest>=7.0.0",
|
|
@@ -91,7 +91,7 @@ all = [
|
|
|
91
91
|
"mkdocstrings[python]>=0.24.0",
|
|
92
92
|
"mike>=1.1.0",
|
|
93
93
|
"pandas>=2.0.0",
|
|
94
|
-
"fastmcp>=2.14",
|
|
94
|
+
"fastmcp>=2.14,<4",
|
|
95
95
|
"pip-audit>=2.7.0",
|
|
96
96
|
"build>=1.3.0",
|
|
97
97
|
"urllib3>=2.7.0",
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|