structx-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.
- structx_mcp-0.1.0/.gitignore +12 -0
- structx_mcp-0.1.0/CHANGELOG.md +39 -0
- structx_mcp-0.1.0/LICENSE +21 -0
- structx_mcp-0.1.0/PKG-INFO +140 -0
- structx_mcp-0.1.0/README.md +112 -0
- structx_mcp-0.1.0/pyproject.toml +72 -0
- structx_mcp-0.1.0/src/structx_mcp/__init__.py +24 -0
- structx_mcp-0.1.0/src/structx_mcp/__main__.py +18 -0
- structx_mcp-0.1.0/src/structx_mcp/_tools.py +228 -0
- structx_mcp-0.1.0/src/structx_mcp/_url_safety.py +127 -0
- structx_mcp-0.1.0/src/structx_mcp/_version.py +1 -0
- structx_mcp-0.1.0/src/structx_mcp/server.py +214 -0
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
All notable changes to the `structx-mcp` package are documented here.
|
|
4
|
+
This project follows [Semantic Versioning](https://semver.org/).
|
|
5
|
+
|
|
6
|
+
## [Unreleased]
|
|
7
|
+
|
|
8
|
+
### Changed
|
|
9
|
+
- Internal: bumped the `structx` dependency to `structx-sdk>=0.2.0` (SDK package rename). No customer-facing change — the MCP package name, CLI command, and configuration are unchanged.
|
|
10
|
+
|
|
11
|
+
## [0.1.0] — 2026-05-24
|
|
12
|
+
|
|
13
|
+
Initial release. Replaces the in-tree `backend/mcp/server.py` (which
|
|
14
|
+
used raw httpx) with a standalone PyPI package backed by the official
|
|
15
|
+
`structx` Python SDK.
|
|
16
|
+
|
|
17
|
+
### Added
|
|
18
|
+
- 5 MCP tools designed for agent-first use:
|
|
19
|
+
- `structx_extract` — extract using inline schema OR `template_slug`.
|
|
20
|
+
- `structx_infer_schema` — generate a schema from sample content.
|
|
21
|
+
- `structx_extract_from_url` — fetch + extract in one call, SSRF-defended.
|
|
22
|
+
- `structx_list_templates` — browse the public template catalog.
|
|
23
|
+
- `structx_usage` — current credit usage.
|
|
24
|
+
- `structx-mcp` console-script entrypoint for Claude Desktop / Cursor / Windsurf configs.
|
|
25
|
+
- SSRF defense for URL fetching (HTTPS only, IP classification, hostname blocklist, no redirects, 10MB cap).
|
|
26
|
+
- Typed-error pass-through — SDK errors surface to the agent as JSON `{"error", "type"}` rather than vanishing into transport.
|
|
27
|
+
|
|
28
|
+
### Compatible with
|
|
29
|
+
- Python 3.10+.
|
|
30
|
+
- `structx>=0.1.0,<1.0.0` (the Python SDK).
|
|
31
|
+
- MCP protocol via the `mcp>=1.0.0` reference implementation.
|
|
32
|
+
- struct-x API v1.x.
|
|
33
|
+
|
|
34
|
+
### Migration note (from the in-tree server)
|
|
35
|
+
The in-tree `backend/mcp/server.py` had hardcoded convenience tools
|
|
36
|
+
(`structx_extract_product`, `_article`, `_contact`). Those are dropped
|
|
37
|
+
in favor of `structx_extract(template_slug="ecommerce.product")` etc.
|
|
38
|
+
— new templates ship without code changes, and the tool catalog stays
|
|
39
|
+
lean (agents handle small tool surfaces better).
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 struct-x
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: structx-mcp
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Model Context Protocol server for struct-x — install in Claude Desktop, Cursor, Windsurf, or any MCP-compatible client to give your agent structured-extraction superpowers.
|
|
5
|
+
Project-URL: Homepage, https://structx.ai
|
|
6
|
+
Project-URL: Documentation, https://docs.structx.ai/mcp
|
|
7
|
+
Project-URL: Repository, https://github.com/struct-x-ai/struct-x
|
|
8
|
+
Project-URL: Issues, https://github.com/struct-x-ai/struct-x/issues
|
|
9
|
+
Author-email: struct-x <support@structx.ai>
|
|
10
|
+
License: MIT
|
|
11
|
+
License-File: LICENSE
|
|
12
|
+
Keywords: agent,claude,cursor,llm,mcp,model-context-protocol,structured-extraction,structx
|
|
13
|
+
Classifier: Development Status :: 4 - Beta
|
|
14
|
+
Classifier: Intended Audience :: Developers
|
|
15
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
16
|
+
Classifier: Programming Language :: Python
|
|
17
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
18
|
+
Classifier: Topic :: Software Development :: Libraries
|
|
19
|
+
Requires-Python: >=3.10
|
|
20
|
+
Requires-Dist: httpx<0.29,>=0.27
|
|
21
|
+
Requires-Dist: mcp>=1.0.0
|
|
22
|
+
Requires-Dist: structx-sdk<1.0.0,>=0.2.0
|
|
23
|
+
Provides-Extra: dev
|
|
24
|
+
Requires-Dist: pytest-asyncio>=0.23; extra == 'dev'
|
|
25
|
+
Requires-Dist: pytest>=8.0; extra == 'dev'
|
|
26
|
+
Requires-Dist: respx>=0.21; extra == 'dev'
|
|
27
|
+
Description-Content-Type: text/markdown
|
|
28
|
+
|
|
29
|
+
# structx-mcp
|
|
30
|
+
|
|
31
|
+
[](https://pypi.org/project/structx-mcp/)
|
|
32
|
+
[](LICENSE)
|
|
33
|
+
|
|
34
|
+
Model Context Protocol (MCP) server for **[struct-x](https://structx.ai)** — install in Claude Desktop, Cursor, Windsurf, or any MCP-compatible client and give your agent first-class structured-extraction tools.
|
|
35
|
+
|
|
36
|
+
```
|
|
37
|
+
You: extract the product details from this Aeron chair page →
|
|
38
|
+
[pastes URL or HTML]
|
|
39
|
+
Agent: [calls structx_extract under the hood, returns typed JSON]
|
|
40
|
+
{
|
|
41
|
+
"title": "Aeron Chair",
|
|
42
|
+
"price_cents": 179500,
|
|
43
|
+
"brand": "Herman Miller",
|
|
44
|
+
…
|
|
45
|
+
}
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
No HTTP plumbing, no schema-writing detours — the agent just *uses* extraction.
|
|
49
|
+
|
|
50
|
+
## Install
|
|
51
|
+
|
|
52
|
+
```bash
|
|
53
|
+
pip install structx-mcp
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
Then add to your MCP client's config.
|
|
57
|
+
|
|
58
|
+
### Claude Desktop
|
|
59
|
+
|
|
60
|
+
Edit `~/Library/Application Support/Claude/claude_desktop_config.json` (macOS) or `%APPDATA%\Claude\claude_desktop_config.json` (Windows):
|
|
61
|
+
|
|
62
|
+
```json
|
|
63
|
+
{
|
|
64
|
+
"mcpServers": {
|
|
65
|
+
"structx": {
|
|
66
|
+
"command": "structx-mcp",
|
|
67
|
+
"env": {
|
|
68
|
+
"STRUCTX_API_KEY": "sx_..."
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
Restart Claude Desktop. Confirm by typing `/mcp` and verifying `structx` is listed.
|
|
76
|
+
|
|
77
|
+
### Cursor
|
|
78
|
+
|
|
79
|
+
`.cursor/mcp.json` (project-scope) or `~/.cursor/mcp.json` (user-scope):
|
|
80
|
+
|
|
81
|
+
```json
|
|
82
|
+
{
|
|
83
|
+
"mcpServers": {
|
|
84
|
+
"structx": {
|
|
85
|
+
"command": "structx-mcp",
|
|
86
|
+
"env": { "STRUCTX_API_KEY": "sx_..." }
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
### Windsurf / any other MCP client
|
|
93
|
+
|
|
94
|
+
Same pattern — `command: "structx-mcp"`, `env: { STRUCTX_API_KEY: ... }`. The server speaks the standard MCP stdio protocol.
|
|
95
|
+
|
|
96
|
+
## Tools the agent gets
|
|
97
|
+
|
|
98
|
+
| Tool | What it does |
|
|
99
|
+
|---|---|
|
|
100
|
+
| **`structx_extract`** | Extract structured JSON from raw content (HTML / markdown / text / JSON / emails) using a JSON Schema you provide OR a pre-built template slug. Returns per-field confidence scores. |
|
|
101
|
+
| **`structx_infer_schema`** | Don't have a schema yet? Paste raw content, get a draft schema back with per-field rationales. Plus matching templates from the public catalog. |
|
|
102
|
+
| **`structx_extract_from_url`** | Fetch a URL and extract in one call. Sandboxed (HTTPS only, public DNS only, 10MB cap, no redirects). Replaces the two-call `fetch_url → extract` pattern. |
|
|
103
|
+
| **`structx_list_templates`** | Browse every public template — saves the agent from hand-writing a schema for common formats (Stripe events, GitHub issues, product pages, news articles). |
|
|
104
|
+
| **`structx_usage`** | Current credit usage for the API key. Pre-flight check before batches, or post-mortem after a `RateLimitError`. |
|
|
105
|
+
|
|
106
|
+
## Why use an MCP server vs. the SDK directly?
|
|
107
|
+
|
|
108
|
+
The Python SDK ([`structx-sdk`](https://pypi.org/project/structx-sdk/)) is for building **applications**. This MCP package is for putting struct-x **inside the AI tools you already use** — Claude Desktop, Cursor, Windsurf. The agent calls these tools natively, no app code required.
|
|
109
|
+
|
|
110
|
+
Both are first-party; they share the same backend, same credit pool, same API key.
|
|
111
|
+
|
|
112
|
+
## Security
|
|
113
|
+
|
|
114
|
+
`structx_extract_from_url` is sandboxed at the network layer:
|
|
115
|
+
|
|
116
|
+
- HTTPS only (no `http://`, `file://`, `ftp://`, etc.)
|
|
117
|
+
- Hostname blocklist for cloud metadata targets (`169.254.169.254`, `metadata.google.internal`, etc.)
|
|
118
|
+
- Hostname suffix blocklist for internal-network conventions (`.internal`, `.local`, `.cluster.local`, `.consul`)
|
|
119
|
+
- DNS resolution + IP classification — refuses any private, loopback, link-local, multicast, or reserved IP
|
|
120
|
+
- No redirect following (one less SSRF vector)
|
|
121
|
+
- 10MB response cap
|
|
122
|
+
|
|
123
|
+
This matches the defenses on the struct-x backend's webhook outbound-URL validator. See [`src/structx_mcp/_url_safety.py`](src/structx_mcp/_url_safety.py) for the implementation.
|
|
124
|
+
|
|
125
|
+
## Development
|
|
126
|
+
|
|
127
|
+
```bash
|
|
128
|
+
git clone https://github.com/struct-x-ai/struct-x
|
|
129
|
+
cd struct-x/mcp/structx-mcp
|
|
130
|
+
|
|
131
|
+
# Install editable + the SDK from the local checkout (the SDK is in
|
|
132
|
+
# the same monorepo at sdk/python/).
|
|
133
|
+
pip install -e "../../sdk/python" -e ".[dev]"
|
|
134
|
+
|
|
135
|
+
pytest -q
|
|
136
|
+
```
|
|
137
|
+
|
|
138
|
+
## License
|
|
139
|
+
|
|
140
|
+
MIT — see [LICENSE](LICENSE).
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
# structx-mcp
|
|
2
|
+
|
|
3
|
+
[](https://pypi.org/project/structx-mcp/)
|
|
4
|
+
[](LICENSE)
|
|
5
|
+
|
|
6
|
+
Model Context Protocol (MCP) server for **[struct-x](https://structx.ai)** — install in Claude Desktop, Cursor, Windsurf, or any MCP-compatible client and give your agent first-class structured-extraction tools.
|
|
7
|
+
|
|
8
|
+
```
|
|
9
|
+
You: extract the product details from this Aeron chair page →
|
|
10
|
+
[pastes URL or HTML]
|
|
11
|
+
Agent: [calls structx_extract under the hood, returns typed JSON]
|
|
12
|
+
{
|
|
13
|
+
"title": "Aeron Chair",
|
|
14
|
+
"price_cents": 179500,
|
|
15
|
+
"brand": "Herman Miller",
|
|
16
|
+
…
|
|
17
|
+
}
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
No HTTP plumbing, no schema-writing detours — the agent just *uses* extraction.
|
|
21
|
+
|
|
22
|
+
## Install
|
|
23
|
+
|
|
24
|
+
```bash
|
|
25
|
+
pip install structx-mcp
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
Then add to your MCP client's config.
|
|
29
|
+
|
|
30
|
+
### Claude Desktop
|
|
31
|
+
|
|
32
|
+
Edit `~/Library/Application Support/Claude/claude_desktop_config.json` (macOS) or `%APPDATA%\Claude\claude_desktop_config.json` (Windows):
|
|
33
|
+
|
|
34
|
+
```json
|
|
35
|
+
{
|
|
36
|
+
"mcpServers": {
|
|
37
|
+
"structx": {
|
|
38
|
+
"command": "structx-mcp",
|
|
39
|
+
"env": {
|
|
40
|
+
"STRUCTX_API_KEY": "sx_..."
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
Restart Claude Desktop. Confirm by typing `/mcp` and verifying `structx` is listed.
|
|
48
|
+
|
|
49
|
+
### Cursor
|
|
50
|
+
|
|
51
|
+
`.cursor/mcp.json` (project-scope) or `~/.cursor/mcp.json` (user-scope):
|
|
52
|
+
|
|
53
|
+
```json
|
|
54
|
+
{
|
|
55
|
+
"mcpServers": {
|
|
56
|
+
"structx": {
|
|
57
|
+
"command": "structx-mcp",
|
|
58
|
+
"env": { "STRUCTX_API_KEY": "sx_..." }
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
### Windsurf / any other MCP client
|
|
65
|
+
|
|
66
|
+
Same pattern — `command: "structx-mcp"`, `env: { STRUCTX_API_KEY: ... }`. The server speaks the standard MCP stdio protocol.
|
|
67
|
+
|
|
68
|
+
## Tools the agent gets
|
|
69
|
+
|
|
70
|
+
| Tool | What it does |
|
|
71
|
+
|---|---|
|
|
72
|
+
| **`structx_extract`** | Extract structured JSON from raw content (HTML / markdown / text / JSON / emails) using a JSON Schema you provide OR a pre-built template slug. Returns per-field confidence scores. |
|
|
73
|
+
| **`structx_infer_schema`** | Don't have a schema yet? Paste raw content, get a draft schema back with per-field rationales. Plus matching templates from the public catalog. |
|
|
74
|
+
| **`structx_extract_from_url`** | Fetch a URL and extract in one call. Sandboxed (HTTPS only, public DNS only, 10MB cap, no redirects). Replaces the two-call `fetch_url → extract` pattern. |
|
|
75
|
+
| **`structx_list_templates`** | Browse every public template — saves the agent from hand-writing a schema for common formats (Stripe events, GitHub issues, product pages, news articles). |
|
|
76
|
+
| **`structx_usage`** | Current credit usage for the API key. Pre-flight check before batches, or post-mortem after a `RateLimitError`. |
|
|
77
|
+
|
|
78
|
+
## Why use an MCP server vs. the SDK directly?
|
|
79
|
+
|
|
80
|
+
The Python SDK ([`structx-sdk`](https://pypi.org/project/structx-sdk/)) is for building **applications**. This MCP package is for putting struct-x **inside the AI tools you already use** — Claude Desktop, Cursor, Windsurf. The agent calls these tools natively, no app code required.
|
|
81
|
+
|
|
82
|
+
Both are first-party; they share the same backend, same credit pool, same API key.
|
|
83
|
+
|
|
84
|
+
## Security
|
|
85
|
+
|
|
86
|
+
`structx_extract_from_url` is sandboxed at the network layer:
|
|
87
|
+
|
|
88
|
+
- HTTPS only (no `http://`, `file://`, `ftp://`, etc.)
|
|
89
|
+
- Hostname blocklist for cloud metadata targets (`169.254.169.254`, `metadata.google.internal`, etc.)
|
|
90
|
+
- Hostname suffix blocklist for internal-network conventions (`.internal`, `.local`, `.cluster.local`, `.consul`)
|
|
91
|
+
- DNS resolution + IP classification — refuses any private, loopback, link-local, multicast, or reserved IP
|
|
92
|
+
- No redirect following (one less SSRF vector)
|
|
93
|
+
- 10MB response cap
|
|
94
|
+
|
|
95
|
+
This matches the defenses on the struct-x backend's webhook outbound-URL validator. See [`src/structx_mcp/_url_safety.py`](src/structx_mcp/_url_safety.py) for the implementation.
|
|
96
|
+
|
|
97
|
+
## Development
|
|
98
|
+
|
|
99
|
+
```bash
|
|
100
|
+
git clone https://github.com/struct-x-ai/struct-x
|
|
101
|
+
cd struct-x/mcp/structx-mcp
|
|
102
|
+
|
|
103
|
+
# Install editable + the SDK from the local checkout (the SDK is in
|
|
104
|
+
# the same monorepo at sdk/python/).
|
|
105
|
+
pip install -e "../../sdk/python" -e ".[dev]"
|
|
106
|
+
|
|
107
|
+
pytest -q
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
## License
|
|
111
|
+
|
|
112
|
+
MIT — see [LICENSE](LICENSE).
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling>=1.18"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "structx-mcp"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Model Context Protocol server for struct-x — install in Claude Desktop, Cursor, Windsurf, or any MCP-compatible client to give your agent structured-extraction superpowers."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.10"
|
|
11
|
+
license = { text = "MIT" }
|
|
12
|
+
authors = [
|
|
13
|
+
{ name = "struct-x", email = "support@structx.ai" },
|
|
14
|
+
]
|
|
15
|
+
keywords = [
|
|
16
|
+
"mcp",
|
|
17
|
+
"model-context-protocol",
|
|
18
|
+
"structured-extraction",
|
|
19
|
+
"llm",
|
|
20
|
+
"agent",
|
|
21
|
+
"claude",
|
|
22
|
+
"cursor",
|
|
23
|
+
"structx",
|
|
24
|
+
]
|
|
25
|
+
classifiers = [
|
|
26
|
+
"Development Status :: 4 - Beta",
|
|
27
|
+
"Intended Audience :: Developers",
|
|
28
|
+
"License :: OSI Approved :: MIT License",
|
|
29
|
+
"Programming Language :: Python",
|
|
30
|
+
"Programming Language :: Python :: 3 :: Only",
|
|
31
|
+
"Topic :: Software Development :: Libraries",
|
|
32
|
+
]
|
|
33
|
+
dependencies = [
|
|
34
|
+
"structx-sdk>=0.2.0,<1.0.0",
|
|
35
|
+
"mcp>=1.0.0",
|
|
36
|
+
"httpx>=0.27,<0.29",
|
|
37
|
+
]
|
|
38
|
+
|
|
39
|
+
[project.optional-dependencies]
|
|
40
|
+
dev = [
|
|
41
|
+
"pytest>=8.0",
|
|
42
|
+
"pytest-asyncio>=0.23",
|
|
43
|
+
"respx>=0.21",
|
|
44
|
+
]
|
|
45
|
+
|
|
46
|
+
[project.scripts]
|
|
47
|
+
# `structx-mcp` becomes a CLI command on install. Claude Desktop /
|
|
48
|
+
# Cursor / Windsurf can reference it directly in their MCP config:
|
|
49
|
+
# { "mcpServers": { "structx": { "command": "structx-mcp", ... } } }
|
|
50
|
+
structx-mcp = "structx_mcp.__main__:main"
|
|
51
|
+
|
|
52
|
+
[project.urls]
|
|
53
|
+
Homepage = "https://structx.ai"
|
|
54
|
+
Documentation = "https://docs.structx.ai/mcp"
|
|
55
|
+
Repository = "https://github.com/struct-x-ai/struct-x"
|
|
56
|
+
Issues = "https://github.com/struct-x-ai/struct-x/issues"
|
|
57
|
+
|
|
58
|
+
[tool.hatch.build.targets.wheel]
|
|
59
|
+
packages = ["src/structx_mcp"]
|
|
60
|
+
|
|
61
|
+
[tool.hatch.build.targets.sdist]
|
|
62
|
+
include = [
|
|
63
|
+
"src/structx_mcp",
|
|
64
|
+
"README.md",
|
|
65
|
+
"CHANGELOG.md",
|
|
66
|
+
"LICENSE",
|
|
67
|
+
"pyproject.toml",
|
|
68
|
+
]
|
|
69
|
+
|
|
70
|
+
[tool.pytest.ini_options]
|
|
71
|
+
testpaths = ["tests"]
|
|
72
|
+
asyncio_mode = "auto"
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
"""structx-mcp — Model Context Protocol server for struct-x.
|
|
2
|
+
|
|
3
|
+
Install in Claude Desktop / Cursor / Windsurf or any MCP-compatible
|
|
4
|
+
client and give your agent first-class structured-extraction tools.
|
|
5
|
+
|
|
6
|
+
Quickstart (Claude Desktop):
|
|
7
|
+
|
|
8
|
+
1. pip install structx-mcp
|
|
9
|
+
2. Add to ~/Library/Application Support/Claude/claude_desktop_config.json:
|
|
10
|
+
|
|
11
|
+
{
|
|
12
|
+
"mcpServers": {
|
|
13
|
+
"structx": {
|
|
14
|
+
"command": "structx-mcp",
|
|
15
|
+
"env": { "STRUCTX_API_KEY": "sx_..." }
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
3. Restart Claude Desktop.
|
|
21
|
+
"""
|
|
22
|
+
from ._version import __version__
|
|
23
|
+
|
|
24
|
+
__all__ = ["__version__"]
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
"""Entry point invoked by the `structx-mcp` console script (defined in
|
|
2
|
+
pyproject.toml's [project.scripts]). Also runnable as `python -m
|
|
3
|
+
structx_mcp`.
|
|
4
|
+
"""
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
import asyncio
|
|
8
|
+
|
|
9
|
+
from .server import _check_api_key, serve
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def main() -> None:
|
|
13
|
+
_check_api_key()
|
|
14
|
+
asyncio.run(serve())
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
if __name__ == "__main__":
|
|
18
|
+
main()
|
|
@@ -0,0 +1,228 @@
|
|
|
1
|
+
"""Tool definitions for the structx MCP server.
|
|
2
|
+
|
|
3
|
+
Tool DESCRIPTIONS are the public API for any MCP — an LLM agent reads
|
|
4
|
+
them to decide whether/when to invoke a tool. They need to be:
|
|
5
|
+
|
|
6
|
+
- SPECIFIC about what kind of input the tool eats (HTML? URL? text?)
|
|
7
|
+
- CLEAR about the output shape (typed JSON? confidence scores?)
|
|
8
|
+
- HONEST about edge cases the agent should know (5KB hard cap, etc.)
|
|
9
|
+
- AGENT-FIRST in their phrasing — "use this for X, Y, Z" beats
|
|
10
|
+
"this function does X"
|
|
11
|
+
|
|
12
|
+
Kept separate from the server runtime so the descriptions are easy
|
|
13
|
+
to review independently of the wiring code.
|
|
14
|
+
"""
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
from mcp.types import Tool
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
# ── Tool 1 — the workhorse ──────────────────────────────────
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
EXTRACT_TOOL = Tool(
|
|
24
|
+
name="structx_extract",
|
|
25
|
+
description=(
|
|
26
|
+
"Extract structured JSON from raw content (HTML, markdown, plain text, "
|
|
27
|
+
"JSON-API responses, emails, scraped web pages, documents) using a JSON "
|
|
28
|
+
"Schema you provide OR a pre-built template slug. Returns typed data with "
|
|
29
|
+
"per-field confidence scores so you know which fields to trust. "
|
|
30
|
+
"Use this whenever you need to turn unstructured/semi-structured text into "
|
|
31
|
+
"reliable JSON. Prefer the `template_slug` argument for common formats "
|
|
32
|
+
"(Stripe events, GitHub issues, product pages, news articles) — see "
|
|
33
|
+
"`structx_list_templates` to discover available ones. "
|
|
34
|
+
"Content cap: 500KB. Calls bill against your API key's credit quota."
|
|
35
|
+
),
|
|
36
|
+
inputSchema={
|
|
37
|
+
"type": "object",
|
|
38
|
+
"properties": {
|
|
39
|
+
"content": {
|
|
40
|
+
"type": "string",
|
|
41
|
+
"description": "Raw text/HTML/markdown to extract from. 500KB max.",
|
|
42
|
+
},
|
|
43
|
+
"schema": {
|
|
44
|
+
"type": "object",
|
|
45
|
+
"description": (
|
|
46
|
+
"Inline JSON Schema describing the desired output shape. "
|
|
47
|
+
"Use this OR `template_slug`, not both. Required: `type` "
|
|
48
|
+
"field at root; `type` must be 'object' or 'array'."
|
|
49
|
+
),
|
|
50
|
+
},
|
|
51
|
+
"template_slug": {
|
|
52
|
+
"type": "string",
|
|
53
|
+
"description": (
|
|
54
|
+
"Catalog template identifier. Bare slug (e.g. 'logs.stripe.event') "
|
|
55
|
+
"uses the latest published version; pin to a specific version with "
|
|
56
|
+
"'family.name@1.0.0'. Mutually exclusive with `schema`."
|
|
57
|
+
),
|
|
58
|
+
},
|
|
59
|
+
"tier": {
|
|
60
|
+
"type": "string",
|
|
61
|
+
"enum": ["required", "recommended", "optional", "extended"],
|
|
62
|
+
"default": "required",
|
|
63
|
+
"description": (
|
|
64
|
+
"Field-depth dial when using a template. 'required' returns only "
|
|
65
|
+
"the headline fields (cheapest). 'extended' returns every field "
|
|
66
|
+
"in the template. Ignored for inline-schema extractions."
|
|
67
|
+
),
|
|
68
|
+
},
|
|
69
|
+
"options": {
|
|
70
|
+
"type": "object",
|
|
71
|
+
"description": (
|
|
72
|
+
"Optional extraction overrides. Common keys: `include_citations` "
|
|
73
|
+
"(boolean) to add source snippets per field; `use_cache` (boolean, "
|
|
74
|
+
"default true) to allow cached responses; `confidence_threshold` "
|
|
75
|
+
"(float 0-1) to flag low-confidence results."
|
|
76
|
+
),
|
|
77
|
+
},
|
|
78
|
+
},
|
|
79
|
+
"required": ["content"],
|
|
80
|
+
},
|
|
81
|
+
)
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
# ── Tool 2 — the magic moment ──────────────────────────────
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
INFER_SCHEMA_TOOL = Tool(
|
|
88
|
+
name="structx_infer_schema",
|
|
89
|
+
description=(
|
|
90
|
+
"Don't have a schema yet? Paste raw content and get a draft JSON Schema "
|
|
91
|
+
"back, with per-field rationales explaining why each field was inferred. "
|
|
92
|
+
"Optionally returns matching templates from the public catalog. "
|
|
93
|
+
"Use this when starting fresh with unfamiliar content (a new SaaS webhook, "
|
|
94
|
+
"an unknown API response, an unstructured document type) — saves the agent "
|
|
95
|
+
"from hand-writing a schema. The returned schema can be passed directly "
|
|
96
|
+
"back to `structx_extract`. Cost: 3-5 credits per call (higher than "
|
|
97
|
+
"extraction because of the meta-reasoning involved)."
|
|
98
|
+
),
|
|
99
|
+
inputSchema={
|
|
100
|
+
"type": "object",
|
|
101
|
+
"properties": {
|
|
102
|
+
"content": {
|
|
103
|
+
"type": "string",
|
|
104
|
+
"description": (
|
|
105
|
+
"Sample content the schema should describe. 200 chars min, "
|
|
106
|
+
"100KB max. One representative sample is plenty — don't paste "
|
|
107
|
+
"a giant corpus."
|
|
108
|
+
),
|
|
109
|
+
},
|
|
110
|
+
"content_type": {
|
|
111
|
+
"type": "string",
|
|
112
|
+
"enum": ["html", "json", "markdown", "text"],
|
|
113
|
+
"description": (
|
|
114
|
+
"Hint about the format. Optional — the backend can guess from "
|
|
115
|
+
"content, but a hint improves inference quality."
|
|
116
|
+
),
|
|
117
|
+
},
|
|
118
|
+
"hints": {
|
|
119
|
+
"type": "object",
|
|
120
|
+
"description": (
|
|
121
|
+
"Optional constraints. Keys: `vertical` (e.g. 'ecommerce', "
|
|
122
|
+
"'finance', 'logs'), `fields_must_include` (array of field names "
|
|
123
|
+
"that MUST appear in the inferred schema)."
|
|
124
|
+
),
|
|
125
|
+
},
|
|
126
|
+
"return_recommendations": {
|
|
127
|
+
"type": "boolean",
|
|
128
|
+
"default": True,
|
|
129
|
+
"description": (
|
|
130
|
+
"If true, also returns matching templates from the public catalog "
|
|
131
|
+
"ranked by similarity. Useful for the agent to decide between "
|
|
132
|
+
"'use this template' and 'use my freshly-inferred schema'."
|
|
133
|
+
),
|
|
134
|
+
},
|
|
135
|
+
},
|
|
136
|
+
"required": ["content"],
|
|
137
|
+
},
|
|
138
|
+
)
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
# ── Tool 3 — the integration smoothie ──────────────────────
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
EXTRACT_FROM_URL_TOOL = Tool(
|
|
145
|
+
name="structx_extract_from_url",
|
|
146
|
+
description=(
|
|
147
|
+
"Fetch a URL and extract structured JSON from it in one call. Removes "
|
|
148
|
+
"the two-call pattern (fetch_url → structx_extract) that every other agent "
|
|
149
|
+
"workflow ends up writing. Honors the same `schema` / `template_slug` / "
|
|
150
|
+
"`tier` / `options` arguments as `structx_extract`. "
|
|
151
|
+
"URL fetching is sandboxed: HTTPS only, public DNS only (no metadata "
|
|
152
|
+
"endpoints, no link-local, no private ranges, no DNS rebinds), 10MB "
|
|
153
|
+
"response cap, no redirects followed. Use for live-page extraction "
|
|
154
|
+
"(product pages, news articles, RSS items, public docs)."
|
|
155
|
+
),
|
|
156
|
+
inputSchema={
|
|
157
|
+
"type": "object",
|
|
158
|
+
"properties": {
|
|
159
|
+
"url": {
|
|
160
|
+
"type": "string",
|
|
161
|
+
"description": "Public HTTPS URL to fetch. http://, file://, ftp://, etc. rejected.",
|
|
162
|
+
},
|
|
163
|
+
"schema": {
|
|
164
|
+
"type": "object",
|
|
165
|
+
"description": "Inline JSON Schema. Mutually exclusive with `template_slug`.",
|
|
166
|
+
},
|
|
167
|
+
"template_slug": {
|
|
168
|
+
"type": "string",
|
|
169
|
+
"description": "Catalog template identifier. Mutually exclusive with `schema`.",
|
|
170
|
+
},
|
|
171
|
+
"tier": {
|
|
172
|
+
"type": "string",
|
|
173
|
+
"enum": ["required", "recommended", "optional", "extended"],
|
|
174
|
+
"default": "required",
|
|
175
|
+
},
|
|
176
|
+
"options": {
|
|
177
|
+
"type": "object",
|
|
178
|
+
"description": "Forwarded to /v1/extract — see `structx_extract`.",
|
|
179
|
+
},
|
|
180
|
+
},
|
|
181
|
+
"required": ["url"],
|
|
182
|
+
},
|
|
183
|
+
)
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
# ── Tool 4 — discovery ──────────────────────────────────────
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
LIST_TEMPLATES_TOOL = Tool(
|
|
190
|
+
name="structx_list_templates",
|
|
191
|
+
description=(
|
|
192
|
+
"List every public template in the struct-x catalog. Each template has a "
|
|
193
|
+
"slug (e.g. 'logs.stripe.event'), a name, a description, an optional "
|
|
194
|
+
"category, and the underlying JSON Schema. Use this to discover what "
|
|
195
|
+
"schemas already exist BEFORE writing your own — chances are someone "
|
|
196
|
+
"(possibly the platform team, possibly another user via promotion) has "
|
|
197
|
+
"already published a template for the format you care about. "
|
|
198
|
+
"Pair with `structx_extract(template_slug=...)` to skip schema-writing entirely."
|
|
199
|
+
),
|
|
200
|
+
inputSchema={"type": "object", "properties": {}},
|
|
201
|
+
)
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
# ── Tool 5 — observability ──────────────────────────────────
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
USAGE_TOOL = Tool(
|
|
208
|
+
name="structx_usage",
|
|
209
|
+
description=(
|
|
210
|
+
"Return the current credit usage for the API key in use — credits used "
|
|
211
|
+
"today, daily limit, current tier. Useful as a pre-flight check before a "
|
|
212
|
+
"batch operation, OR after a `RateLimitError` to confirm the cap is "
|
|
213
|
+
"actually exhausted vs. a transient throttle."
|
|
214
|
+
),
|
|
215
|
+
inputSchema={"type": "object", "properties": {}},
|
|
216
|
+
)
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
# Ordered list for `list_tools()` — sort by likely usage frequency
|
|
220
|
+
# (extract > infer > list > url > usage). Agents tend to surface the
|
|
221
|
+
# first few tools more readily; put the workhorses up top.
|
|
222
|
+
ALL_TOOLS: list[Tool] = [
|
|
223
|
+
EXTRACT_TOOL,
|
|
224
|
+
INFER_SCHEMA_TOOL,
|
|
225
|
+
LIST_TEMPLATES_TOOL,
|
|
226
|
+
EXTRACT_FROM_URL_TOOL,
|
|
227
|
+
USAGE_TOOL,
|
|
228
|
+
]
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
"""SSRF defense for `structx_extract_from_url`.
|
|
2
|
+
|
|
3
|
+
Agents can be tricked into fetching internal-network URLs via prompt
|
|
4
|
+
injection — "please summarize http://169.254.169.254/latest/meta-data/"
|
|
5
|
+
extracts AWS instance metadata. This module validates URLs before
|
|
6
|
+
`structx_extract_from_url` actually fetches anything.
|
|
7
|
+
|
|
8
|
+
Pattern mirrored from backend/services/webhooks.py (PR #173). Kept
|
|
9
|
+
inline here so the MCP package doesn't need to import backend code.
|
|
10
|
+
|
|
11
|
+
Defense layers:
|
|
12
|
+
1. Scheme allowlist (https only)
|
|
13
|
+
2. Hostname blocklist (cloud-metadata + .internal/.local)
|
|
14
|
+
3. DNS resolution → IP classification (rejects private, loopback,
|
|
15
|
+
link-local, multicast, reserved, ipv6-link-local)
|
|
16
|
+
4. Response size cap (10MB) — enforced at the caller, but documented
|
|
17
|
+
here as part of the threat model.
|
|
18
|
+
|
|
19
|
+
Not in this module's scope:
|
|
20
|
+
- Redirect following (caller pins follow_redirects=False)
|
|
21
|
+
- Content-Type checks (caller's responsibility)
|
|
22
|
+
- Authentication header stripping (no auth headers sent in the first
|
|
23
|
+
place — caller uses a vanilla httpx call with no creds)
|
|
24
|
+
"""
|
|
25
|
+
from __future__ import annotations
|
|
26
|
+
|
|
27
|
+
import ipaddress
|
|
28
|
+
import socket
|
|
29
|
+
from urllib.parse import urlparse
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class UrlSafetyError(ValueError):
|
|
33
|
+
"""Raised when a URL fails any SSRF check."""
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
# Hostnames that resolve to internal services on common cloud providers.
|
|
37
|
+
# These should NEVER be reachable from a public-facing fetch.
|
|
38
|
+
_BLOCKED_HOSTNAMES = frozenset(
|
|
39
|
+
{
|
|
40
|
+
"metadata.google.internal",
|
|
41
|
+
"metadata",
|
|
42
|
+
"instance-data",
|
|
43
|
+
"instance-data.ec2.internal",
|
|
44
|
+
"169.254.169.254", # AWS/Azure/GCP metadata IP
|
|
45
|
+
"100.100.100.200", # Alibaba Cloud metadata IP
|
|
46
|
+
"fd00:ec2::254", # AWS IPv6 metadata
|
|
47
|
+
}
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
_BLOCKED_HOSTNAME_SUFFIXES = (
|
|
51
|
+
".internal",
|
|
52
|
+
".local",
|
|
53
|
+
".cluster.local",
|
|
54
|
+
".consul",
|
|
55
|
+
".lan",
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def validate_fetch_url(url: str) -> tuple[str, str]:
|
|
60
|
+
"""Validate `url` is safe to fetch. Returns (normalized_url, hostname).
|
|
61
|
+
Raises UrlSafetyError on any rule break."""
|
|
62
|
+
if not url:
|
|
63
|
+
raise UrlSafetyError("URL is empty")
|
|
64
|
+
|
|
65
|
+
parsed = urlparse(url)
|
|
66
|
+
|
|
67
|
+
# Scheme allowlist — https only. Blocks http (cleartext + downgrade
|
|
68
|
+
# risk), file:// (local file read), gopher:// (smuggling), data://
|
|
69
|
+
# (no fetch needed), and any other esoteric scheme.
|
|
70
|
+
if parsed.scheme != "https":
|
|
71
|
+
raise UrlSafetyError(
|
|
72
|
+
f"Only https URLs are allowed (got scheme={parsed.scheme!r})"
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
host = (parsed.hostname or "").lower()
|
|
76
|
+
if not host:
|
|
77
|
+
raise UrlSafetyError("URL has no hostname")
|
|
78
|
+
|
|
79
|
+
# Port allowlist (Phase 5.5 / chi). The scheme check above only
|
|
80
|
+
# rejects non-https; an attacker can still target arbitrary TCP
|
|
81
|
+
# ports on a public host via https://public-relay.example:25/ etc.
|
|
82
|
+
# That enables (a) using the agent as a network-position scanner
|
|
83
|
+
# of memcached/redis/elasticsearch via timing oracles, (b) reaching
|
|
84
|
+
# internal services via a public hostname that resolves to a
|
|
85
|
+
# public IP fronting an internal proxy. Allowlist: standard HTTPS
|
|
86
|
+
# only. Set port to None or 443; reject everything else.
|
|
87
|
+
if parsed.port is not None and parsed.port != 443:
|
|
88
|
+
raise UrlSafetyError(
|
|
89
|
+
f"Only standard HTTPS port (443) is allowed (got port={parsed.port})"
|
|
90
|
+
)
|
|
91
|
+
|
|
92
|
+
if host in _BLOCKED_HOSTNAMES:
|
|
93
|
+
raise UrlSafetyError(f"Hostname {host!r} is blocked (cloud-metadata target)")
|
|
94
|
+
|
|
95
|
+
if any(host.endswith(s) for s in _BLOCKED_HOSTNAME_SUFFIXES):
|
|
96
|
+
raise UrlSafetyError(
|
|
97
|
+
f"Hostname {host!r} matches a blocked internal-network suffix"
|
|
98
|
+
)
|
|
99
|
+
|
|
100
|
+
# Resolve DNS and reject any IP that classifies as non-public.
|
|
101
|
+
# This catches DNS-rebinding (private IPs hidden behind public-
|
|
102
|
+
# looking hostnames) and IPv6 link-local / unique-local.
|
|
103
|
+
try:
|
|
104
|
+
addrinfo = socket.getaddrinfo(host, None)
|
|
105
|
+
except socket.gaierror as e:
|
|
106
|
+
raise UrlSafetyError(f"DNS resolution failed for {host!r}: {e}") from e
|
|
107
|
+
|
|
108
|
+
seen: set[str] = set()
|
|
109
|
+
for entry in addrinfo:
|
|
110
|
+
ip_str = entry[4][0]
|
|
111
|
+
if ip_str in seen:
|
|
112
|
+
continue
|
|
113
|
+
seen.add(ip_str)
|
|
114
|
+
ip = ipaddress.ip_address(ip_str)
|
|
115
|
+
if (
|
|
116
|
+
ip.is_private
|
|
117
|
+
or ip.is_loopback
|
|
118
|
+
or ip.is_link_local
|
|
119
|
+
or ip.is_multicast
|
|
120
|
+
or ip.is_reserved
|
|
121
|
+
or ip.is_unspecified
|
|
122
|
+
):
|
|
123
|
+
raise UrlSafetyError(
|
|
124
|
+
f"Hostname {host!r} resolves to non-public IP {ip_str} — refusing fetch"
|
|
125
|
+
)
|
|
126
|
+
|
|
127
|
+
return url, host
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.1.0"
|
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
"""MCP server runtime — wires the tool definitions in `_tools.py` to
|
|
2
|
+
the `structx-sdk` SDK from PyPI.
|
|
3
|
+
|
|
4
|
+
Designed for stdio transport (the Claude Desktop / Cursor / Windsurf
|
|
5
|
+
default). HTTP transport would be a follow-up — different deployment
|
|
6
|
+
model entirely.
|
|
7
|
+
|
|
8
|
+
Layering:
|
|
9
|
+
- _tools.py defines TOOL METADATA (descriptions, input schemas).
|
|
10
|
+
- _url_safety.py validates URLs before fetch.
|
|
11
|
+
- server.py is the runtime — instantiates the SDK client once,
|
|
12
|
+
routes tool calls to SDK methods, serializes responses.
|
|
13
|
+
|
|
14
|
+
Why dependency injection on the SDK client (`_make_client`): tests
|
|
15
|
+
substitute a fake client with `respx`-mocked HTTP. Production code
|
|
16
|
+
calls `_make_client()` with no args → defaults to env-driven SDK.
|
|
17
|
+
"""
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
import asyncio
|
|
21
|
+
import json
|
|
22
|
+
import os
|
|
23
|
+
import sys
|
|
24
|
+
from typing import Any
|
|
25
|
+
|
|
26
|
+
import httpx
|
|
27
|
+
from mcp.server import Server
|
|
28
|
+
from mcp.server.stdio import stdio_server
|
|
29
|
+
from mcp.types import TextContent
|
|
30
|
+
from structx_sdk import AsyncStructX, StructXError
|
|
31
|
+
|
|
32
|
+
from ._tools import ALL_TOOLS
|
|
33
|
+
from ._url_safety import UrlSafetyError, validate_fetch_url
|
|
34
|
+
from ._version import __version__
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
# Response size cap for `structx_extract_from_url`. 10MB matches typical
|
|
38
|
+
# web-page upper bounds; larger pages are almost certainly site dumps
|
|
39
|
+
# or attacks rather than real extraction targets.
|
|
40
|
+
_URL_FETCH_BYTE_CAP = 10 * 1024 * 1024
|
|
41
|
+
|
|
42
|
+
# Overall-fetch deadline (Phase 5.5 / φ). httpx's per-operation timeout
|
|
43
|
+
# doesn't bound a slow-loris server emitting 1 byte/chunk infinitely
|
|
44
|
+
# slowly. asyncio.wait_for around the whole fetch enforces a true
|
|
45
|
+
# total-time deadline. 60s is generous for a single page (95th-percentile
|
|
46
|
+
# load time for real pages is <10s) while bounding the worst case.
|
|
47
|
+
_URL_FETCH_TOTAL_TIMEOUT_S = 60.0
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _make_client() -> AsyncStructX:
|
|
51
|
+
"""Construct the SDK client from env. Separated from import-time
|
|
52
|
+
code so tests can patch it. Raises StructXError if STRUCTX_API_KEY
|
|
53
|
+
is missing."""
|
|
54
|
+
return AsyncStructX(
|
|
55
|
+
api_key=os.environ.get("STRUCTX_API_KEY", ""),
|
|
56
|
+
base_url=os.environ.get("STRUCTX_BASE_URL"),
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _check_api_key() -> None:
|
|
61
|
+
"""Friendly preflight — print a one-line error instead of letting
|
|
62
|
+
the SDK raise a traceback at first tool call."""
|
|
63
|
+
if not os.environ.get("STRUCTX_API_KEY"):
|
|
64
|
+
sys.stderr.write(
|
|
65
|
+
"ERROR: STRUCTX_API_KEY environment variable is required.\n"
|
|
66
|
+
"Get a key at https://app.structx.ai/api-keys, then add it\n"
|
|
67
|
+
"to your MCP-client config or shell environment.\n"
|
|
68
|
+
)
|
|
69
|
+
sys.exit(1)
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def build_server(client: AsyncStructX | None = None) -> Server:
|
|
73
|
+
"""Build the MCP server. Injectable `client` for tests; defaults to
|
|
74
|
+
env-driven SDK at runtime.
|
|
75
|
+
|
|
76
|
+
Tool dispatch is a flat if/elif chain — there are 5 tools and the
|
|
77
|
+
indirection of a registry would obscure the wire-up. If we grow
|
|
78
|
+
past ~10 tools, refactor.
|
|
79
|
+
"""
|
|
80
|
+
server = Server(f"structx-mcp/{__version__}")
|
|
81
|
+
sdk = client # bound at call-time, see _get_sdk
|
|
82
|
+
|
|
83
|
+
def _get_sdk() -> AsyncStructX:
|
|
84
|
+
nonlocal sdk
|
|
85
|
+
if sdk is None:
|
|
86
|
+
sdk = _make_client()
|
|
87
|
+
return sdk
|
|
88
|
+
|
|
89
|
+
@server.list_tools()
|
|
90
|
+
async def _list_tools():
|
|
91
|
+
return ALL_TOOLS
|
|
92
|
+
|
|
93
|
+
@server.call_tool()
|
|
94
|
+
async def _call_tool(name: str, arguments: dict[str, Any]) -> list[TextContent]:
|
|
95
|
+
try:
|
|
96
|
+
payload = await _dispatch(name, arguments, _get_sdk())
|
|
97
|
+
return [TextContent(type="text", text=json.dumps(payload, indent=2, default=str))]
|
|
98
|
+
except StructXError as e:
|
|
99
|
+
# Surface SDK errors as JSON the agent can reason about,
|
|
100
|
+
# not as exceptions that vanish into the MCP transport.
|
|
101
|
+
return [TextContent(
|
|
102
|
+
type="text",
|
|
103
|
+
text=json.dumps({"error": str(e), "type": type(e).__name__}, indent=2),
|
|
104
|
+
)]
|
|
105
|
+
except UrlSafetyError as e:
|
|
106
|
+
return [TextContent(
|
|
107
|
+
type="text",
|
|
108
|
+
text=json.dumps({"error": str(e), "type": "UrlSafetyError"}, indent=2),
|
|
109
|
+
)]
|
|
110
|
+
|
|
111
|
+
return server
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
async def _dispatch(
|
|
115
|
+
name: str,
|
|
116
|
+
arguments: dict[str, Any],
|
|
117
|
+
sdk: AsyncStructX,
|
|
118
|
+
) -> dict[str, Any]:
|
|
119
|
+
"""Pure-function dispatch — separate from server.py wiring so tests
|
|
120
|
+
can call it directly without spinning up an MCP transport."""
|
|
121
|
+
if name == "structx_extract":
|
|
122
|
+
result = await sdk.extract(
|
|
123
|
+
content=arguments["content"],
|
|
124
|
+
schema=arguments.get("schema"),
|
|
125
|
+
template_slug=arguments.get("template_slug"),
|
|
126
|
+
tier=arguments.get("tier", "required"),
|
|
127
|
+
options=arguments.get("options"),
|
|
128
|
+
)
|
|
129
|
+
return result.model_dump()
|
|
130
|
+
|
|
131
|
+
if name == "structx_infer_schema":
|
|
132
|
+
result = await sdk.infer_schema(
|
|
133
|
+
content=arguments["content"],
|
|
134
|
+
content_type=arguments.get("content_type"),
|
|
135
|
+
hints=arguments.get("hints"),
|
|
136
|
+
return_recommendations=arguments.get("return_recommendations", True),
|
|
137
|
+
)
|
|
138
|
+
return result.model_dump()
|
|
139
|
+
|
|
140
|
+
if name == "structx_extract_from_url":
|
|
141
|
+
url = arguments["url"]
|
|
142
|
+
validate_fetch_url(url)
|
|
143
|
+
content = await _fetch_url(url)
|
|
144
|
+
result = await sdk.extract(
|
|
145
|
+
content=content,
|
|
146
|
+
schema=arguments.get("schema"),
|
|
147
|
+
template_slug=arguments.get("template_slug"),
|
|
148
|
+
tier=arguments.get("tier", "required"),
|
|
149
|
+
options=arguments.get("options"),
|
|
150
|
+
)
|
|
151
|
+
return result.model_dump()
|
|
152
|
+
|
|
153
|
+
if name == "structx_list_templates":
|
|
154
|
+
templates = await sdk.list_templates()
|
|
155
|
+
return {"templates": [t.model_dump(by_alias=True) for t in templates]}
|
|
156
|
+
|
|
157
|
+
if name == "structx_usage":
|
|
158
|
+
usage = await sdk.usage()
|
|
159
|
+
return usage.model_dump()
|
|
160
|
+
|
|
161
|
+
raise ValueError(f"Unknown tool: {name}")
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
async def _fetch_url(url: str) -> str:
|
|
165
|
+
"""Fetch a URL with size cap + overall timeout + no-redirect.
|
|
166
|
+
|
|
167
|
+
The httpx `timeout` is per-operation (per-chunk during streaming
|
|
168
|
+
reads); a malicious slow-loris server emitting 1 byte every 29s
|
|
169
|
+
would never hit the per-chunk timeout but could tie up the agent
|
|
170
|
+
for hours. asyncio.wait_for around the whole fetch enforces a
|
|
171
|
+
total deadline, bounding the worst case regardless of chunk
|
|
172
|
+
pacing. (Phase 5.5 / φ.)
|
|
173
|
+
|
|
174
|
+
Streams the response and breaks at the size cap rather than reading
|
|
175
|
+
a multi-GB body into memory. Treats response as UTF-8 with errors='
|
|
176
|
+
replace' — extraction works on noisy text, no need to be strict
|
|
177
|
+
about encoding.
|
|
178
|
+
|
|
179
|
+
Validation already happened upstream in `validate_fetch_url`;
|
|
180
|
+
this is the actual I/O.
|
|
181
|
+
"""
|
|
182
|
+
async def _do_fetch() -> str:
|
|
183
|
+
async with httpx.AsyncClient(
|
|
184
|
+
timeout=30.0,
|
|
185
|
+
follow_redirects=False, # one less SSRF vector
|
|
186
|
+
limits=httpx.Limits(max_keepalive_connections=0),
|
|
187
|
+
) as fetcher:
|
|
188
|
+
async with fetcher.stream("GET", url) as response:
|
|
189
|
+
response.raise_for_status()
|
|
190
|
+
chunks: list[bytes] = []
|
|
191
|
+
total = 0
|
|
192
|
+
async for chunk in response.aiter_bytes():
|
|
193
|
+
total += len(chunk)
|
|
194
|
+
if total > _URL_FETCH_BYTE_CAP:
|
|
195
|
+
raise UrlSafetyError(
|
|
196
|
+
f"Response exceeded {_URL_FETCH_BYTE_CAP // (1024*1024)}MB cap"
|
|
197
|
+
)
|
|
198
|
+
chunks.append(chunk)
|
|
199
|
+
return b"".join(chunks).decode("utf-8", errors="replace")
|
|
200
|
+
|
|
201
|
+
try:
|
|
202
|
+
return await asyncio.wait_for(_do_fetch(), timeout=_URL_FETCH_TOTAL_TIMEOUT_S)
|
|
203
|
+
except asyncio.TimeoutError as e:
|
|
204
|
+
raise UrlSafetyError(
|
|
205
|
+
f"Fetch exceeded {_URL_FETCH_TOTAL_TIMEOUT_S}s overall deadline "
|
|
206
|
+
f"(possible slow-loris server)"
|
|
207
|
+
) from e
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
async def serve() -> None:
|
|
211
|
+
"""Run the MCP server over stdio."""
|
|
212
|
+
server = build_server()
|
|
213
|
+
async with stdio_server() as (read_stream, write_stream):
|
|
214
|
+
await server.run(read_stream, write_stream, server.create_initialization_options())
|