exstruct 0.4.0__tar.gz → 0.4.2__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.
- {exstruct-0.4.0 → exstruct-0.4.2}/PKG-INFO +6 -4
- {exstruct-0.4.0 → exstruct-0.4.2}/README.md +3 -3
- {exstruct-0.4.0 → exstruct-0.4.2}/pyproject.toml +6 -2
- {exstruct-0.4.0 → exstruct-0.4.2}/src/exstruct/mcp/server.py +312 -312
- {exstruct-0.4.0 → exstruct-0.4.2}/LICENSE +0 -0
- {exstruct-0.4.0 → exstruct-0.4.2}/src/exstruct/__init__.py +0 -0
- {exstruct-0.4.0 → exstruct-0.4.2}/src/exstruct/cli/availability.py +0 -0
- {exstruct-0.4.0 → exstruct-0.4.2}/src/exstruct/cli/main.py +0 -0
- {exstruct-0.4.0 → exstruct-0.4.2}/src/exstruct/core/__init__.py +0 -0
- {exstruct-0.4.0 → exstruct-0.4.2}/src/exstruct/core/backends/__init__.py +0 -0
- {exstruct-0.4.0 → exstruct-0.4.2}/src/exstruct/core/backends/base.py +0 -0
- {exstruct-0.4.0 → exstruct-0.4.2}/src/exstruct/core/backends/com_backend.py +0 -0
- {exstruct-0.4.0 → exstruct-0.4.2}/src/exstruct/core/backends/openpyxl_backend.py +0 -0
- {exstruct-0.4.0 → exstruct-0.4.2}/src/exstruct/core/cells.py +0 -0
- {exstruct-0.4.0 → exstruct-0.4.2}/src/exstruct/core/charts.py +0 -0
- {exstruct-0.4.0 → exstruct-0.4.2}/src/exstruct/core/integrate.py +0 -0
- {exstruct-0.4.0 → exstruct-0.4.2}/src/exstruct/core/logging_utils.py +0 -0
- {exstruct-0.4.0 → exstruct-0.4.2}/src/exstruct/core/modeling.py +0 -0
- {exstruct-0.4.0 → exstruct-0.4.2}/src/exstruct/core/pipeline.py +0 -0
- {exstruct-0.4.0 → exstruct-0.4.2}/src/exstruct/core/ranges.py +0 -0
- {exstruct-0.4.0 → exstruct-0.4.2}/src/exstruct/core/shapes.py +0 -0
- {exstruct-0.4.0 → exstruct-0.4.2}/src/exstruct/core/workbook.py +0 -0
- {exstruct-0.4.0 → exstruct-0.4.2}/src/exstruct/engine.py +0 -0
- {exstruct-0.4.0 → exstruct-0.4.2}/src/exstruct/errors.py +0 -0
- {exstruct-0.4.0 → exstruct-0.4.2}/src/exstruct/io/__init__.py +0 -0
- {exstruct-0.4.0 → exstruct-0.4.2}/src/exstruct/io/serialize.py +0 -0
- {exstruct-0.4.0 → exstruct-0.4.2}/src/exstruct/mcp/__init__.py +0 -0
- {exstruct-0.4.0 → exstruct-0.4.2}/src/exstruct/mcp/chunk_reader.py +0 -0
- {exstruct-0.4.0 → exstruct-0.4.2}/src/exstruct/mcp/extract_runner.py +0 -0
- {exstruct-0.4.0 → exstruct-0.4.2}/src/exstruct/mcp/io.py +0 -0
- {exstruct-0.4.0 → exstruct-0.4.2}/src/exstruct/mcp/tools.py +0 -0
- {exstruct-0.4.0 → exstruct-0.4.2}/src/exstruct/mcp/validate_input.py +0 -0
- {exstruct-0.4.0 → exstruct-0.4.2}/src/exstruct/models/__init__.py +0 -0
- {exstruct-0.4.0 → exstruct-0.4.2}/src/exstruct/models/maps.py +0 -0
- {exstruct-0.4.0 → exstruct-0.4.2}/src/exstruct/models/types.py +0 -0
- {exstruct-0.4.0 → exstruct-0.4.2}/src/exstruct/py.typed +0 -0
- {exstruct-0.4.0 → exstruct-0.4.2}/src/exstruct/render/__init__.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.3
|
|
2
2
|
Name: exstruct
|
|
3
|
-
Version: 0.4.
|
|
3
|
+
Version: 0.4.2
|
|
4
4
|
Summary: Excel to structured JSON (tables, shapes, charts) for LLM/RAG pipelines
|
|
5
5
|
Keywords: excel,structure,data,exstruct
|
|
6
6
|
Author: harumiWeb
|
|
@@ -44,7 +44,9 @@ Requires-Dist: python-toon>=0.1.3 ; extra == 'all'
|
|
|
44
44
|
Requires-Dist: pypdfium2>=5.1.0 ; extra == 'all'
|
|
45
45
|
Requires-Dist: pillow>=12.0.0 ; extra == 'all'
|
|
46
46
|
Requires-Dist: mcp>=1.6.0,<2.0.0 ; extra == 'all'
|
|
47
|
+
Requires-Dist: httpx>=0.27,<1.0 ; extra == 'all'
|
|
47
48
|
Requires-Dist: mcp>=1.6.0,<2.0.0 ; extra == 'mcp'
|
|
49
|
+
Requires-Dist: httpx>=0.27,<1.0 ; extra == 'mcp'
|
|
48
50
|
Requires-Dist: pypdfium2>=5.1.0 ; extra == 'render'
|
|
49
51
|
Requires-Dist: pillow>=12.0.0 ; extra == 'render'
|
|
50
52
|
Requires-Dist: python-toon>=0.1.3 ; extra == 'toon'
|
|
@@ -126,9 +128,9 @@ exstruct-mcp --root C:\data --log-file C:\logs\exstruct-mcp.log --on-conflict re
|
|
|
126
128
|
|
|
127
129
|
Available tools:
|
|
128
130
|
|
|
129
|
-
- `
|
|
130
|
-
- `
|
|
131
|
-
- `
|
|
131
|
+
- `exstruct_extract`
|
|
132
|
+
- `exstruct_read_json_chunk`
|
|
133
|
+
- `exstruct_validate_input`
|
|
132
134
|
|
|
133
135
|
Notes:
|
|
134
136
|
|
|
@@ -63,9 +63,9 @@ exstruct-mcp --root C:\data --log-file C:\logs\exstruct-mcp.log --on-conflict re
|
|
|
63
63
|
|
|
64
64
|
Available tools:
|
|
65
65
|
|
|
66
|
-
- `
|
|
67
|
-
- `
|
|
68
|
-
- `
|
|
66
|
+
- `exstruct_extract`
|
|
67
|
+
- `exstruct_read_json_chunk`
|
|
68
|
+
- `exstruct_validate_input`
|
|
69
69
|
|
|
70
70
|
Notes:
|
|
71
71
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "exstruct"
|
|
3
|
-
version = "0.4.
|
|
3
|
+
version = "0.4.2"
|
|
4
4
|
description = "Excel to structured JSON (tables, shapes, charts) for LLM/RAG pipelines"
|
|
5
5
|
readme = "README.md"
|
|
6
6
|
license = { file = "LICENSE" }
|
|
@@ -43,11 +43,15 @@ all = [
|
|
|
43
43
|
"pypdfium2>=5.1.0",
|
|
44
44
|
"Pillow>=12.0.0",
|
|
45
45
|
"mcp>=1.6.0,<2.0.0",
|
|
46
|
+
"httpx>=0.27,<1.0",
|
|
46
47
|
]
|
|
47
48
|
yaml = ["pyyaml>=6.0.3"]
|
|
48
49
|
toon = ["python-toon>=0.1.3"]
|
|
49
50
|
render = ["pypdfium2>=5.1.0", "Pillow>=12.0.0"]
|
|
50
|
-
mcp = [
|
|
51
|
+
mcp = [
|
|
52
|
+
"mcp>=1.6.0,<2.0.0",
|
|
53
|
+
"httpx>=0.27,<1.0",
|
|
54
|
+
]
|
|
51
55
|
|
|
52
56
|
[project.scripts]
|
|
53
57
|
exstruct = "exstruct.cli.main:main"
|
|
@@ -1,312 +1,312 @@
|
|
|
1
|
-
from __future__ import annotations
|
|
2
|
-
|
|
3
|
-
import argparse
|
|
4
|
-
import functools
|
|
5
|
-
import importlib
|
|
6
|
-
import logging
|
|
7
|
-
import os
|
|
8
|
-
from pathlib import Path
|
|
9
|
-
from types import ModuleType
|
|
10
|
-
from typing import TYPE_CHECKING, Any, Literal, cast
|
|
11
|
-
|
|
12
|
-
import anyio
|
|
13
|
-
from pydantic import BaseModel, Field
|
|
14
|
-
|
|
15
|
-
from exstruct import ExtractionMode
|
|
16
|
-
|
|
17
|
-
from .extract_runner import OnConflictPolicy
|
|
18
|
-
from .io import PathPolicy
|
|
19
|
-
from .tools import (
|
|
20
|
-
ExtractToolInput,
|
|
21
|
-
ExtractToolOutput,
|
|
22
|
-
ReadJsonChunkToolInput,
|
|
23
|
-
ReadJsonChunkToolOutput,
|
|
24
|
-
ValidateInputToolInput,
|
|
25
|
-
ValidateInputToolOutput,
|
|
26
|
-
run_extract_tool,
|
|
27
|
-
run_read_json_chunk_tool,
|
|
28
|
-
run_validate_input_tool,
|
|
29
|
-
)
|
|
30
|
-
|
|
31
|
-
if TYPE_CHECKING: # pragma: no cover - typing only
|
|
32
|
-
from mcp.server.fastmcp import FastMCP
|
|
33
|
-
|
|
34
|
-
logger = logging.getLogger(__name__)
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
class ServerConfig(BaseModel):
|
|
38
|
-
"""Configuration for the MCP server process."""
|
|
39
|
-
|
|
40
|
-
root: Path = Field(..., description="Root directory for file access.")
|
|
41
|
-
deny_globs: list[str] = Field(default_factory=list, description="Denied glob list.")
|
|
42
|
-
log_level: str = Field(default="INFO", description="Logging level.")
|
|
43
|
-
log_file: Path | None = Field(default=None, description="Optional log file path.")
|
|
44
|
-
on_conflict: OnConflictPolicy = Field(
|
|
45
|
-
default="overwrite", description="Output conflict policy."
|
|
46
|
-
)
|
|
47
|
-
warmup: bool = Field(default=False, description="Warm up heavy imports on start.")
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
def main(argv: list[str] | None = None) -> int:
|
|
51
|
-
"""Run the MCP server entrypoint.
|
|
52
|
-
|
|
53
|
-
Args:
|
|
54
|
-
argv: Optional CLI arguments for testing.
|
|
55
|
-
|
|
56
|
-
Returns:
|
|
57
|
-
Exit code (0 for success, 1 for failure).
|
|
58
|
-
"""
|
|
59
|
-
config = _parse_args(argv)
|
|
60
|
-
_configure_logging(config)
|
|
61
|
-
try:
|
|
62
|
-
run_server(config)
|
|
63
|
-
except Exception as exc: # pragma: no cover - surface runtime errors
|
|
64
|
-
logger.error("MCP server failed: %s", exc)
|
|
65
|
-
return 1
|
|
66
|
-
return 0
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
def run_server(config: ServerConfig) -> None:
|
|
70
|
-
"""Start the MCP server.
|
|
71
|
-
|
|
72
|
-
Args:
|
|
73
|
-
config: Server configuration.
|
|
74
|
-
"""
|
|
75
|
-
os.environ.setdefault("EXSTRUCT_BORDER_CLUSTER_BACKEND", "python")
|
|
76
|
-
logger.info(
|
|
77
|
-
"Border cluster backend set to %s for MCP.",
|
|
78
|
-
os.getenv("EXSTRUCT_BORDER_CLUSTER_BACKEND"),
|
|
79
|
-
)
|
|
80
|
-
_import_mcp()
|
|
81
|
-
policy = PathPolicy(root=config.root, deny_globs=config.deny_globs)
|
|
82
|
-
logger.info("MCP root: %s", policy.normalize_root())
|
|
83
|
-
if config.warmup:
|
|
84
|
-
_warmup_exstruct()
|
|
85
|
-
app = _create_app(policy, on_conflict=config.on_conflict)
|
|
86
|
-
app.run()
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
def _parse_args(argv: list[str] | None) -> ServerConfig:
|
|
90
|
-
"""Parse CLI arguments into server config.
|
|
91
|
-
|
|
92
|
-
Args:
|
|
93
|
-
argv: Optional CLI argument list.
|
|
94
|
-
|
|
95
|
-
Returns:
|
|
96
|
-
Parsed server configuration.
|
|
97
|
-
"""
|
|
98
|
-
parser = argparse.ArgumentParser(description="ExStruct MCP server (stdio).")
|
|
99
|
-
parser.add_argument("--root", type=Path, required=True, help="Workspace root.")
|
|
100
|
-
parser.add_argument(
|
|
101
|
-
"--deny-glob",
|
|
102
|
-
action="append",
|
|
103
|
-
default=[],
|
|
104
|
-
help="Glob pattern to deny (can be specified multiple times).",
|
|
105
|
-
)
|
|
106
|
-
parser.add_argument(
|
|
107
|
-
"--log-level",
|
|
108
|
-
default="INFO",
|
|
109
|
-
help="Logging level (DEBUG, INFO, WARNING, ERROR).",
|
|
110
|
-
)
|
|
111
|
-
parser.add_argument("--log-file", type=Path, help="Optional log file path.")
|
|
112
|
-
parser.add_argument(
|
|
113
|
-
"--on-conflict",
|
|
114
|
-
choices=["overwrite", "skip", "rename"],
|
|
115
|
-
default="overwrite",
|
|
116
|
-
help="Output conflict policy (overwrite/skip/rename).",
|
|
117
|
-
)
|
|
118
|
-
parser.add_argument(
|
|
119
|
-
"--warmup",
|
|
120
|
-
action="store_true",
|
|
121
|
-
help="Warm up heavy imports on startup to reduce tool latency.",
|
|
122
|
-
)
|
|
123
|
-
args = parser.parse_args(argv)
|
|
124
|
-
return ServerConfig(
|
|
125
|
-
root=args.root,
|
|
126
|
-
deny_globs=list(args.deny_glob),
|
|
127
|
-
log_level=args.log_level,
|
|
128
|
-
log_file=args.log_file,
|
|
129
|
-
on_conflict=args.on_conflict,
|
|
130
|
-
warmup=bool(args.warmup),
|
|
131
|
-
)
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
def _configure_logging(config: ServerConfig) -> None:
|
|
135
|
-
"""Configure logging for the server process.
|
|
136
|
-
|
|
137
|
-
Args:
|
|
138
|
-
config: Server configuration.
|
|
139
|
-
"""
|
|
140
|
-
handlers: list[logging.Handler] = [logging.StreamHandler()]
|
|
141
|
-
if config.log_file is not None:
|
|
142
|
-
handlers.append(logging.FileHandler(config.log_file))
|
|
143
|
-
logging.basicConfig(
|
|
144
|
-
level=config.log_level.upper(),
|
|
145
|
-
handlers=handlers,
|
|
146
|
-
format="%(asctime)s %(levelname)s %(name)s: %(message)s",
|
|
147
|
-
)
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
def _import_mcp() -> ModuleType:
|
|
151
|
-
"""Import the MCP SDK module or raise a helpful error.
|
|
152
|
-
|
|
153
|
-
Returns:
|
|
154
|
-
Imported MCP module.
|
|
155
|
-
"""
|
|
156
|
-
try:
|
|
157
|
-
return importlib.import_module("mcp")
|
|
158
|
-
except ModuleNotFoundError as exc:
|
|
159
|
-
raise RuntimeError(
|
|
160
|
-
"MCP SDK is not installed. Install with `pip install exstruct[mcp]`."
|
|
161
|
-
) from exc
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
def _warmup_exstruct() -> None:
|
|
165
|
-
"""Warm up heavy imports to reduce first-call latency."""
|
|
166
|
-
logger.info("Warming up ExStruct imports...")
|
|
167
|
-
importlib.import_module("exstruct.core.cells")
|
|
168
|
-
importlib.import_module("exstruct.core.integrate")
|
|
169
|
-
logger.info("Warmup completed.")
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
def _create_app(policy: PathPolicy, *, on_conflict: OnConflictPolicy) -> FastMCP:
|
|
173
|
-
"""Create the MCP FastMCP application.
|
|
174
|
-
|
|
175
|
-
Args:
|
|
176
|
-
policy: Path policy for filesystem access.
|
|
177
|
-
|
|
178
|
-
Returns:
|
|
179
|
-
FastMCP application instance.
|
|
180
|
-
"""
|
|
181
|
-
from mcp.server.fastmcp import FastMCP
|
|
182
|
-
|
|
183
|
-
app = FastMCP("ExStruct MCP", json_response=True)
|
|
184
|
-
_register_tools(app, policy, default_on_conflict=on_conflict)
|
|
185
|
-
return app
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
def _register_tools(
|
|
189
|
-
app: FastMCP, policy: PathPolicy, *, default_on_conflict: OnConflictPolicy
|
|
190
|
-
) -> None:
|
|
191
|
-
"""Register MCP tools for the server.
|
|
192
|
-
|
|
193
|
-
Args:
|
|
194
|
-
app: FastMCP application instance.
|
|
195
|
-
policy: Path policy for filesystem access.
|
|
196
|
-
"""
|
|
197
|
-
|
|
198
|
-
async def _extract_tool( # pylint: disable=redefined-builtin
|
|
199
|
-
xlsx_path: str,
|
|
200
|
-
mode: ExtractionMode = "standard",
|
|
201
|
-
format: Literal["json", "yaml", "yml", "toon"] = "json", # noqa: A002
|
|
202
|
-
out_dir: str | None = None,
|
|
203
|
-
out_name: str | None = None,
|
|
204
|
-
on_conflict: OnConflictPolicy | None = None,
|
|
205
|
-
options: dict[str, Any] | None = None,
|
|
206
|
-
) -> ExtractToolOutput:
|
|
207
|
-
"""Handle the ExStruct extraction tool call.
|
|
208
|
-
|
|
209
|
-
Args:
|
|
210
|
-
xlsx_path: Path to the Excel workbook.
|
|
211
|
-
mode: Extraction mode.
|
|
212
|
-
format: Output format.
|
|
213
|
-
out_dir: Optional output directory.
|
|
214
|
-
out_name: Optional output filename.
|
|
215
|
-
options: Additional options (reserved for future use).
|
|
216
|
-
|
|
217
|
-
Returns:
|
|
218
|
-
Extraction result payload.
|
|
219
|
-
"""
|
|
220
|
-
payload = ExtractToolInput(
|
|
221
|
-
xlsx_path=xlsx_path,
|
|
222
|
-
mode=mode,
|
|
223
|
-
format=format,
|
|
224
|
-
out_dir=out_dir,
|
|
225
|
-
out_name=out_name,
|
|
226
|
-
on_conflict=on_conflict,
|
|
227
|
-
options=options or {},
|
|
228
|
-
)
|
|
229
|
-
effective_on_conflict = on_conflict or default_on_conflict
|
|
230
|
-
work = functools.partial(
|
|
231
|
-
run_extract_tool,
|
|
232
|
-
payload,
|
|
233
|
-
policy=policy,
|
|
234
|
-
on_conflict=effective_on_conflict,
|
|
235
|
-
)
|
|
236
|
-
result = cast(ExtractToolOutput, await anyio.to_thread.run_sync(work))
|
|
237
|
-
return result
|
|
238
|
-
|
|
239
|
-
tool = app.tool(name="
|
|
240
|
-
tool(_extract_tool)
|
|
241
|
-
|
|
242
|
-
async def _read_json_chunk_tool( # pylint: disable=redefined-builtin
|
|
243
|
-
out_path: str,
|
|
244
|
-
sheet: str | None = None,
|
|
245
|
-
max_bytes: int = 50_000,
|
|
246
|
-
filter: dict[str, Any] | None = None, # noqa: A002
|
|
247
|
-
cursor: str | None = None,
|
|
248
|
-
) -> ReadJsonChunkToolOutput:
|
|
249
|
-
"""Handle JSON chunk tool call.
|
|
250
|
-
|
|
251
|
-
Args:
|
|
252
|
-
out_path: Path to the JSON output file.
|
|
253
|
-
sheet: Optional sheet name.
|
|
254
|
-
max_bytes: Maximum chunk size in bytes.
|
|
255
|
-
filter: Optional filter payload.
|
|
256
|
-
cursor: Optional cursor for pagination.
|
|
257
|
-
|
|
258
|
-
Returns:
|
|
259
|
-
JSON chunk result payload.
|
|
260
|
-
"""
|
|
261
|
-
payload = ReadJsonChunkToolInput(
|
|
262
|
-
out_path=out_path,
|
|
263
|
-
sheet=sheet,
|
|
264
|
-
max_bytes=max_bytes,
|
|
265
|
-
filter=_coerce_filter(filter),
|
|
266
|
-
cursor=cursor,
|
|
267
|
-
)
|
|
268
|
-
work = functools.partial(
|
|
269
|
-
run_read_json_chunk_tool,
|
|
270
|
-
payload,
|
|
271
|
-
policy=policy,
|
|
272
|
-
)
|
|
273
|
-
result = cast(ReadJsonChunkToolOutput, await anyio.to_thread.run_sync(work))
|
|
274
|
-
return result
|
|
275
|
-
|
|
276
|
-
chunk_tool = app.tool(name="
|
|
277
|
-
chunk_tool(_read_json_chunk_tool)
|
|
278
|
-
|
|
279
|
-
async def _validate_input_tool(xlsx_path: str) -> ValidateInputToolOutput:
|
|
280
|
-
"""Handle input validation tool call.
|
|
281
|
-
|
|
282
|
-
Args:
|
|
283
|
-
xlsx_path: Path to the Excel workbook.
|
|
284
|
-
|
|
285
|
-
Returns:
|
|
286
|
-
Validation result payload.
|
|
287
|
-
"""
|
|
288
|
-
payload = ValidateInputToolInput(xlsx_path=xlsx_path)
|
|
289
|
-
work = functools.partial(
|
|
290
|
-
run_validate_input_tool,
|
|
291
|
-
payload,
|
|
292
|
-
policy=policy,
|
|
293
|
-
)
|
|
294
|
-
result = cast(ValidateInputToolOutput, await anyio.to_thread.run_sync(work))
|
|
295
|
-
return result
|
|
296
|
-
|
|
297
|
-
validate_tool = app.tool(name="
|
|
298
|
-
validate_tool(_validate_input_tool)
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
def _coerce_filter(filter_data: dict[str, Any] | None) -> dict[str, Any] | None:
|
|
302
|
-
"""Normalize filter input for chunk reading.
|
|
303
|
-
|
|
304
|
-
Args:
|
|
305
|
-
filter_data: Filter payload from MCP tool call.
|
|
306
|
-
|
|
307
|
-
Returns:
|
|
308
|
-
Normalized filter dict or None.
|
|
309
|
-
"""
|
|
310
|
-
if not filter_data:
|
|
311
|
-
return None
|
|
312
|
-
return dict(filter_data)
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
import functools
|
|
5
|
+
import importlib
|
|
6
|
+
import logging
|
|
7
|
+
import os
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from types import ModuleType
|
|
10
|
+
from typing import TYPE_CHECKING, Any, Literal, cast
|
|
11
|
+
|
|
12
|
+
import anyio
|
|
13
|
+
from pydantic import BaseModel, Field
|
|
14
|
+
|
|
15
|
+
from exstruct import ExtractionMode
|
|
16
|
+
|
|
17
|
+
from .extract_runner import OnConflictPolicy
|
|
18
|
+
from .io import PathPolicy
|
|
19
|
+
from .tools import (
|
|
20
|
+
ExtractToolInput,
|
|
21
|
+
ExtractToolOutput,
|
|
22
|
+
ReadJsonChunkToolInput,
|
|
23
|
+
ReadJsonChunkToolOutput,
|
|
24
|
+
ValidateInputToolInput,
|
|
25
|
+
ValidateInputToolOutput,
|
|
26
|
+
run_extract_tool,
|
|
27
|
+
run_read_json_chunk_tool,
|
|
28
|
+
run_validate_input_tool,
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
if TYPE_CHECKING: # pragma: no cover - typing only
|
|
32
|
+
from mcp.server.fastmcp import FastMCP
|
|
33
|
+
|
|
34
|
+
logger = logging.getLogger(__name__)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class ServerConfig(BaseModel):
|
|
38
|
+
"""Configuration for the MCP server process."""
|
|
39
|
+
|
|
40
|
+
root: Path = Field(..., description="Root directory for file access.")
|
|
41
|
+
deny_globs: list[str] = Field(default_factory=list, description="Denied glob list.")
|
|
42
|
+
log_level: str = Field(default="INFO", description="Logging level.")
|
|
43
|
+
log_file: Path | None = Field(default=None, description="Optional log file path.")
|
|
44
|
+
on_conflict: OnConflictPolicy = Field(
|
|
45
|
+
default="overwrite", description="Output conflict policy."
|
|
46
|
+
)
|
|
47
|
+
warmup: bool = Field(default=False, description="Warm up heavy imports on start.")
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def main(argv: list[str] | None = None) -> int:
|
|
51
|
+
"""Run the MCP server entrypoint.
|
|
52
|
+
|
|
53
|
+
Args:
|
|
54
|
+
argv: Optional CLI arguments for testing.
|
|
55
|
+
|
|
56
|
+
Returns:
|
|
57
|
+
Exit code (0 for success, 1 for failure).
|
|
58
|
+
"""
|
|
59
|
+
config = _parse_args(argv)
|
|
60
|
+
_configure_logging(config)
|
|
61
|
+
try:
|
|
62
|
+
run_server(config)
|
|
63
|
+
except Exception as exc: # pragma: no cover - surface runtime errors
|
|
64
|
+
logger.error("MCP server failed: %s", exc)
|
|
65
|
+
return 1
|
|
66
|
+
return 0
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def run_server(config: ServerConfig) -> None:
|
|
70
|
+
"""Start the MCP server.
|
|
71
|
+
|
|
72
|
+
Args:
|
|
73
|
+
config: Server configuration.
|
|
74
|
+
"""
|
|
75
|
+
os.environ.setdefault("EXSTRUCT_BORDER_CLUSTER_BACKEND", "python")
|
|
76
|
+
logger.info(
|
|
77
|
+
"Border cluster backend set to %s for MCP.",
|
|
78
|
+
os.getenv("EXSTRUCT_BORDER_CLUSTER_BACKEND"),
|
|
79
|
+
)
|
|
80
|
+
_import_mcp()
|
|
81
|
+
policy = PathPolicy(root=config.root, deny_globs=config.deny_globs)
|
|
82
|
+
logger.info("MCP root: %s", policy.normalize_root())
|
|
83
|
+
if config.warmup:
|
|
84
|
+
_warmup_exstruct()
|
|
85
|
+
app = _create_app(policy, on_conflict=config.on_conflict)
|
|
86
|
+
app.run()
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def _parse_args(argv: list[str] | None) -> ServerConfig:
|
|
90
|
+
"""Parse CLI arguments into server config.
|
|
91
|
+
|
|
92
|
+
Args:
|
|
93
|
+
argv: Optional CLI argument list.
|
|
94
|
+
|
|
95
|
+
Returns:
|
|
96
|
+
Parsed server configuration.
|
|
97
|
+
"""
|
|
98
|
+
parser = argparse.ArgumentParser(description="ExStruct MCP server (stdio).")
|
|
99
|
+
parser.add_argument("--root", type=Path, required=True, help="Workspace root.")
|
|
100
|
+
parser.add_argument(
|
|
101
|
+
"--deny-glob",
|
|
102
|
+
action="append",
|
|
103
|
+
default=[],
|
|
104
|
+
help="Glob pattern to deny (can be specified multiple times).",
|
|
105
|
+
)
|
|
106
|
+
parser.add_argument(
|
|
107
|
+
"--log-level",
|
|
108
|
+
default="INFO",
|
|
109
|
+
help="Logging level (DEBUG, INFO, WARNING, ERROR).",
|
|
110
|
+
)
|
|
111
|
+
parser.add_argument("--log-file", type=Path, help="Optional log file path.")
|
|
112
|
+
parser.add_argument(
|
|
113
|
+
"--on-conflict",
|
|
114
|
+
choices=["overwrite", "skip", "rename"],
|
|
115
|
+
default="overwrite",
|
|
116
|
+
help="Output conflict policy (overwrite/skip/rename).",
|
|
117
|
+
)
|
|
118
|
+
parser.add_argument(
|
|
119
|
+
"--warmup",
|
|
120
|
+
action="store_true",
|
|
121
|
+
help="Warm up heavy imports on startup to reduce tool latency.",
|
|
122
|
+
)
|
|
123
|
+
args = parser.parse_args(argv)
|
|
124
|
+
return ServerConfig(
|
|
125
|
+
root=args.root,
|
|
126
|
+
deny_globs=list(args.deny_glob),
|
|
127
|
+
log_level=args.log_level,
|
|
128
|
+
log_file=args.log_file,
|
|
129
|
+
on_conflict=args.on_conflict,
|
|
130
|
+
warmup=bool(args.warmup),
|
|
131
|
+
)
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def _configure_logging(config: ServerConfig) -> None:
|
|
135
|
+
"""Configure logging for the server process.
|
|
136
|
+
|
|
137
|
+
Args:
|
|
138
|
+
config: Server configuration.
|
|
139
|
+
"""
|
|
140
|
+
handlers: list[logging.Handler] = [logging.StreamHandler()]
|
|
141
|
+
if config.log_file is not None:
|
|
142
|
+
handlers.append(logging.FileHandler(config.log_file))
|
|
143
|
+
logging.basicConfig(
|
|
144
|
+
level=config.log_level.upper(),
|
|
145
|
+
handlers=handlers,
|
|
146
|
+
format="%(asctime)s %(levelname)s %(name)s: %(message)s",
|
|
147
|
+
)
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def _import_mcp() -> ModuleType:
|
|
151
|
+
"""Import the MCP SDK module or raise a helpful error.
|
|
152
|
+
|
|
153
|
+
Returns:
|
|
154
|
+
Imported MCP module.
|
|
155
|
+
"""
|
|
156
|
+
try:
|
|
157
|
+
return importlib.import_module("mcp")
|
|
158
|
+
except ModuleNotFoundError as exc:
|
|
159
|
+
raise RuntimeError(
|
|
160
|
+
"MCP SDK is not installed. Install with `pip install exstruct[mcp]`."
|
|
161
|
+
) from exc
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def _warmup_exstruct() -> None:
|
|
165
|
+
"""Warm up heavy imports to reduce first-call latency."""
|
|
166
|
+
logger.info("Warming up ExStruct imports...")
|
|
167
|
+
importlib.import_module("exstruct.core.cells")
|
|
168
|
+
importlib.import_module("exstruct.core.integrate")
|
|
169
|
+
logger.info("Warmup completed.")
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def _create_app(policy: PathPolicy, *, on_conflict: OnConflictPolicy) -> FastMCP:
|
|
173
|
+
"""Create the MCP FastMCP application.
|
|
174
|
+
|
|
175
|
+
Args:
|
|
176
|
+
policy: Path policy for filesystem access.
|
|
177
|
+
|
|
178
|
+
Returns:
|
|
179
|
+
FastMCP application instance.
|
|
180
|
+
"""
|
|
181
|
+
from mcp.server.fastmcp import FastMCP
|
|
182
|
+
|
|
183
|
+
app = FastMCP("ExStruct MCP", json_response=True)
|
|
184
|
+
_register_tools(app, policy, default_on_conflict=on_conflict)
|
|
185
|
+
return app
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def _register_tools(
|
|
189
|
+
app: FastMCP, policy: PathPolicy, *, default_on_conflict: OnConflictPolicy
|
|
190
|
+
) -> None:
|
|
191
|
+
"""Register MCP tools for the server.
|
|
192
|
+
|
|
193
|
+
Args:
|
|
194
|
+
app: FastMCP application instance.
|
|
195
|
+
policy: Path policy for filesystem access.
|
|
196
|
+
"""
|
|
197
|
+
|
|
198
|
+
async def _extract_tool( # pylint: disable=redefined-builtin
|
|
199
|
+
xlsx_path: str,
|
|
200
|
+
mode: ExtractionMode = "standard",
|
|
201
|
+
format: Literal["json", "yaml", "yml", "toon"] = "json", # noqa: A002
|
|
202
|
+
out_dir: str | None = None,
|
|
203
|
+
out_name: str | None = None,
|
|
204
|
+
on_conflict: OnConflictPolicy | None = None,
|
|
205
|
+
options: dict[str, Any] | None = None,
|
|
206
|
+
) -> ExtractToolOutput:
|
|
207
|
+
"""Handle the ExStruct extraction tool call.
|
|
208
|
+
|
|
209
|
+
Args:
|
|
210
|
+
xlsx_path: Path to the Excel workbook.
|
|
211
|
+
mode: Extraction mode.
|
|
212
|
+
format: Output format.
|
|
213
|
+
out_dir: Optional output directory.
|
|
214
|
+
out_name: Optional output filename.
|
|
215
|
+
options: Additional options (reserved for future use).
|
|
216
|
+
|
|
217
|
+
Returns:
|
|
218
|
+
Extraction result payload.
|
|
219
|
+
"""
|
|
220
|
+
payload = ExtractToolInput(
|
|
221
|
+
xlsx_path=xlsx_path,
|
|
222
|
+
mode=mode,
|
|
223
|
+
format=format,
|
|
224
|
+
out_dir=out_dir,
|
|
225
|
+
out_name=out_name,
|
|
226
|
+
on_conflict=on_conflict,
|
|
227
|
+
options=options or {},
|
|
228
|
+
)
|
|
229
|
+
effective_on_conflict = on_conflict or default_on_conflict
|
|
230
|
+
work = functools.partial(
|
|
231
|
+
run_extract_tool,
|
|
232
|
+
payload,
|
|
233
|
+
policy=policy,
|
|
234
|
+
on_conflict=effective_on_conflict,
|
|
235
|
+
)
|
|
236
|
+
result = cast(ExtractToolOutput, await anyio.to_thread.run_sync(work))
|
|
237
|
+
return result
|
|
238
|
+
|
|
239
|
+
tool = app.tool(name="exstruct_extract")
|
|
240
|
+
tool(_extract_tool)
|
|
241
|
+
|
|
242
|
+
async def _read_json_chunk_tool( # pylint: disable=redefined-builtin
|
|
243
|
+
out_path: str,
|
|
244
|
+
sheet: str | None = None,
|
|
245
|
+
max_bytes: int = 50_000,
|
|
246
|
+
filter: dict[str, Any] | None = None, # noqa: A002
|
|
247
|
+
cursor: str | None = None,
|
|
248
|
+
) -> ReadJsonChunkToolOutput:
|
|
249
|
+
"""Handle JSON chunk tool call.
|
|
250
|
+
|
|
251
|
+
Args:
|
|
252
|
+
out_path: Path to the JSON output file.
|
|
253
|
+
sheet: Optional sheet name.
|
|
254
|
+
max_bytes: Maximum chunk size in bytes.
|
|
255
|
+
filter: Optional filter payload.
|
|
256
|
+
cursor: Optional cursor for pagination.
|
|
257
|
+
|
|
258
|
+
Returns:
|
|
259
|
+
JSON chunk result payload.
|
|
260
|
+
"""
|
|
261
|
+
payload = ReadJsonChunkToolInput(
|
|
262
|
+
out_path=out_path,
|
|
263
|
+
sheet=sheet,
|
|
264
|
+
max_bytes=max_bytes,
|
|
265
|
+
filter=_coerce_filter(filter),
|
|
266
|
+
cursor=cursor,
|
|
267
|
+
)
|
|
268
|
+
work = functools.partial(
|
|
269
|
+
run_read_json_chunk_tool,
|
|
270
|
+
payload,
|
|
271
|
+
policy=policy,
|
|
272
|
+
)
|
|
273
|
+
result = cast(ReadJsonChunkToolOutput, await anyio.to_thread.run_sync(work))
|
|
274
|
+
return result
|
|
275
|
+
|
|
276
|
+
chunk_tool = app.tool(name="exstruct_read_json_chunk")
|
|
277
|
+
chunk_tool(_read_json_chunk_tool)
|
|
278
|
+
|
|
279
|
+
async def _validate_input_tool(xlsx_path: str) -> ValidateInputToolOutput:
|
|
280
|
+
"""Handle input validation tool call.
|
|
281
|
+
|
|
282
|
+
Args:
|
|
283
|
+
xlsx_path: Path to the Excel workbook.
|
|
284
|
+
|
|
285
|
+
Returns:
|
|
286
|
+
Validation result payload.
|
|
287
|
+
"""
|
|
288
|
+
payload = ValidateInputToolInput(xlsx_path=xlsx_path)
|
|
289
|
+
work = functools.partial(
|
|
290
|
+
run_validate_input_tool,
|
|
291
|
+
payload,
|
|
292
|
+
policy=policy,
|
|
293
|
+
)
|
|
294
|
+
result = cast(ValidateInputToolOutput, await anyio.to_thread.run_sync(work))
|
|
295
|
+
return result
|
|
296
|
+
|
|
297
|
+
validate_tool = app.tool(name="exstruct_validate_input")
|
|
298
|
+
validate_tool(_validate_input_tool)
|
|
299
|
+
|
|
300
|
+
|
|
301
|
+
def _coerce_filter(filter_data: dict[str, Any] | None) -> dict[str, Any] | None:
|
|
302
|
+
"""Normalize filter input for chunk reading.
|
|
303
|
+
|
|
304
|
+
Args:
|
|
305
|
+
filter_data: Filter payload from MCP tool call.
|
|
306
|
+
|
|
307
|
+
Returns:
|
|
308
|
+
Normalized filter dict or None.
|
|
309
|
+
"""
|
|
310
|
+
if not filter_data:
|
|
311
|
+
return None
|
|
312
|
+
return dict(filter_data)
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|