fetchgate 0.1.0__tar.gz → 0.2.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.
- fetchgate-0.2.0/.claude-plugin/marketplace.json +17 -0
- fetchgate-0.2.0/.claude-plugin/plugin.json +16 -0
- {fetchgate-0.1.0 → fetchgate-0.2.0}/.gitignore +1 -0
- {fetchgate-0.1.0 → fetchgate-0.2.0}/PKG-INFO +39 -4
- {fetchgate-0.1.0 → fetchgate-0.2.0}/README.md +36 -3
- fetchgate-0.2.0/bundle/manifest.json +17 -0
- fetchgate-0.2.0/bundle/requirements.txt +1 -0
- fetchgate-0.2.0/bundle/server/main.py +4 -0
- fetchgate-0.2.0/docs/claude-code-plugin.md +43 -0
- fetchgate-0.2.0/docs/config.md +54 -0
- fetchgate-0.2.0/docs/evaluation.md +37 -0
- fetchgate-0.2.0/fetchgate.example.toml +22 -0
- {fetchgate-0.1.0 → fetchgate-0.2.0}/pyproject.toml +2 -1
- fetchgate-0.2.0/smithery.yaml +11 -0
- {fetchgate-0.1.0 → fetchgate-0.2.0}/src/fetchgate/__init__.py +3 -3
- fetchgate-0.2.0/src/fetchgate/app.py +21 -0
- {fetchgate-0.1.0 → fetchgate-0.2.0}/src/fetchgate/config.py +44 -0
- {fetchgate-0.1.0 → fetchgate-0.2.0}/src/fetchgate/eval/corpus/eval_manifest.json +30 -30
- {fetchgate-0.1.0 → fetchgate-0.2.0}/src/fetchgate/eval/corpus/sample_envelope.json +2 -2
- {fetchgate-0.1.0 → fetchgate-0.2.0}/src/fetchgate/eval/run_eval.py +1 -1
- {fetchgate-0.1.0 → fetchgate-0.2.0}/src/fetchgate/eval/spine.py +1 -1
- fetchgate-0.2.0/src/fetchgate/eval/tier_b.py +107 -0
- {fetchgate-0.1.0 → fetchgate-0.2.0}/src/fetchgate/extract.py +53 -10
- {fetchgate-0.1.0 → fetchgate-0.2.0}/src/fetchgate/fetch.py +5 -4
- {fetchgate-0.1.0 → fetchgate-0.2.0}/src/fetchgate/gate.py +11 -12
- fetchgate-0.2.0/src/fetchgate/mcp_server.py +77 -0
- {fetchgate-0.1.0 → fetchgate-0.2.0}/src/fetchgate/transports.py +34 -20
- {fetchgate-0.1.0 → fetchgate-0.2.0}/src/fetchgate/types.py +11 -0
- fetchgate-0.2.0/tests/test_config.py +72 -0
- {fetchgate-0.1.0 → fetchgate-0.2.0}/tests/test_gate.py +12 -1
- fetchgate-0.2.0/tests/test_mcp.py +68 -0
- fetchgate-0.2.0/tests/test_redirect.py +31 -0
- fetchgate-0.2.0/tests/test_tier_b.py +23 -0
- fetchgate-0.1.0/src/fetchgate/app.py +0 -20
- fetchgate-0.1.0/src/fetchgate/mcp_server.py +0 -55
- fetchgate-0.1.0/tests/test_mcp.py +0 -40
- {fetchgate-0.1.0 → fetchgate-0.2.0}/LICENSE +0 -0
- {fetchgate-0.1.0 → fetchgate-0.2.0}/demo.py +0 -0
- {fetchgate-0.1.0 → fetchgate-0.2.0}/docs/mcp.md +0 -0
- {fetchgate-0.1.0 → fetchgate-0.2.0}/docs/threat-model.md +0 -0
- {fetchgate-0.1.0 → fetchgate-0.2.0}/src/fetchgate/detect.py +0 -0
- {fetchgate-0.1.0 → fetchgate-0.2.0}/src/fetchgate/envelope.py +0 -0
- {fetchgate-0.1.0 → fetchgate-0.2.0}/src/fetchgate/eval/__init__.py +0 -0
- {fetchgate-0.1.0 → fetchgate-0.2.0}/src/fetchgate/verify.py +0 -0
- {fetchgate-0.1.0 → fetchgate-0.2.0}/tests/helpers.py +0 -0
- {fetchgate-0.1.0 → fetchgate-0.2.0}/tests/test_cheat.py +0 -0
- {fetchgate-0.1.0 → fetchgate-0.2.0}/tests/test_decision_table_totality.py +0 -0
- {fetchgate-0.1.0 → fetchgate-0.2.0}/tests/test_envelope.py +0 -0
- {fetchgate-0.1.0 → fetchgate-0.2.0}/tests/test_eval_and_controls.py +0 -0
- {fetchgate-0.1.0 → fetchgate-0.2.0}/tests/test_extract.py +0 -0
- {fetchgate-0.1.0 → fetchgate-0.2.0}/tests/test_render.py +0 -0
- {fetchgate-0.1.0 → fetchgate-0.2.0}/tests/test_transport.py +0 -0
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "fetchgate",
|
|
3
|
+
"description": "FetchGate: a deterministic gate at the fetch boundary for AI agents.",
|
|
4
|
+
"owner": { "name": "Mohamed Azahrioui" },
|
|
5
|
+
"plugins": [
|
|
6
|
+
{
|
|
7
|
+
"name": "fetchgate",
|
|
8
|
+
"source": "./",
|
|
9
|
+
"description": "The agent only answers from a page it actually read.",
|
|
10
|
+
"version": "0.1.0",
|
|
11
|
+
"homepage": "https://github.com/MoAz06/FetchGate",
|
|
12
|
+
"repository": "https://github.com/MoAz06/FetchGate",
|
|
13
|
+
"license": "MIT",
|
|
14
|
+
"keywords": ["mcp", "ai-agents", "guardrails"]
|
|
15
|
+
}
|
|
16
|
+
]
|
|
17
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "fetchgate",
|
|
3
|
+
"displayName": "FetchGate",
|
|
4
|
+
"description": "A gate at the fetch boundary: the agent only answers from a page it actually read.",
|
|
5
|
+
"version": "0.1.0",
|
|
6
|
+
"author": { "name": "Mohamed Azahrioui" },
|
|
7
|
+
"homepage": "https://github.com/MoAz06/FetchGate",
|
|
8
|
+
"repository": "https://github.com/MoAz06/FetchGate",
|
|
9
|
+
"license": "MIT",
|
|
10
|
+
"keywords": ["mcp", "ai-agents", "retrieval", "guardrails"],
|
|
11
|
+
"mcpServers": {
|
|
12
|
+
"fetchgate": {
|
|
13
|
+
"command": "fetchgate-mcp"
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: fetchgate
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.2.0
|
|
4
4
|
Summary: A deterministic gate at the fetch boundary: a 200 is transport success, not a read.
|
|
5
5
|
Project-URL: Homepage, https://github.com/MoAz06/FetchGate
|
|
6
6
|
Project-URL: Repository, https://github.com/MoAz06/FetchGate
|
|
@@ -25,6 +25,8 @@ Requires-Dist: pytest>=8; extra == 'dev'
|
|
|
25
25
|
Requires-Dist: ruff>=0.5; extra == 'dev'
|
|
26
26
|
Provides-Extra: extract
|
|
27
27
|
Requires-Dist: trafilatura>=1.9; extra == 'extract'
|
|
28
|
+
Provides-Extra: http
|
|
29
|
+
Requires-Dist: httpx>=0.27; extra == 'http'
|
|
28
30
|
Provides-Extra: mcp
|
|
29
31
|
Requires-Dist: mcp>=1.2; extra == 'mcp'
|
|
30
32
|
Provides-Extra: sign
|
|
@@ -72,10 +74,32 @@ pip install -e .
|
|
|
72
74
|
python -m unittest discover -s tests
|
|
73
75
|
```
|
|
74
76
|
|
|
75
|
-
##
|
|
77
|
+
## Easiest: one double-click (Claude Desktop)
|
|
78
|
+
|
|
79
|
+
Download `fetchgate.mcpb` from the [Releases](https://github.com/MoAz06/FetchGate/releases)
|
|
80
|
+
page and double-click it. Claude Desktop shows an Install button, no JSON editing.
|
|
81
|
+
The bundle source is in [bundle/](bundle); rebuild it with
|
|
82
|
+
`npx @anthropic-ai/mcpb pack bundle fetchgate.mcpb`.
|
|
83
|
+
|
|
84
|
+
## Vibecoders: add it from inside your editor
|
|
85
|
+
|
|
86
|
+
**Cursor, one click** (needs `uv`, which most setups have):
|
|
87
|
+
|
|
88
|
+
[Add FetchGate to Cursor](cursor://anysphere.cursor-deeplink/mcp/install?name=fetchgate&config=eyJjb21tYW5kIjogInV2eCIsICJhcmdzIjogWyItLWZyb20iLCAiZmV0Y2hnYXRlW21jcF0iLCAiZmV0Y2hnYXRlLW1jcCJdfQ==)
|
|
89
|
+
|
|
90
|
+
**Or just ask your AI.** Paste this into Cursor, Claude Code, or Windsurf:
|
|
91
|
+
|
|
92
|
+
> Add the fetchgate MCP server to this setup. Run `pip install "fetchgate[mcp]"`
|
|
93
|
+
> and register it as an MCP server named `fetchgate` with the command
|
|
94
|
+
> `fetchgate-mcp`. It stops an agent from answering as if it read a page it could
|
|
95
|
+
> not actually fetch.
|
|
96
|
+
|
|
97
|
+
Your assistant runs the install and wires up the config for you.
|
|
98
|
+
|
|
99
|
+
## Use it in Claude Desktop or Cursor (manual)
|
|
76
100
|
|
|
77
101
|
```bash
|
|
78
|
-
pip install ".[mcp]"
|
|
102
|
+
pip install ".[mcp,http,extract]" # the fetch tool, real redirect chain, better extraction
|
|
79
103
|
pip install ".[browser]" # optional: render JavaScript pages
|
|
80
104
|
python -m playwright install chromium
|
|
81
105
|
```
|
|
@@ -89,6 +113,15 @@ Add to your MCP config (details in [docs/mcp.md](docs/mcp.md)):
|
|
|
89
113
|
Your agent now has a `fetch_url` tool that returns content only for a confirmed
|
|
90
114
|
read, and a refusal otherwise.
|
|
91
115
|
|
|
116
|
+
This repo is also a Claude Code plugin. After `pip install "fetchgate[mcp]"`:
|
|
117
|
+
|
|
118
|
+
```bash
|
|
119
|
+
/plugin marketplace add MoAz06/FetchGate
|
|
120
|
+
/plugin install fetchgate@fetchgate
|
|
121
|
+
```
|
|
122
|
+
|
|
123
|
+
See [docs/claude-code-plugin.md](docs/claude-code-plugin.md).
|
|
124
|
+
|
|
92
125
|
## Use it in your own agent
|
|
93
126
|
|
|
94
127
|
```python
|
|
@@ -142,6 +175,8 @@ walled after an honest render stays a non-read.
|
|
|
142
175
|
|
|
143
176
|
The deterministic core, the offline evaluation with a signed manifest, the
|
|
144
177
|
build-breaking cheat-test, the MCP server, and the Playwright render tier. Optional
|
|
145
|
-
extras: `[mcp]`, `[
|
|
178
|
+
extras: `[mcp]`, `[http]` (httpx redirect chain), `[extract]` (trafilatura),
|
|
179
|
+
`[browser]` (Playwright), `[sign]` (Ed25519). When installed they are used
|
|
180
|
+
automatically; the core stays stdlib-only.
|
|
146
181
|
|
|
147
182
|
MIT licensed.
|
|
@@ -39,10 +39,32 @@ pip install -e .
|
|
|
39
39
|
python -m unittest discover -s tests
|
|
40
40
|
```
|
|
41
41
|
|
|
42
|
-
##
|
|
42
|
+
## Easiest: one double-click (Claude Desktop)
|
|
43
|
+
|
|
44
|
+
Download `fetchgate.mcpb` from the [Releases](https://github.com/MoAz06/FetchGate/releases)
|
|
45
|
+
page and double-click it. Claude Desktop shows an Install button, no JSON editing.
|
|
46
|
+
The bundle source is in [bundle/](bundle); rebuild it with
|
|
47
|
+
`npx @anthropic-ai/mcpb pack bundle fetchgate.mcpb`.
|
|
48
|
+
|
|
49
|
+
## Vibecoders: add it from inside your editor
|
|
50
|
+
|
|
51
|
+
**Cursor, one click** (needs `uv`, which most setups have):
|
|
52
|
+
|
|
53
|
+
[Add FetchGate to Cursor](cursor://anysphere.cursor-deeplink/mcp/install?name=fetchgate&config=eyJjb21tYW5kIjogInV2eCIsICJhcmdzIjogWyItLWZyb20iLCAiZmV0Y2hnYXRlW21jcF0iLCAiZmV0Y2hnYXRlLW1jcCJdfQ==)
|
|
54
|
+
|
|
55
|
+
**Or just ask your AI.** Paste this into Cursor, Claude Code, or Windsurf:
|
|
56
|
+
|
|
57
|
+
> Add the fetchgate MCP server to this setup. Run `pip install "fetchgate[mcp]"`
|
|
58
|
+
> and register it as an MCP server named `fetchgate` with the command
|
|
59
|
+
> `fetchgate-mcp`. It stops an agent from answering as if it read a page it could
|
|
60
|
+
> not actually fetch.
|
|
61
|
+
|
|
62
|
+
Your assistant runs the install and wires up the config for you.
|
|
63
|
+
|
|
64
|
+
## Use it in Claude Desktop or Cursor (manual)
|
|
43
65
|
|
|
44
66
|
```bash
|
|
45
|
-
pip install ".[mcp]"
|
|
67
|
+
pip install ".[mcp,http,extract]" # the fetch tool, real redirect chain, better extraction
|
|
46
68
|
pip install ".[browser]" # optional: render JavaScript pages
|
|
47
69
|
python -m playwright install chromium
|
|
48
70
|
```
|
|
@@ -56,6 +78,15 @@ Add to your MCP config (details in [docs/mcp.md](docs/mcp.md)):
|
|
|
56
78
|
Your agent now has a `fetch_url` tool that returns content only for a confirmed
|
|
57
79
|
read, and a refusal otherwise.
|
|
58
80
|
|
|
81
|
+
This repo is also a Claude Code plugin. After `pip install "fetchgate[mcp]"`:
|
|
82
|
+
|
|
83
|
+
```bash
|
|
84
|
+
/plugin marketplace add MoAz06/FetchGate
|
|
85
|
+
/plugin install fetchgate@fetchgate
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
See [docs/claude-code-plugin.md](docs/claude-code-plugin.md).
|
|
89
|
+
|
|
59
90
|
## Use it in your own agent
|
|
60
91
|
|
|
61
92
|
```python
|
|
@@ -109,6 +140,8 @@ walled after an honest render stays a non-read.
|
|
|
109
140
|
|
|
110
141
|
The deterministic core, the offline evaluation with a signed manifest, the
|
|
111
142
|
build-breaking cheat-test, the MCP server, and the Playwright render tier. Optional
|
|
112
|
-
extras: `[mcp]`, `[
|
|
143
|
+
extras: `[mcp]`, `[http]` (httpx redirect chain), `[extract]` (trafilatura),
|
|
144
|
+
`[browser]` (Playwright), `[sign]` (Ed25519). When installed they are used
|
|
145
|
+
automatically; the core stays stdlib-only.
|
|
113
146
|
|
|
114
147
|
MIT licensed.
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
{
|
|
2
|
+
"manifest_version": "0.2",
|
|
3
|
+
"name": "fetchgate",
|
|
4
|
+
"display_name": "FetchGate",
|
|
5
|
+
"version": "0.1.0",
|
|
6
|
+
"description": "A gate at the fetch boundary: the agent only answers from a page it actually read.",
|
|
7
|
+
"author": { "name": "Mohamed Azahrioui" },
|
|
8
|
+
"homepage": "https://github.com/MoAz06/FetchGate",
|
|
9
|
+
"server": {
|
|
10
|
+
"type": "python",
|
|
11
|
+
"entry_point": "server/main.py",
|
|
12
|
+
"mcp_config": {
|
|
13
|
+
"command": "python",
|
|
14
|
+
"args": ["${__dirname}/server/main.py"]
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
fetchgate[mcp]
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
# FetchGate as a Claude Code plugin
|
|
2
|
+
|
|
3
|
+
This repo is also a Claude Code plugin. Installing it registers the `fetchgate`
|
|
4
|
+
MCP server so Claude Code gets the `fetch_url` tool.
|
|
5
|
+
|
|
6
|
+
## Prerequisite
|
|
7
|
+
|
|
8
|
+
The plugin registers the server config, it does not install the Python package.
|
|
9
|
+
So the `fetchgate-mcp` command has to be on your PATH first:
|
|
10
|
+
|
|
11
|
+
```bash
|
|
12
|
+
pip install "fetchgate[mcp]"
|
|
13
|
+
pip install "fetchgate[browser]" # optional: render JavaScript pages
|
|
14
|
+
python -m playwright install chromium # optional, for the render tier
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
## Install
|
|
18
|
+
|
|
19
|
+
```bash
|
|
20
|
+
/plugin marketplace add MoAz06/FetchGate
|
|
21
|
+
/plugin install fetchgate@fetchgate
|
|
22
|
+
/mcp # confirm the fetchgate server is listed
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
Update later with:
|
|
26
|
+
|
|
27
|
+
```bash
|
|
28
|
+
/plugin marketplace update fetchgate
|
|
29
|
+
/plugin update fetchgate@fetchgate
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
## One-click once it is on PyPI
|
|
33
|
+
|
|
34
|
+
After publishing to PyPI you can drop the manual pip step by having the plugin run
|
|
35
|
+
the server through `uvx` (needs `uv` installed). Change the command in
|
|
36
|
+
`.claude-plugin/plugin.json` to:
|
|
37
|
+
|
|
38
|
+
```json
|
|
39
|
+
{ "command": "uvx", "args": ["--from", "fetchgate[mcp]", "fetchgate-mcp"] }
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
Then installing the plugin fetches and runs the server on first use, no separate
|
|
43
|
+
pip install.
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
# Configuring FetchGate
|
|
2
|
+
|
|
3
|
+
FetchGate reads a `fetchgate.toml` if it finds one, so you can set policy without
|
|
4
|
+
touching code. This also configures the MCP server, since it uses `default_gate()`.
|
|
5
|
+
|
|
6
|
+
## Where it looks
|
|
7
|
+
|
|
8
|
+
In order:
|
|
9
|
+
1. a path you pass to `load_policy(path)`
|
|
10
|
+
2. `$FETCHGATE_CONFIG`
|
|
11
|
+
3. `./fetchgate.toml`
|
|
12
|
+
4. `~/.config/fetchgate/config.toml`
|
|
13
|
+
|
|
14
|
+
If none exists, sensible defaults apply (render when the `[browser]` extra is
|
|
15
|
+
installed, otherwise static only).
|
|
16
|
+
|
|
17
|
+
## Example
|
|
18
|
+
|
|
19
|
+
```toml
|
|
20
|
+
default_mode = "render_allowed" # never_render | render_allowed | always_hard_fail
|
|
21
|
+
html_extractor = "auto" # auto | stdlib | trafilatura
|
|
22
|
+
unverified_disposition = "stop" # stop | warn
|
|
23
|
+
max_redirects = 5
|
|
24
|
+
block_cross_origin_redirect = true
|
|
25
|
+
unsupported_type_policy = "UNKNOWN" # UNKNOWN | BYTES_OK
|
|
26
|
+
|
|
27
|
+
[domains]
|
|
28
|
+
"docs.mycompany.com" = "render_allowed"
|
|
29
|
+
"api.stripe.com" = "never_render"
|
|
30
|
+
"pinterest.com" = "always_hard_fail"
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
## Stop or warn on an unconfirmed read
|
|
34
|
+
|
|
35
|
+
The verdict is always deterministic. `unverified_disposition` only decides what the
|
|
36
|
+
MCP tool does with an `UNKNOWN` read (a fetch that arrived but could not be confirmed
|
|
37
|
+
as readable, for example a thin or JavaScript-rendered page):
|
|
38
|
+
|
|
39
|
+
- `stop` (default, fail-closed): return a refusal with no page content.
|
|
40
|
+
- `warn`: return a warning banner plus whatever text was extracted, clearly labelled
|
|
41
|
+
as unverified, so the agent can proceed with caution instead of being blocked.
|
|
42
|
+
|
|
43
|
+
A hard `FAILED` (transport error, 403, downgrade) always refuses regardless of this
|
|
44
|
+
setting, because there is no content to hand over.
|
|
45
|
+
|
|
46
|
+
See [fetchgate.example.toml](../fetchgate.example.toml).
|
|
47
|
+
|
|
48
|
+
## In code
|
|
49
|
+
|
|
50
|
+
```python
|
|
51
|
+
from fetchgate import load_policy, FetchGate, HttpTransport
|
|
52
|
+
|
|
53
|
+
gate = FetchGate(load_policy(), HttpTransport())
|
|
54
|
+
```
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
# FetchGate evaluation
|
|
2
|
+
|
|
3
|
+
Two numbers, two tiers.
|
|
4
|
+
|
|
5
|
+
## Headline (deterministic, offline, no model)
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
python -m fetchgate.eval.run_eval
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
Replays the 9-scenario spine through the gate and verifies it offline by
|
|
12
|
+
recomputing every verdict from the stored signals (no re-extraction). Result:
|
|
13
|
+
|
|
14
|
+
> the gate admitted the 3 true reads and refused the 6 non-reads. 0 non-reads
|
|
15
|
+
> reached answerable state.
|
|
16
|
+
|
|
17
|
+
This needs no model and is re-checkable by anyone.
|
|
18
|
+
|
|
19
|
+
## Model exhibit (the author runs this once)
|
|
20
|
+
|
|
21
|
+
```bash
|
|
22
|
+
# deterministic proof of the harness (a mock model that always answers):
|
|
23
|
+
python -m fetchgate.eval.tier_b
|
|
24
|
+
|
|
25
|
+
# the real number (needs the anthropic SDK and ANTHROPIC_API_KEY):
|
|
26
|
+
python -m fetchgate.eval.tier_b claude-<pinned-model-id>
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
Over the pages FetchGate calls non-reads, it measures how often an ungated agent
|
|
30
|
+
answers anyway, versus a gated one:
|
|
31
|
+
|
|
32
|
+
> ungated answered N of M non-reads; gated answered 0; and still answered every
|
|
33
|
+
> real read.
|
|
34
|
+
|
|
35
|
+
The gated 0 is by construction: on a non-read the gate refuses before the model
|
|
36
|
+
is ever asked. Pin the exact dated model id and temperature 0.0 so the number is
|
|
37
|
+
reproducible.
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
# FetchGate configuration. Copy to fetchgate.toml, or point $FETCHGATE_CONFIG at it,
|
|
2
|
+
# or drop it at ~/.config/fetchgate/config.toml.
|
|
3
|
+
|
|
4
|
+
# never_render | render_allowed | always_hard_fail
|
|
5
|
+
default_mode = "render_allowed"
|
|
6
|
+
|
|
7
|
+
# auto (trafilatura if installed) | stdlib | trafilatura
|
|
8
|
+
html_extractor = "auto"
|
|
9
|
+
|
|
10
|
+
# What the MCP tool does with an UNKNOWN read (thin or JS page):
|
|
11
|
+
# stop = refuse with no content (fail-closed), warn = hand over the text with a warning.
|
|
12
|
+
# A hard FAILED always refuses either way.
|
|
13
|
+
unverified_disposition = "stop" # stop | warn
|
|
14
|
+
|
|
15
|
+
max_redirects = 5
|
|
16
|
+
block_cross_origin_redirect = true
|
|
17
|
+
unsupported_type_policy = "UNKNOWN" # UNKNOWN | BYTES_OK
|
|
18
|
+
|
|
19
|
+
[domains]
|
|
20
|
+
"docs.mycompany.com" = "render_allowed"
|
|
21
|
+
"api.stripe.com" = "never_render" # structured JSON, rendering is pointless
|
|
22
|
+
"pinterest.com" = "always_hard_fail" # never even try
|
|
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "fetchgate"
|
|
7
|
-
version = "0.
|
|
7
|
+
version = "0.2.0"
|
|
8
8
|
description = "A deterministic gate at the fetch boundary: a 200 is transport success, not a read."
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
requires-python = ">=3.11"
|
|
@@ -29,6 +29,7 @@ Issues = "https://github.com/MoAz06/FetchGate/issues"
|
|
|
29
29
|
|
|
30
30
|
[project.optional-dependencies]
|
|
31
31
|
mcp = ["mcp>=1.2"] # run FetchGate as a local MCP server
|
|
32
|
+
http = ["httpx>=0.27"] # real redirect chain for the static transport
|
|
32
33
|
extract = ["trafilatura>=1.9"] # production HTML main-text extractor (seam)
|
|
33
34
|
browser = ["playwright>=1.44"] # optional headless render tier
|
|
34
35
|
sign = ["cryptography>=42"] # optional Ed25519 manifest signing
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
# Smithery config for FetchGate.
|
|
2
|
+
# Runs the server locally over stdio via uvx, which fetches fetchgate from PyPI
|
|
3
|
+
# on first use, so there is no manual pip step for the user.
|
|
4
|
+
startCommand:
|
|
5
|
+
type: stdio
|
|
6
|
+
configSchema:
|
|
7
|
+
type: object
|
|
8
|
+
properties: {}
|
|
9
|
+
additionalProperties: false
|
|
10
|
+
commandFunction: |
|
|
11
|
+
(config) => ({ command: 'uvx', args: ['--from', 'fetchgate[mcp]', 'fetchgate-mcp'] })
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
"""FetchGate: a deterministic gate at the fetch boundary. A 200 is not a read."""
|
|
2
2
|
|
|
3
3
|
from .app import default_gate
|
|
4
|
-
from .config import DomainPolicy, FetchPolicy
|
|
4
|
+
from .config import DomainPolicy, FetchPolicy, load_policy
|
|
5
5
|
from .detect import decide
|
|
6
6
|
from .fetch import build_envelope, fetch
|
|
7
7
|
from .gate import FetchGate, GateOutcome
|
|
@@ -11,10 +11,10 @@ from .types import (
|
|
|
11
11
|
)
|
|
12
12
|
from .verify import envelope_from_dict, recompute_verdict
|
|
13
13
|
|
|
14
|
-
__version__ = "0.
|
|
14
|
+
__version__ = "0.2.0"
|
|
15
15
|
|
|
16
16
|
__all__ = [
|
|
17
|
-
"default_gate", "FetchPolicy", "DomainPolicy", "decide", "fetch", "build_envelope",
|
|
17
|
+
"default_gate", "load_policy", "FetchPolicy", "DomainPolicy", "decide", "fetch", "build_envelope",
|
|
18
18
|
"FetchGate", "GateOutcome", "Transport", "MockTransport", "HttpTransport",
|
|
19
19
|
"BrowserTransport", "RawResponse", "ProvenanceEnvelope", "GateResult", "Verdict",
|
|
20
20
|
"GateDisposition", "envelope_from_dict", "recompute_verdict", "__version__",
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
"""Convenience factory: a gate from config, with the render tier if available."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from .config import load_policy
|
|
6
|
+
from .gate import FetchGate
|
|
7
|
+
from .transports import BrowserTransport, HttpTransport
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def default_gate() -> FetchGate:
|
|
11
|
+
"""A ready gate. Reads fetchgate.toml if present, renders JS pages when the
|
|
12
|
+
[browser] extra is installed (unless the config overrides default_mode)."""
|
|
13
|
+
heavy = None
|
|
14
|
+
fallback = "never_render"
|
|
15
|
+
try:
|
|
16
|
+
import playwright # noqa: F401
|
|
17
|
+
heavy = BrowserTransport()
|
|
18
|
+
fallback = "render_allowed"
|
|
19
|
+
except ImportError:
|
|
20
|
+
pass
|
|
21
|
+
return FetchGate(load_policy(default_mode_fallback=fallback), HttpTransport(), heavy)
|
|
@@ -36,6 +36,8 @@ class FetchPolicy:
|
|
|
36
36
|
block_cross_origin_redirect: bool = True
|
|
37
37
|
max_redirects: int = 5
|
|
38
38
|
unsupported_type_policy: str = "UNKNOWN"
|
|
39
|
+
html_extractor: str = "auto" # auto | stdlib | trafilatura
|
|
40
|
+
unverified_disposition: str = "stop" # stop | warn: what an unconfirmed read does at the boundary
|
|
39
41
|
domains: dict = field(default_factory=dict) # registrable-domain -> DomainPolicy
|
|
40
42
|
|
|
41
43
|
def profile_for(self, content_type: Optional[str]) -> ExtractionProfile:
|
|
@@ -72,12 +74,54 @@ class FetchPolicy:
|
|
|
72
74
|
"block_cross_origin_redirect": self.block_cross_origin_redirect,
|
|
73
75
|
"max_redirects": self.max_redirects,
|
|
74
76
|
"unsupported_type_policy": self.unsupported_type_policy,
|
|
77
|
+
"html_extractor": self.html_extractor,
|
|
78
|
+
"unverified_disposition": self.unverified_disposition,
|
|
75
79
|
"domains": {k: v.__dict__ for k, v in sorted(self.domains.items())},
|
|
76
80
|
}
|
|
77
81
|
raw = json.dumps(payload, sort_keys=True, separators=(",", ":")).encode()
|
|
78
82
|
return hashlib.sha256(raw).hexdigest()
|
|
79
83
|
|
|
80
84
|
|
|
85
|
+
def _read_config(path: Optional[str]) -> Optional[dict]:
|
|
86
|
+
import os
|
|
87
|
+
import tomllib
|
|
88
|
+
candidates = [path, os.environ.get("FETCHGATE_CONFIG"), "fetchgate.toml",
|
|
89
|
+
os.path.join(os.path.expanduser("~"), ".config", "fetchgate", "config.toml")]
|
|
90
|
+
for c in candidates:
|
|
91
|
+
if c and os.path.isfile(c):
|
|
92
|
+
with open(c, "rb") as f:
|
|
93
|
+
return tomllib.load(f)
|
|
94
|
+
return None
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def load_policy(path: Optional[str] = None, default_mode_fallback: str = "never_render") -> FetchPolicy:
|
|
98
|
+
"""Build a FetchPolicy from a fetchgate.toml, or defaults if none is found.
|
|
99
|
+
Looked up in: path arg, $FETCHGATE_CONFIG, ./fetchgate.toml, ~/.config/fetchgate/config.toml."""
|
|
100
|
+
data = _read_config(path)
|
|
101
|
+
if data is None:
|
|
102
|
+
return FetchPolicy(default_mode=default_mode_fallback)
|
|
103
|
+
raw_domains = data.get("domains") or {}
|
|
104
|
+
domains = {
|
|
105
|
+
k: DomainPolicy(mode=(v if isinstance(v, str) else v.get("mode", "never_render")))
|
|
106
|
+
for k, v in raw_domains.items()
|
|
107
|
+
}
|
|
108
|
+
return FetchPolicy(
|
|
109
|
+
policy_id=data.get("policy_id", "config"),
|
|
110
|
+
default_mode=data.get("default_mode", default_mode_fallback),
|
|
111
|
+
block_cross_origin_redirect=bool(data.get("block_cross_origin_redirect", True)),
|
|
112
|
+
max_redirects=int(data.get("max_redirects", 5)),
|
|
113
|
+
unsupported_type_policy=data.get("unsupported_type_policy", "UNKNOWN"),
|
|
114
|
+
html_extractor=data.get("html_extractor", "auto"),
|
|
115
|
+
unverified_disposition=_disposition(data.get("unverified_disposition", "stop")),
|
|
116
|
+
domains=domains,
|
|
117
|
+
)
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def _disposition(value: object) -> str:
|
|
121
|
+
v = str(value).strip().lower()
|
|
122
|
+
return v if v in ("stop", "warn") else "stop"
|
|
123
|
+
|
|
124
|
+
|
|
81
125
|
def _host_of(url: str) -> str:
|
|
82
126
|
from urllib.parse import urlparse
|
|
83
127
|
return (urlparse(url).hostname or "").lower()
|