hf-mcp 0.1.0__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 (47) hide show
  1. hf_mcp-0.1.0/MANIFEST.in +9 -0
  2. hf_mcp-0.1.0/PKG-INFO +84 -0
  3. hf_mcp-0.1.0/README.md +67 -0
  4. hf_mcp-0.1.0/config.example.yaml +39 -0
  5. hf_mcp-0.1.0/docs/configuration.md +90 -0
  6. hf_mcp-0.1.0/docs/coverage_matrix.md +38 -0
  7. hf_mcp-0.1.0/docs/export_boundary.md +28 -0
  8. hf_mcp-0.1.0/docs/security_model.md +25 -0
  9. hf_mcp-0.1.0/export_manifest.toml +12 -0
  10. hf_mcp-0.1.0/pyproject.toml +30 -0
  11. hf_mcp-0.1.0/setup.cfg +4 -0
  12. hf_mcp-0.1.0/src/hf_mcp/__init__.py +4 -0
  13. hf_mcp-0.1.0/src/hf_mcp/__main__.py +5 -0
  14. hf_mcp-0.1.0/src/hf_mcp/annotations.py +18 -0
  15. hf_mcp-0.1.0/src/hf_mcp/auth.py +178 -0
  16. hf_mcp-0.1.0/src/hf_mcp/capabilities.py +134 -0
  17. hf_mcp-0.1.0/src/hf_mcp/cli.py +191 -0
  18. hf_mcp-0.1.0/src/hf_mcp/config.py +363 -0
  19. hf_mcp-0.1.0/src/hf_mcp/dispatcher.py +114 -0
  20. hf_mcp-0.1.0/src/hf_mcp/metadata.py +26 -0
  21. hf_mcp-0.1.0/src/hf_mcp/normalizers.py +177 -0
  22. hf_mcp-0.1.0/src/hf_mcp/onboarding.py +219 -0
  23. hf_mcp-0.1.0/src/hf_mcp/registry.py +334 -0
  24. hf_mcp-0.1.0/src/hf_mcp/schemas.py +75 -0
  25. hf_mcp-0.1.0/src/hf_mcp/server.py +105 -0
  26. hf_mcp-0.1.0/src/hf_mcp/token_store.py +115 -0
  27. hf_mcp-0.1.0/src/hf_mcp/tools/read_core.py +123 -0
  28. hf_mcp-0.1.0/src/hf_mcp/tools/read_extended.py +176 -0
  29. hf_mcp-0.1.0/src/hf_mcp/tools/write_documented.py +134 -0
  30. hf_mcp-0.1.0/src/hf_mcp/transport.py +87 -0
  31. hf_mcp-0.1.0/src/hf_mcp.egg-info/PKG-INFO +84 -0
  32. hf_mcp-0.1.0/src/hf_mcp.egg-info/SOURCES.txt +45 -0
  33. hf_mcp-0.1.0/src/hf_mcp.egg-info/dependency_links.txt +1 -0
  34. hf_mcp-0.1.0/src/hf_mcp.egg-info/entry_points.txt +2 -0
  35. hf_mcp-0.1.0/src/hf_mcp.egg-info/requires.txt +2 -0
  36. hf_mcp-0.1.0/src/hf_mcp.egg-info/top_level.txt +1 -0
  37. hf_mcp-0.1.0/tests/test_auth_transport.py +368 -0
  38. hf_mcp-0.1.0/tests/test_capability_policy.py +244 -0
  39. hf_mcp-0.1.0/tests/test_dispatcher_surface.py +361 -0
  40. hf_mcp-0.1.0/tests/test_entrypoints.py +175 -0
  41. hf_mcp-0.1.0/tests/test_export_boundary.py +100 -0
  42. hf_mcp-0.1.0/tests/test_onboarding_cli.py +133 -0
  43. hf_mcp-0.1.0/tests/test_read_core.py +187 -0
  44. hf_mcp-0.1.0/tests/test_read_extended.py +197 -0
  45. hf_mcp-0.1.0/tests/test_registry_schemas.py +99 -0
  46. hf_mcp-0.1.0/tests/test_runtime_config.py +207 -0
  47. hf_mcp-0.1.0/tests/test_write_documented.py +208 -0
@@ -0,0 +1,9 @@
1
+ include README.md
2
+ include pyproject.toml
3
+ include MANIFEST.in
4
+ include config.example.yaml
5
+ include export_manifest.toml
6
+ recursive-include docs *.md
7
+ recursive-include tests *.py
8
+ recursive-include src/hf_mcp *.py
9
+ global-exclude __pycache__ *.py[cod]
hf_mcp-0.1.0/PKG-INFO ADDED
@@ -0,0 +1,84 @@
1
+ Metadata-Version: 2.4
2
+ Name: hf-mcp
3
+ Version: 0.1.0
4
+ Summary: Standalone Hack Forums MCP launch surface
5
+ Keywords: hackforums,mcp,model-context-protocol
6
+ Classifier: Environment :: Console
7
+ Classifier: Intended Audience :: Developers
8
+ Classifier: Programming Language :: Python :: 3
9
+ Classifier: Programming Language :: Python :: 3 :: Only
10
+ Classifier: Programming Language :: Python :: 3.11
11
+ Classifier: Topic :: Internet
12
+ Classifier: Topic :: Software Development :: Libraries :: Python Modules
13
+ Requires-Python: >=3.11
14
+ Description-Content-Type: text/markdown
15
+ Requires-Dist: PyYAML>=6.0
16
+ Requires-Dist: mcp>=1.0.0
17
+
18
+ # hf-mcp
19
+
20
+ `hf-mcp` is a standalone MCP server package for the Hack Forums API v2.
21
+
22
+ ## Product target
23
+
24
+ The product target is full documented Hack Forums API coverage. The registry and
25
+ coverage matrix include:
26
+
27
+ - core read helpers (`me`, `users`, `forums`, `threads`, `posts`)
28
+ - extended read helpers (`bytes`, `contracts`, `disputes`, `bratings`)
29
+ - later-lane documented read/write rows for `sigmarket`, `contract`, and
30
+ documented `admin/high-risk` helpers
31
+ - documented write helpers (`threads.create`, `posts.reply`, `bytes.transfer`,
32
+ `bytes.deposit`, `bytes.withdraw`, `bytes.bump`)
33
+
34
+ ## Exposure model
35
+
36
+ Profiles and overlays are an exposure control layer for a specific user or
37
+ deployment. They do not redefine product scope. If a deployment disables a row,
38
+ that row still remains in the documented product coverage matrix.
39
+
40
+ ## Launch surface
41
+
42
+ - console entrypoint: `hf-mcp`
43
+ - module entrypoint: `python -m hf_mcp`
44
+ - `hf-mcp` aliases `hf-mcp serve` (stdio runtime launch)
45
+ - `hf-mcp setup init` creates first-run local config and prints the next commands
46
+ - `hf-mcp doctor` validates local config/secrets/token readiness without network I/O
47
+ - `hf-mcp` ships the `mcp` runtime dependency required for the stdio launch path
48
+ - `hf-mcp serve` runs one stdio server instance and exits when the stdio client disconnects
49
+ - `hf-mcp serve` fails closed on invalid bootstrap state (config/secrets/token/runtime dependency)
50
+
51
+ ## First run
52
+
53
+ ```bash
54
+ pip install hf-mcp
55
+ hf-mcp setup init
56
+ ```
57
+
58
+ Set secrets in your local environment (`.env` or shell):
59
+
60
+ ```bash
61
+ export HF_MCP_CLIENT_ID=your_client_id
62
+ export HF_MCP_CLIENT_SECRET=your_client_secret
63
+ ```
64
+
65
+ Finish local bootstrap and readiness checks:
66
+
67
+ ```bash
68
+ hf-mcp auth bootstrap
69
+ hf-mcp doctor
70
+ hf-mcp serve
71
+ ```
72
+
73
+ ## Source distribution boundary
74
+
75
+ `MANIFEST.in` defines a package-local source distribution boundary rooted in
76
+ `products/hf_mcp`. The source distribution includes package docs, tests, and
77
+ runtime source from this product subtree only.
78
+
79
+ ## Documentation
80
+
81
+ - `docs/configuration.md`
82
+ - `docs/coverage_matrix.md`
83
+ - `docs/security_model.md`
84
+ - `docs/export_boundary.md`
hf_mcp-0.1.0/README.md ADDED
@@ -0,0 +1,67 @@
1
+ # hf-mcp
2
+
3
+ `hf-mcp` is a standalone MCP server package for the Hack Forums API v2.
4
+
5
+ ## Product target
6
+
7
+ The product target is full documented Hack Forums API coverage. The registry and
8
+ coverage matrix include:
9
+
10
+ - core read helpers (`me`, `users`, `forums`, `threads`, `posts`)
11
+ - extended read helpers (`bytes`, `contracts`, `disputes`, `bratings`)
12
+ - later-lane documented read/write rows for `sigmarket`, `contract`, and
13
+ documented `admin/high-risk` helpers
14
+ - documented write helpers (`threads.create`, `posts.reply`, `bytes.transfer`,
15
+ `bytes.deposit`, `bytes.withdraw`, `bytes.bump`)
16
+
17
+ ## Exposure model
18
+
19
+ Profiles and overlays are an exposure control layer for a specific user or
20
+ deployment. They do not redefine product scope. If a deployment disables a row,
21
+ that row still remains in the documented product coverage matrix.
22
+
23
+ ## Launch surface
24
+
25
+ - console entrypoint: `hf-mcp`
26
+ - module entrypoint: `python -m hf_mcp`
27
+ - `hf-mcp` aliases `hf-mcp serve` (stdio runtime launch)
28
+ - `hf-mcp setup init` creates first-run local config and prints the next commands
29
+ - `hf-mcp doctor` validates local config/secrets/token readiness without network I/O
30
+ - `hf-mcp` ships the `mcp` runtime dependency required for the stdio launch path
31
+ - `hf-mcp serve` runs one stdio server instance and exits when the stdio client disconnects
32
+ - `hf-mcp serve` fails closed on invalid bootstrap state (config/secrets/token/runtime dependency)
33
+
34
+ ## First run
35
+
36
+ ```bash
37
+ pip install hf-mcp
38
+ hf-mcp setup init
39
+ ```
40
+
41
+ Set secrets in your local environment (`.env` or shell):
42
+
43
+ ```bash
44
+ export HF_MCP_CLIENT_ID=your_client_id
45
+ export HF_MCP_CLIENT_SECRET=your_client_secret
46
+ ```
47
+
48
+ Finish local bootstrap and readiness checks:
49
+
50
+ ```bash
51
+ hf-mcp auth bootstrap
52
+ hf-mcp doctor
53
+ hf-mcp serve
54
+ ```
55
+
56
+ ## Source distribution boundary
57
+
58
+ `MANIFEST.in` defines a package-local source distribution boundary rooted in
59
+ `products/hf_mcp`. The source distribution includes package docs, tests, and
60
+ runtime source from this product subtree only.
61
+
62
+ ## Documentation
63
+
64
+ - `docs/configuration.md`
65
+ - `docs/coverage_matrix.md`
66
+ - `docs/security_model.md`
67
+ - `docs/export_boundary.md`
@@ -0,0 +1,39 @@
1
+ # hf-mcp capability exposure configuration
2
+ #
3
+ # Presets:
4
+ # - reader: read-oriented exposure
5
+ # - forum_operator: read + core content/bytes write exposure
6
+ # - full_api: broadest shipped exposure preset (explicit opt-in)
7
+ # - custom: start empty and explicitly opt in
8
+ #
9
+ # IMPORTANT:
10
+ # Presets are exposure control for a user/deployment. They do not reduce product
11
+ # scope. The product target remains full documented HF API coverage, including
12
+ # later-lane contract, sigmarket, and admin/high-risk rows tracked in
13
+ # docs/coverage_matrix.md.
14
+
15
+ profile: reader
16
+
17
+ # Optional explicit overlays for any profile.
18
+ # These keep naming human-readable and fail closed on unknown values.
19
+ # enabled_capabilities: []
20
+ # enabled_parameter_families: []
21
+ # disabled_capabilities: []
22
+ # disabled_parameter_families: []
23
+
24
+ # Optional runtime path override. Must be absolute and outside this repository.
25
+ # token_path: /home/you/.config/hf_mcp/token.json
26
+
27
+ # Capabilities currently exposed by profile = "full_api":
28
+ # me.read, users.read, forums.read, threads.read, posts.read, bytes.read,
29
+ # contracts.read, disputes.read, bratings.read, sigmarket.market.read,
30
+ # sigmarket.order.read, threads.create, posts.reply, bytes.transfer,
31
+ # bytes.deposit, bytes.withdraw, bytes.bump, contracts.write,
32
+ # sigmarket.write, admin.high_risk.read, admin.high_risk.write
33
+
34
+ # Parameter families currently exposed by profile = "full_api":
35
+ # selectors.user, selectors.forum, selectors.thread, selectors.post,
36
+ # selectors.bytes, selectors.contract, selectors.dispute, selectors.sigmarket,
37
+ # filters.pagination, fields.me.basic, fields.me.advanced,
38
+ # fields.users.profile, fields.posts.body, fields.bytes.amount,
39
+ # writes.content, writes.bytes, confirm.live
@@ -0,0 +1,90 @@
1
+ # Configuration
2
+
3
+ `hf-mcp` uses a presets-first configuration model with fail-closed overlays.
4
+
5
+ ## Scope and exposure control
6
+
7
+ The product scope is the full documented Hack Forums API surface captured in the
8
+ coverage matrix. Configuration is an exposure control mechanism for the active
9
+ user or deployment:
10
+
11
+ - `enabled_capabilities` and `disabled_capabilities` decide which tool families
12
+ can register.
13
+ - `enabled_parameter_families` and `disabled_parameter_families` decide which
14
+ schema inputs remain visible and accepted.
15
+
16
+ This means a deployment can disable `sigmarket`, `contract`, or
17
+ `admin/high-risk` rows without redefining the product target.
18
+
19
+ ## Profiles
20
+
21
+ - `reader`: read-oriented exposure profile.
22
+ - `forum_operator`: read + core forum/content/bytes write exposure.
23
+ - `full_api`: broadest shipped exposure profile.
24
+ - `custom`: starts empty; caller opts in explicitly.
25
+
26
+ `reader` is the recommended first-run profile. Use `full_api` as an explicit
27
+ opt-in when validating the full documented coverage surface.
28
+
29
+ ## First-run setup flow
30
+
31
+ Run the terminal-native onboarding commands in this order:
32
+
33
+ ```bash
34
+ pip install hf-mcp
35
+ hf-mcp setup init
36
+ ```
37
+
38
+ Then set local secrets (for example in `.env` or shell):
39
+
40
+ ```bash
41
+ export HF_MCP_CLIENT_ID=your_client_id
42
+ export HF_MCP_CLIENT_SECRET=your_client_secret
43
+ ```
44
+
45
+ Complete auth/bootstrap and local readiness validation:
46
+
47
+ ```bash
48
+ hf-mcp auth bootstrap
49
+ hf-mcp doctor
50
+ hf-mcp serve
51
+ ```
52
+
53
+ ## Runtime configuration responsibilities
54
+
55
+ - YAML is the canonical runtime config input for non-secret policy:
56
+ `profile`, capability overlays, parameter-family overlays, and optional
57
+ `token_path`.
58
+ - `.env` is for secrets and machine-local overrides only (for example
59
+ `HF_MCP_CLIENT_ID`, `HF_MCP_CLIENT_SECRET`, `HF_MCP_TOKEN_PATH`).
60
+ - `.env` must not be used to choose non-secret runtime policy such as
61
+ `profile`, capability families, or parameter families.
62
+ - `token_path` must resolve to an absolute path outside the tracked repository.
63
+
64
+ ## Overlays
65
+
66
+ Overlays are additive/subtractive and always validated against known family
67
+ names:
68
+
69
+ - `enabled_capabilities`
70
+ - `disabled_capabilities`
71
+ - `enabled_parameter_families`
72
+ - `disabled_parameter_families`
73
+
74
+ Unknown names fail fast during config load.
75
+
76
+ ## Example
77
+
78
+ ```yaml
79
+ profile: reader
80
+
81
+ disabled_capabilities:
82
+ - sigmarket.market.read
83
+ - sigmarket.order.read
84
+
85
+ disabled_parameter_families:
86
+ - fields.me.advanced
87
+ ```
88
+
89
+ The example above is still the same product. It is a narrower exposure profile
90
+ for one deployment.
@@ -0,0 +1,38 @@
1
+ # Coverage Matrix
2
+
3
+ This matrix tracks the public product target for `hf-mcp`: full documented HF
4
+ API coverage. Deployment policy can hide rows, but rows remain in-scope product
5
+ coverage.
6
+
7
+ ## Registry-backed rows
8
+
9
+ | Coverage family | Operation | Helper path | Product scope status | Exposure note |
10
+ |---|---|---|---|---|
11
+ | `me.read` | read | `me` | in scope | can be disabled by capability policy |
12
+ | `users.read` | read | `users` | in scope | can be disabled by capability policy |
13
+ | `forums.read` | read | `forums` | in scope | can be disabled by capability policy |
14
+ | `threads.read` | read | `threads` | in scope | can be disabled by capability policy |
15
+ | `posts.read` | read | `posts` | in scope | can be disabled by capability policy |
16
+ | `bytes.read` | read | `bytes` | in scope | can be disabled by capability policy |
17
+ | `contracts.read` | read | `contracts` | in scope (later-lane row retained) | deployment exposure control may keep disabled |
18
+ | `disputes.read` | read | `disputes` | in scope | can be disabled by capability policy |
19
+ | `bratings.read` | read | `bratings` | in scope | can be disabled by capability policy |
20
+ | `sigmarket.market.read` | read | `sigmarket/market` | in scope (later-lane row retained) | deployment exposure control may keep disabled |
21
+ | `sigmarket.order.read` | read | `sigmarket/order` | in scope (later-lane row retained) | deployment exposure control may keep disabled |
22
+ | `admin.high_risk.read` | read | `admin/high-risk/read` | in scope (documented admin/high-risk row retained) | deployment exposure control may keep disabled |
23
+ | `threads.create` | write | `threads` | in scope | `confirm_live` guard required |
24
+ | `posts.reply` | write | `posts` | in scope | `confirm_live` guard required |
25
+ | `bytes.transfer` | write | `bytes` | in scope | `confirm_live` guard required |
26
+ | `bytes.deposit` | write | `bytes/deposit` | in scope | `confirm_live` guard required |
27
+ | `bytes.withdraw` | write | `bytes/withdraw` | in scope | `confirm_live` guard required |
28
+ | `bytes.bump` | write | `bytes/bump` | in scope | `confirm_live` guard required |
29
+ | `contracts.write` | write | `contracts` | in scope (later-lane contract row retained) | deployment exposure control may keep disabled until helper proof is named |
30
+ | `sigmarket.write` | write | `sigmarket` | in scope (later-lane sigmarket row retained) | deployment exposure control may keep disabled until helper proof is named |
31
+ | `admin.high_risk.write` | write | `admin/high-risk/write` | in scope (documented admin/high-risk row retained) | deployment exposure control may keep disabled until helper proof is named |
32
+
33
+ ## Interpretation
34
+
35
+ - A disabled row is still part of product scope.
36
+ - A later-lane row is still part of product scope.
37
+ - If documented HF API coverage expands, this matrix must add named rows rather
38
+ than replacing specifics with vague future language.
@@ -0,0 +1,28 @@
1
+ # Export Boundary
2
+
3
+ `hf-mcp` is published from a product subtree. Export is allowlist-based.
4
+
5
+ ## Authoritative manifest
6
+
7
+ `export_manifest.toml` is the machine-readable allowlist for exported files.
8
+ Allowed entries are scoped to `products/hf_mcp/` only.
9
+
10
+ ## Included public surfaces
11
+
12
+ - package metadata and launch docs (`pyproject.toml`, `README.md`)
13
+ - example configuration (`config.example.yaml`)
14
+ - public documentation (`docs/**`)
15
+ - package source (`src/hf_mcp/**`)
16
+ - package tests (`tests/**`)
17
+
18
+ ## Boundary guarantees
19
+
20
+ - Root-level repository assets are outside the export boundary.
21
+ - Internal orchestration/planning assets are outside the export boundary.
22
+ - Release packaging should fail if any allowlist entry leaves the
23
+ `products/hf_mcp/` subtree.
24
+ - Runtime policy is YAML-first: non-secret policy belongs in `config.yaml`.
25
+ - `.env` is reserved for secrets and machine-local overrides; it must not define
26
+ profile/capability/parameter policy.
27
+ - Token-store location must be an absolute path outside the tracked repository
28
+ tree.
@@ -0,0 +1,25 @@
1
+ # Security Model
2
+
3
+ `hf-mcp` is a networked MCP server for live HF API calls, so every enabled tool
4
+ should be treated as open-world input/output.
5
+
6
+ ## Core controls
7
+
8
+ - Capability policy is fail-closed. Unknown capability or parameter-family names
9
+ fail config load.
10
+ - Parameter-family pruning keeps schema exposure aligned with runtime validation.
11
+ - Documented write helpers require explicit `confirm_live = true`.
12
+ - Capability and parameter families can restrict user exposure for high-risk
13
+ rows such as `contract`, `sigmarket`, and `admin/high-risk` helpers.
14
+
15
+ ## Secret handling
16
+
17
+ - Keep OAuth client credentials and access tokens in private runtime storage.
18
+ - Do not commit credentials into config files, docs, or tests.
19
+ - Rotate credentials if they are ever exposed.
20
+
21
+ ## Operational guidance
22
+
23
+ - Start from read-oriented exposure when validating a new deployment.
24
+ - Enable write families deliberately and verify each path with test coverage.
25
+ - Keep audit logs outside the tracked package tree.
@@ -0,0 +1,12 @@
1
+ manifest_version = 1
2
+ package_root = "products/hf_mcp"
3
+
4
+ allowlist = [
5
+ "products/hf_mcp/README.md",
6
+ "products/hf_mcp/pyproject.toml",
7
+ "products/hf_mcp/config.example.yaml",
8
+ "products/hf_mcp/export_manifest.toml",
9
+ "products/hf_mcp/docs/**",
10
+ "products/hf_mcp/src/hf_mcp/**",
11
+ "products/hf_mcp/tests/**",
12
+ ]
@@ -0,0 +1,30 @@
1
+ [build-system]
2
+ requires = ["setuptools>=68", "wheel"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "hf-mcp"
7
+ version = "0.1.0"
8
+ description = "Standalone Hack Forums MCP launch surface"
9
+ keywords = ["hackforums", "mcp", "model-context-protocol"]
10
+ classifiers = [
11
+ "Environment :: Console",
12
+ "Intended Audience :: Developers",
13
+ "Programming Language :: Python :: 3",
14
+ "Programming Language :: Python :: 3 :: Only",
15
+ "Programming Language :: Python :: 3.11",
16
+ "Topic :: Internet",
17
+ "Topic :: Software Development :: Libraries :: Python Modules",
18
+ ]
19
+ readme = "README.md"
20
+ requires-python = ">=3.11"
21
+ dependencies = ["PyYAML>=6.0", "mcp>=1.0.0"]
22
+
23
+ [project.scripts]
24
+ hf-mcp = "hf_mcp.cli:main"
25
+
26
+ [tool.setuptools]
27
+ package-dir = {"" = "src"}
28
+
29
+ [tool.setuptools.packages.find]
30
+ where = ["src"]
hf_mcp-0.1.0/setup.cfg ADDED
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,4 @@
1
+ """Public package root for the standalone hf-mcp launch surface."""
2
+
3
+ __all__ = ["__version__"]
4
+ __version__ = "0.1.0"
@@ -0,0 +1,5 @@
1
+ from .cli import main
2
+
3
+
4
+ if __name__ == "__main__":
5
+ raise SystemExit(main())
@@ -0,0 +1,18 @@
1
+ from __future__ import annotations
2
+
3
+ from .metadata import build_tool_meta
4
+ from .registry import ToolSpec
5
+
6
+
7
+ def build_annotations(spec: ToolSpec) -> dict[str, object]:
8
+ is_read = spec.operation == "read"
9
+ return {
10
+ "readOnlyHint": is_read,
11
+ "destructiveHint": not is_read,
12
+ "idempotentHint": is_read,
13
+ "openWorldHint": True,
14
+ "_meta": build_tool_meta(spec),
15
+ }
16
+
17
+
18
+ __all__ = ["build_annotations"]
@@ -0,0 +1,178 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import os
5
+ import secrets
6
+ import time
7
+ import urllib.error
8
+ import urllib.parse
9
+ import urllib.request
10
+ import webbrowser
11
+ from http.server import BaseHTTPRequestHandler, HTTPServer
12
+ from typing import Mapping
13
+
14
+ from .config import HFMCPSettings
15
+ from .token_store import TokenBundle
16
+
17
+ DEFAULT_AUTHORIZE_ENDPOINT = "https://hackforums.net/api/v2/authorize"
18
+
19
+
20
+ def authorize_via_loopback(settings: HFMCPSettings, open_browser: bool = True) -> TokenBundle:
21
+ client_id = _required_setting(settings, "HF_MCP_CLIENT_ID")
22
+ client_secret = _required_setting(settings, "HF_MCP_CLIENT_SECRET")
23
+
24
+ redirect_uri = settings.runtime_env.get("HF_MCP_REDIRECT_URI", "http://127.0.0.1:8765/callback")
25
+ authorize_url = settings.runtime_env.get("HF_MCP_AUTHORIZE_URL", DEFAULT_AUTHORIZE_ENDPOINT)
26
+ token_url = settings.runtime_env.get("HF_MCP_TOKEN_URL", DEFAULT_AUTHORIZE_ENDPOINT)
27
+
28
+ timeout_raw = settings.runtime_env.get("HF_MCP_AUTH_TIMEOUT_SECONDS", "180")
29
+ try:
30
+ timeout_seconds = int(timeout_raw)
31
+ except (TypeError, ValueError) as exc:
32
+ raise ValueError("HF_MCP_AUTH_TIMEOUT_SECONDS must be an integer number of seconds.") from exc
33
+
34
+ state = secrets.token_urlsafe(24)
35
+ browser_url = _build_authorize_url(
36
+ authorize_url=authorize_url,
37
+ client_id=client_id,
38
+ redirect_uri=redirect_uri,
39
+ state=state,
40
+ )
41
+
42
+ if open_browser:
43
+ webbrowser.open(browser_url)
44
+
45
+ callback_params = _await_loopback_callback(redirect_uri=redirect_uri, timeout_seconds=timeout_seconds)
46
+ callback_state = callback_params.get("state")
47
+ if callback_state != state:
48
+ raise ValueError("OAuth callback state mismatch. Aborting token exchange.")
49
+
50
+ code = callback_params.get("code")
51
+ if code is None or not code.strip():
52
+ raise ValueError("OAuth callback is missing authorization code.")
53
+
54
+ token_payload = _exchange_code_for_token(
55
+ token_url=token_url,
56
+ client_id=client_id,
57
+ client_secret=client_secret,
58
+ code=code,
59
+ redirect_uri=redirect_uri,
60
+ )
61
+ return TokenBundle.from_payload(token_payload)
62
+
63
+
64
+ def _build_authorize_url(
65
+ authorize_url: str,
66
+ client_id: str,
67
+ redirect_uri: str,
68
+ state: str,
69
+ ) -> str:
70
+ params = urllib.parse.urlencode(
71
+ {
72
+ "client_id": client_id,
73
+ "response_type": "code",
74
+ "redirect_uri": redirect_uri,
75
+ "state": state,
76
+ }
77
+ )
78
+ separator = "&" if "?" in authorize_url else "?"
79
+ return f"{authorize_url}{separator}{params}"
80
+
81
+
82
+ def _await_loopback_callback(redirect_uri: str, timeout_seconds: int) -> Mapping[str, str]:
83
+ parsed_uri = urllib.parse.urlparse(redirect_uri)
84
+ host = parsed_uri.hostname
85
+ port = parsed_uri.port
86
+ callback_path = parsed_uri.path or "/callback"
87
+
88
+ if host not in {"127.0.0.1", "localhost"}:
89
+ raise ValueError("Redirect URI must use localhost or 127.0.0.1 loopback host.")
90
+ if port is None:
91
+ raise ValueError("Redirect URI must include an explicit loopback port.")
92
+
93
+ callback_state: dict[str, str] = {}
94
+
95
+ class _CallbackHandler(BaseHTTPRequestHandler):
96
+ def do_GET(self) -> None: # noqa: N802 (standard library method name)
97
+ parsed_request = urllib.parse.urlparse(self.path)
98
+ if parsed_request.path != callback_path:
99
+ self.send_response(404)
100
+ self.end_headers()
101
+ return
102
+
103
+ parsed_query = urllib.parse.parse_qs(parsed_request.query, keep_blank_values=True)
104
+ for key in ("code", "state", "error"):
105
+ values = parsed_query.get(key)
106
+ if values:
107
+ callback_state[key] = values[0]
108
+
109
+ self.send_response(200)
110
+ self.send_header("Content-Type", "text/plain; charset=utf-8")
111
+ self.end_headers()
112
+ self.wfile.write(b"You can close this tab and return to the terminal.")
113
+
114
+ def log_message(self, format: str, *args: object) -> None: # noqa: A003
115
+ del format, args
116
+ return
117
+
118
+ server = HTTPServer((host, port), _CallbackHandler)
119
+ server.timeout = 0.25
120
+
121
+ deadline = time.monotonic() + timeout_seconds
122
+ while time.monotonic() < deadline and "code" not in callback_state and "error" not in callback_state:
123
+ server.handle_request()
124
+
125
+ server.server_close()
126
+
127
+ if "error" in callback_state:
128
+ raise ValueError(f"OAuth provider returned error: {callback_state['error']}")
129
+ if "code" not in callback_state:
130
+ raise TimeoutError("Timed out waiting for OAuth loopback callback.")
131
+ return callback_state
132
+
133
+
134
+ def _exchange_code_for_token(
135
+ token_url: str,
136
+ client_id: str,
137
+ client_secret: str,
138
+ code: str,
139
+ redirect_uri: str,
140
+ ) -> dict[str, object]:
141
+ encoded_payload = urllib.parse.urlencode(
142
+ {
143
+ "client_id": client_id,
144
+ "client_secret": client_secret,
145
+ "code": code,
146
+ "grant_type": "authorization_code",
147
+ "redirect_uri": redirect_uri,
148
+ }
149
+ ).encode("utf-8")
150
+
151
+ request = urllib.request.Request(
152
+ token_url,
153
+ data=encoded_payload,
154
+ method="POST",
155
+ headers={"Content-Type": "application/x-www-form-urlencoded"},
156
+ )
157
+
158
+ try:
159
+ with urllib.request.urlopen(request, timeout=15) as response:
160
+ response_body = response.read().decode("utf-8")
161
+ except urllib.error.HTTPError as error:
162
+ error_body = error.read().decode("utf-8", errors="replace")
163
+ raise RuntimeError(f"Token exchange failed: HTTP {error.code}. {error_body}") from error
164
+
165
+ parsed = json.loads(response_body)
166
+ if not isinstance(parsed, dict):
167
+ raise ValueError("Token exchange returned non-object JSON payload.")
168
+ return parsed
169
+
170
+
171
+ def _required_setting(settings: HFMCPSettings, name: str) -> str:
172
+ value = settings.runtime_env.get(name) or os.environ.get(name)
173
+ if value is None or not value.strip():
174
+ raise ValueError(f"Missing required environment variable: {name}")
175
+ return value.strip()
176
+
177
+
178
+ __all__ = ["authorize_via_loopback"]