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.
Files changed (52) hide show
  1. {dataquery_sdk-1.2.5 → dataquery_sdk-1.2.7}/CHANGELOG.md +6 -3
  2. {dataquery_sdk-1.2.5 → dataquery_sdk-1.2.7}/PKG-INFO +73 -5
  3. {dataquery_sdk-1.2.5 → dataquery_sdk-1.2.7}/README.md +70 -2
  4. {dataquery_sdk-1.2.5 → dataquery_sdk-1.2.7}/dataquery/__init__.py +1 -1
  5. {dataquery_sdk-1.2.5 → dataquery_sdk-1.2.7}/dataquery/cli.py +141 -0
  6. {dataquery_sdk-1.2.5 → dataquery_sdk-1.2.7}/dataquery/config/env.py +14 -3
  7. {dataquery_sdk-1.2.5 → dataquery_sdk-1.2.7}/dataquery/constants/__init__.py +2 -1
  8. dataquery_sdk-1.2.7/dataquery/constants/rate_limit.py +9 -0
  9. {dataquery_sdk-1.2.5 → dataquery_sdk-1.2.7}/dataquery/core/client.py +13 -6
  10. dataquery_sdk-1.2.7/dataquery/mcp_install.py +198 -0
  11. {dataquery_sdk-1.2.5 → dataquery_sdk-1.2.7}/dataquery/sse/client.py +3 -3
  12. {dataquery_sdk-1.2.5 → dataquery_sdk-1.2.7}/dataquery/transport/rate_limiter.py +11 -7
  13. {dataquery_sdk-1.2.5 → dataquery_sdk-1.2.7}/dataquery/types/models.py +48 -9
  14. {dataquery_sdk-1.2.5 → dataquery_sdk-1.2.7}/dataquery/utils.py +0 -3
  15. {dataquery_sdk-1.2.5 → dataquery_sdk-1.2.7}/dataquery_sdk.egg-info/PKG-INFO +73 -5
  16. {dataquery_sdk-1.2.5 → dataquery_sdk-1.2.7}/dataquery_sdk.egg-info/SOURCES.txt +1 -0
  17. {dataquery_sdk-1.2.5 → dataquery_sdk-1.2.7}/dataquery_sdk.egg-info/requires.txt +2 -2
  18. {dataquery_sdk-1.2.5 → dataquery_sdk-1.2.7}/pyproject.toml +2 -2
  19. dataquery_sdk-1.2.5/dataquery/constants/rate_limit.py +0 -5
  20. {dataquery_sdk-1.2.5 → dataquery_sdk-1.2.7}/CONTRIBUTING.md +0 -0
  21. {dataquery_sdk-1.2.5 → dataquery_sdk-1.2.7}/LICENSE +0 -0
  22. {dataquery_sdk-1.2.5 → dataquery_sdk-1.2.7}/MANIFEST.in +0 -0
  23. {dataquery_sdk-1.2.5 → dataquery_sdk-1.2.7}/dataquery/config/__init__.py +0 -0
  24. {dataquery_sdk-1.2.5 → dataquery_sdk-1.2.7}/dataquery/config/logging.py +0 -0
  25. {dataquery_sdk-1.2.5 → dataquery_sdk-1.2.7}/dataquery/constants/api.py +0 -0
  26. {dataquery_sdk-1.2.5 → dataquery_sdk-1.2.7}/dataquery/constants/download.py +0 -0
  27. {dataquery_sdk-1.2.5 → dataquery_sdk-1.2.7}/dataquery/constants/sse.py +0 -0
  28. {dataquery_sdk-1.2.5 → dataquery_sdk-1.2.7}/dataquery/core/__init__.py +0 -0
  29. {dataquery_sdk-1.2.5 → dataquery_sdk-1.2.7}/dataquery/core/_mixins.py +0 -0
  30. {dataquery_sdk-1.2.5 → dataquery_sdk-1.2.7}/dataquery/core/_sync.py +0 -0
  31. {dataquery_sdk-1.2.5 → dataquery_sdk-1.2.7}/dataquery/data/function.json +0 -0
  32. {dataquery_sdk-1.2.5 → dataquery_sdk-1.2.7}/dataquery/dataquery.py +0 -0
  33. {dataquery_sdk-1.2.5 → dataquery_sdk-1.2.7}/dataquery/download/__init__.py +0 -0
  34. {dataquery_sdk-1.2.5 → dataquery_sdk-1.2.7}/dataquery/download/parallel.py +0 -0
  35. {dataquery_sdk-1.2.5 → dataquery_sdk-1.2.7}/dataquery/download/utils.py +0 -0
  36. {dataquery_sdk-1.2.5 → dataquery_sdk-1.2.7}/dataquery/export.py +0 -0
  37. {dataquery_sdk-1.2.5 → dataquery_sdk-1.2.7}/dataquery/function_registry.py +0 -0
  38. {dataquery_sdk-1.2.5 → dataquery_sdk-1.2.7}/dataquery/mcp_proxy.py +0 -0
  39. {dataquery_sdk-1.2.5 → dataquery_sdk-1.2.7}/dataquery/py.typed +0 -0
  40. {dataquery_sdk-1.2.5 → dataquery_sdk-1.2.7}/dataquery/sse/__init__.py +0 -0
  41. {dataquery_sdk-1.2.5 → dataquery_sdk-1.2.7}/dataquery/sse/event_store.py +0 -0
  42. {dataquery_sdk-1.2.5 → dataquery_sdk-1.2.7}/dataquery/sse/subscriber.py +0 -0
  43. {dataquery_sdk-1.2.5 → dataquery_sdk-1.2.7}/dataquery/transport/__init__.py +0 -0
  44. {dataquery_sdk-1.2.5 → dataquery_sdk-1.2.7}/dataquery/transport/auth.py +0 -0
  45. {dataquery_sdk-1.2.5 → dataquery_sdk-1.2.7}/dataquery/transport/connection_pool.py +0 -0
  46. {dataquery_sdk-1.2.5 → dataquery_sdk-1.2.7}/dataquery/transport/retry.py +0 -0
  47. {dataquery_sdk-1.2.5 → dataquery_sdk-1.2.7}/dataquery/types/__init__.py +0 -0
  48. {dataquery_sdk-1.2.5 → dataquery_sdk-1.2.7}/dataquery/types/exceptions.py +0 -0
  49. {dataquery_sdk-1.2.5 → dataquery_sdk-1.2.7}/dataquery_sdk.egg-info/dependency_links.txt +0 -0
  50. {dataquery_sdk-1.2.5 → dataquery_sdk-1.2.7}/dataquery_sdk.egg-info/entry_points.txt +0 -0
  51. {dataquery_sdk-1.2.5 → dataquery_sdk-1.2.7}/dataquery_sdk.egg-info/top_level.txt +0 -0
  52. {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.5
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>=2.14; extra == "mcp"
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>=2.14; extra == "all"
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
- Add the server to your client's MCP config (`claude_desktop_config.json`,
471
- `.mcp.json`, or the equivalent for your host):
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
- Add the server to your client's MCP config (`claude_desktop_config.json`,
399
- `.mcp.json`, or the equivalent for your host):
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
@@ -1,6 +1,6 @@
1
1
  """DATAQUERY SDK - Python SDK for DATAQUERY Data API."""
2
2
 
3
- __version__ = "1.2.5"
3
+ __version__ = "1.2.7"
4
4
  __author__ = "DATAQUERY SDK Team"
5
5
  __email__ = "dataquery_support@jpmorgan.com"
6
6
  __license__ = "MIT"
@@ -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 ClientConfig.model_fields.items():
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 ClientConfig.model_fields.items():
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 ClientConfig.model_fields.items():
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
- "headers": {
290
- "User-Agent": f"DATAQUERY-SDK/{version}",
291
- "Connection": "keep-alive",
292
- "Accept-Encoding": "gzip, deflate",
293
- **self.config.get_custom_headers(),
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
- return headers
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
- async with self._get_lock():
121
- while True:
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
- if timeout is not None:
133
- elapsed = time.time() - start_time
134
- if elapsed + wait_time > timeout:
135
- return False
136
+ if timeout is not None:
137
+ elapsed = time.time() - start_time
138
+ if elapsed + wait_time > timeout:
139
+ return False
136
140
 
137
- await asyncio.sleep(min(wait_time, 0.1))
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
- x_user_agent: Optional[str] = Field(
55
- default=None,
56
- description="Optional value for the X-User-Agent header sent on each API request",
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 to attach to every API request (e.g. X-User-Agent)."""
255
- headers: Dict[str, str] = {}
256
- if self.x_user_agent:
257
- headers["X-User-Agent"] = self.x_user_agent
258
- return headers
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.5
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>=2.14; extra == "mcp"
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>=2.14; extra == "all"
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
- Add the server to your client's MCP config (`claude_desktop_config.json`,
471
- `.mcp.json`, or the equivalent for your host):
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
@@ -9,6 +9,7 @@ dataquery/cli.py
9
9
  dataquery/dataquery.py
10
10
  dataquery/export.py
11
11
  dataquery/function_registry.py
12
+ dataquery/mcp_install.py
12
13
  dataquery/mcp_proxy.py
13
14
  dataquery/py.typed
14
15
  dataquery/utils.py
@@ -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>=2.14
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>=2.14
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",
@@ -1,5 +0,0 @@
1
- """Rate-limiter tunables."""
2
-
3
- from __future__ import annotations
4
-
5
- RATE_LIMIT_MIN_WAIT_SECONDS = 0.2
File without changes
File without changes
File without changes