sg-mcp-server 0.1.0__py3-none-any.whl
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.
- sg_mcp_server/__init__.py +19 -0
- sg_mcp_server/__main__.py +13 -0
- sg_mcp_server/_audit.py +66 -0
- sg_mcp_server/_cli.py +276 -0
- sg_mcp_server/_config.py +185 -0
- sg_mcp_server/_diagnostics.py +444 -0
- sg_mcp_server/_downstream.py +59 -0
- sg_mcp_server/_engine/__init__.py +28 -0
- sg_mcp_server/_engine/_errors.py +85 -0
- sg_mcp_server/_engine/_execution.py +246 -0
- sg_mcp_server/_engine/_relative_dates.py +182 -0
- sg_mcp_server/_engine/_schema.py +652 -0
- sg_mcp_server/_engine/_validation.py +1106 -0
- sg_mcp_server/_errors.py +30 -0
- sg_mcp_server/_json.py +5 -0
- sg_mcp_server/_providers.py +248 -0
- sg_mcp_server/_rendering.py +325 -0
- sg_mcp_server/_schema_adapter.py +156 -0
- sg_mcp_server/_server.py +373 -0
- sg_mcp_server/_surface.py +124 -0
- sg_mcp_server/_tool_contracts.py +324 -0
- sg_mcp_server/_tools.py +177 -0
- sg_mcp_server/py.typed +0 -0
- sg_mcp_server-0.1.0.dist-info/METADATA +174 -0
- sg_mcp_server-0.1.0.dist-info/RECORD +28 -0
- sg_mcp_server-0.1.0.dist-info/WHEEL +4 -0
- sg_mcp_server-0.1.0.dist-info/entry_points.txt +2 -0
- sg_mcp_server-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
"""Provide the read-only MCP server for Flow Production Tracking.
|
|
2
|
+
|
|
3
|
+
Run `sg-mcp-server`, run `python -m sg_mcp_server`, or call `main()` to serve
|
|
4
|
+
the solo-mode tool surface over stdio in the foreground. `main()` parses
|
|
5
|
+
`sys.argv` unless given an argument list, accepts no arguments other than
|
|
6
|
+
`--help` and `--version`, and returns the process exit status. `--help` and
|
|
7
|
+
invalid arguments end in `SystemExit` from argument parsing; `--version`
|
|
8
|
+
returns 0 after printing the version.
|
|
9
|
+
|
|
10
|
+
Set `SG_MCP_SITE_URL`, `SG_MCP_SCRIPT_NAME`, and `SG_MCP_API_KEY` for the site
|
|
11
|
+
connection. Optional `SG_MCP_TIMEZONE`, `SG_MCP_AUDIT_PATH`,
|
|
12
|
+
`SG_MCP_DEFAULT_LIMIT`, `SG_MCP_HARD_LIMIT`, `SG_MCP_SCHEMA_TTL_SECONDS`,
|
|
13
|
+
`SG_MCP_TIMEOUT_SECONDS`, and `SG_MCP_DIAGNOSTICS_PATH` settings control
|
|
14
|
+
rendering, logging, query limits, the SG request timeout, and schema caching.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from sg_mcp_server._cli import main
|
|
18
|
+
|
|
19
|
+
__all__ = ['main']
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
"""Serve the read-only solo-mode MCP tools over stdio.
|
|
2
|
+
|
|
3
|
+
Run `python -m sg_mcp_server` with `SG_MCP_SITE_URL`, `SG_MCP_SCRIPT_NAME`, and
|
|
4
|
+
`SG_MCP_API_KEY` set. The command uses the same optional environment settings
|
|
5
|
+
as `sg-mcp-server` and runs the server in the foreground.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import sys
|
|
9
|
+
|
|
10
|
+
from sg_mcp_server._cli import main
|
|
11
|
+
|
|
12
|
+
if __name__ == '__main__':
|
|
13
|
+
sys.exit(main())
|
sg_mcp_server/_audit.py
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
"""Append-only JSONL security audit log."""
|
|
2
|
+
|
|
3
|
+
import datetime
|
|
4
|
+
import hashlib
|
|
5
|
+
import json
|
|
6
|
+
import sys
|
|
7
|
+
from collections.abc import Mapping
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
from sg_mcp_server._diagnostics import DiagnosticOutcome
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class AuditLog:
|
|
14
|
+
"""Records every tool invocation; payloads are hashed, never stored.
|
|
15
|
+
|
|
16
|
+
A failed audit write warns on standard error, and the read proceeds.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
def __init__(self, path: Path, caller: str) -> None:
|
|
20
|
+
"""Store the target path and caller identity.
|
|
21
|
+
|
|
22
|
+
No filesystem access happens here: directory creation lives in the
|
|
23
|
+
guarded write path, so an unwritable location degrades to the
|
|
24
|
+
documented warn-and-proceed behavior instead of aborting server
|
|
25
|
+
startup.
|
|
26
|
+
"""
|
|
27
|
+
self._path = path
|
|
28
|
+
self._caller = caller
|
|
29
|
+
|
|
30
|
+
def record_tool_call(
|
|
31
|
+
self,
|
|
32
|
+
tool: str,
|
|
33
|
+
input_payload: Mapping[str, object],
|
|
34
|
+
outcome: DiagnosticOutcome,
|
|
35
|
+
duration_ms: float,
|
|
36
|
+
entity_ids: list[int] | None = None,
|
|
37
|
+
) -> None:
|
|
38
|
+
"""Append one audit entry.
|
|
39
|
+
|
|
40
|
+
Args:
|
|
41
|
+
tool: Tool name as listed to clients.
|
|
42
|
+
input_payload: Tool arguments to fingerprint: the default-filled
|
|
43
|
+
arguments of a dispatched call, or the raw arguments of a
|
|
44
|
+
rejected one. Only their SHA-256 is written.
|
|
45
|
+
outcome: One of `ok`, `validation_error`, `query_error`,
|
|
46
|
+
`unexpected_error`, `invalid_arguments`, or `unknown_tool`.
|
|
47
|
+
duration_ms: Wall time of the call.
|
|
48
|
+
entity_ids: Ids of entities the response touched, if known.
|
|
49
|
+
"""
|
|
50
|
+
entry = {
|
|
51
|
+
'ts': datetime.datetime.now(datetime.UTC).isoformat(),
|
|
52
|
+
'caller': self._caller,
|
|
53
|
+
'tool': tool,
|
|
54
|
+
'input_sha256': hashlib.sha256(
|
|
55
|
+
json.dumps(input_payload, sort_keys=True, default=str).encode()
|
|
56
|
+
).hexdigest(),
|
|
57
|
+
'outcome': outcome,
|
|
58
|
+
'duration_ms': round(duration_ms, 3),
|
|
59
|
+
'entity_ids': entity_ids or [],
|
|
60
|
+
}
|
|
61
|
+
try:
|
|
62
|
+
self._path.parent.mkdir(parents=True, exist_ok=True)
|
|
63
|
+
with self._path.open('a', encoding='utf-8') as handle:
|
|
64
|
+
handle.write(json.dumps(entry) + '\n')
|
|
65
|
+
except OSError as error:
|
|
66
|
+
print(f'warning: audit write failed: {error}', file=sys.stderr)
|
sg_mcp_server/_cli.py
ADDED
|
@@ -0,0 +1,276 @@
|
|
|
1
|
+
"""Parse the sg-mcp-server command line and serve MCP over standard streams."""
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
import asyncio
|
|
5
|
+
import importlib.metadata
|
|
6
|
+
import io
|
|
7
|
+
import os
|
|
8
|
+
import re
|
|
9
|
+
import sys
|
|
10
|
+
import threading
|
|
11
|
+
from collections.abc import AsyncIterator, Generator, Sequence
|
|
12
|
+
from contextlib import ExitStack, contextmanager, suppress
|
|
13
|
+
from typing import cast
|
|
14
|
+
|
|
15
|
+
import anyio
|
|
16
|
+
from mcp import stdio_server
|
|
17
|
+
from mcp.os.win32.utilities import rebind_std_handle_to_fd
|
|
18
|
+
from mcp.server import Server
|
|
19
|
+
|
|
20
|
+
from sg_mcp_server import _tools
|
|
21
|
+
from sg_mcp_server._config import Config, ConfigError
|
|
22
|
+
from sg_mcp_server._downstream import is_downstream_closure, silence_descriptor, write_stdout
|
|
23
|
+
from sg_mcp_server._providers import connect
|
|
24
|
+
from sg_mcp_server._server import build_server
|
|
25
|
+
|
|
26
|
+
_CONTROL_CHARACTERS = re.compile(r'[\x00-\x1f\x7f-\x9f]')
|
|
27
|
+
_DESCRIPTION = 'Serve read-only SG tools to an MCP client over standard input and output.'
|
|
28
|
+
_EPILOG = """\
|
|
29
|
+
An MCP client normally starts this command, which serves until the client
|
|
30
|
+
disconnects. Set these environment variables in the client's configuration:
|
|
31
|
+
|
|
32
|
+
SG_MCP_SITE_URL HTTPS site address (required)
|
|
33
|
+
SG_MCP_SCRIPT_NAME script name used for all requests (required)
|
|
34
|
+
SG_MCP_API_KEY API key for that script (required)
|
|
35
|
+
SG_MCP_TIMEZONE IANA time zone for dates (default: UTC)
|
|
36
|
+
SG_MCP_AUDIT_PATH security audit file
|
|
37
|
+
(default: ~/.sg-mcp-server/audit.jsonl)
|
|
38
|
+
SG_MCP_DIAGNOSTICS_PATH latency diagnostics file (default: off)
|
|
39
|
+
SG_MCP_DEFAULT_LIMIT row limit when a query omits one (default: 50)
|
|
40
|
+
SG_MCP_HARD_LIMIT maximum row limit (default: 200)
|
|
41
|
+
SG_MCP_SCHEMA_TTL_SECONDS seconds to reuse the cached schema (default: 900)
|
|
42
|
+
SG_MCP_TIMEOUT_SECONDS seconds to wait on the SG site (default: 60)
|
|
43
|
+
|
|
44
|
+
See "Settings" in the usage guide, docs/usage.md, for details."""
|
|
45
|
+
_METADATA_MISSING = 'installed distribution metadata for sg-mcp-server is unavailable'
|
|
46
|
+
_SG_CLIENT_MODULE = 'shotgun_api3'
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class _ProtocolInputError(Exception):
|
|
50
|
+
"""A protocol input failure that must not be mistaken for output closure."""
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
class _ProtocolInput(anyio.AsyncFile[str]):
|
|
54
|
+
"""Client messages read on daemon threads, so a stop never waits for more input."""
|
|
55
|
+
|
|
56
|
+
def __init__(self) -> None:
|
|
57
|
+
"""Read standard input through a private UTF-8 reader on its descriptor."""
|
|
58
|
+
try:
|
|
59
|
+
descriptor = sys.stdin.fileno()
|
|
60
|
+
except (OSError, ValueError):
|
|
61
|
+
# A replaced stream without a descriptor, such as an in-memory one, is read as it is.
|
|
62
|
+
stream = sys.stdin
|
|
63
|
+
else:
|
|
64
|
+
# closefd=False leaves sys.stdin open when this reader closes, and without a buffer
|
|
65
|
+
# the read that a stop abandons holds no lock.
|
|
66
|
+
raw = io.FileIO(descriptor, closefd=False)
|
|
67
|
+
stream = io.TextIOWrapper(raw, encoding='utf-8', errors='replace')
|
|
68
|
+
super().__init__(stream)
|
|
69
|
+
|
|
70
|
+
async def __aiter__(self) -> AsyncIterator[str]:
|
|
71
|
+
"""Yield each line until the client ends its input."""
|
|
72
|
+
while line := await self.readline():
|
|
73
|
+
yield line
|
|
74
|
+
|
|
75
|
+
async def readline(self) -> str:
|
|
76
|
+
"""Return the next line, or an empty string once the client ends its input."""
|
|
77
|
+
loop = asyncio.get_running_loop()
|
|
78
|
+
line: asyncio.Future[str] = loop.create_future()
|
|
79
|
+
|
|
80
|
+
def settle(result: str | OSError | ValueError) -> None:
|
|
81
|
+
if line.done(): # the session stopped while this read was waiting
|
|
82
|
+
return
|
|
83
|
+
if isinstance(result, str):
|
|
84
|
+
line.set_result(result)
|
|
85
|
+
else:
|
|
86
|
+
line.set_exception(result)
|
|
87
|
+
|
|
88
|
+
def read() -> None:
|
|
89
|
+
result: str | OSError | ValueError
|
|
90
|
+
try:
|
|
91
|
+
result = self.wrapped.readline()
|
|
92
|
+
except (OSError, ValueError) as error:
|
|
93
|
+
result = error
|
|
94
|
+
with suppress(RuntimeError): # the event loop closed after the session stopped
|
|
95
|
+
loop.call_soon_threadsafe(settle, result)
|
|
96
|
+
|
|
97
|
+
threading.Thread(target=read, name='MCP input', daemon=True).start()
|
|
98
|
+
try:
|
|
99
|
+
return await line
|
|
100
|
+
except OSError as error:
|
|
101
|
+
raise _ProtocolInputError('standard input read failed') from error
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def _build_parser() -> argparse.ArgumentParser:
|
|
105
|
+
"""Build the parser for the help and version options."""
|
|
106
|
+
parser = argparse.ArgumentParser(
|
|
107
|
+
prog='sg-mcp-server',
|
|
108
|
+
description=_DESCRIPTION,
|
|
109
|
+
epilog=_EPILOG,
|
|
110
|
+
formatter_class=argparse.RawDescriptionHelpFormatter,
|
|
111
|
+
allow_abbrev=False,
|
|
112
|
+
)
|
|
113
|
+
parser.add_argument('--version', action='store_true', help='show the version and exit')
|
|
114
|
+
return parser
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def _client_disconnected(error: BaseException) -> bool:
|
|
118
|
+
"""Report whether serving stopped only because the client closed standard output."""
|
|
119
|
+
if isinstance(error, BaseExceptionGroup):
|
|
120
|
+
group = cast('BaseExceptionGroup[BaseException]', error)
|
|
121
|
+
return all(_client_disconnected(item) for item in group.exceptions)
|
|
122
|
+
return isinstance(error, OSError) and is_downstream_closure(error)
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
@contextmanager
|
|
126
|
+
def _divert_stdout(protocol_descriptor: int) -> Generator[None, None, None]:
|
|
127
|
+
"""Keep ordinary writes and child output off the protocol until serving stops."""
|
|
128
|
+
try:
|
|
129
|
+
diversion = os.dup(2)
|
|
130
|
+
except OSError:
|
|
131
|
+
diversion = os.open(os.devnull, os.O_WRONLY)
|
|
132
|
+
try:
|
|
133
|
+
try:
|
|
134
|
+
os.dup2(diversion, 1)
|
|
135
|
+
with suppress(OSError):
|
|
136
|
+
rebind_std_handle_to_fd(1)
|
|
137
|
+
yield
|
|
138
|
+
finally:
|
|
139
|
+
os.dup2(protocol_descriptor, 1)
|
|
140
|
+
with suppress(OSError):
|
|
141
|
+
rebind_std_handle_to_fd(1)
|
|
142
|
+
finally:
|
|
143
|
+
os.close(diversion)
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
@contextmanager
|
|
147
|
+
def _protocol_output() -> Generator[anyio.AsyncFile[str], None, None]:
|
|
148
|
+
"""Own the UTF-8 protocol writer without closing the caller's output stream."""
|
|
149
|
+
try:
|
|
150
|
+
descriptor = sys.stdout.fileno()
|
|
151
|
+
except (OSError, ValueError):
|
|
152
|
+
# Replaced streams retain their buffer and do not need descriptor diversion.
|
|
153
|
+
output = io.TextIOWrapper(sys.stdout.buffer, encoding='utf-8')
|
|
154
|
+
try:
|
|
155
|
+
yield anyio.wrap_file(output)
|
|
156
|
+
finally:
|
|
157
|
+
output.detach()
|
|
158
|
+
return
|
|
159
|
+
|
|
160
|
+
# A missing standard stream must not become our protocol descriptor or diversion.
|
|
161
|
+
with ExitStack() as reserved:
|
|
162
|
+
while (duplicate := os.dup(descriptor)) in (0, 1, 2):
|
|
163
|
+
reserved.callback(os.close, duplicate)
|
|
164
|
+
with io.FileIO(duplicate, 'w') as raw:
|
|
165
|
+
output = io.TextIOWrapper(io.BufferedWriter(raw), encoding='utf-8')
|
|
166
|
+
try:
|
|
167
|
+
if descriptor == 1:
|
|
168
|
+
with _divert_stdout(duplicate):
|
|
169
|
+
yield anyio.wrap_file(output)
|
|
170
|
+
else:
|
|
171
|
+
yield anyio.wrap_file(output)
|
|
172
|
+
finally:
|
|
173
|
+
try:
|
|
174
|
+
# Closing the owned buffer also discards bytes from a failed flush. Leaving
|
|
175
|
+
# it for garbage collection retries that flush outside our error handler.
|
|
176
|
+
output.close()
|
|
177
|
+
except OSError as error:
|
|
178
|
+
if not is_downstream_closure(error):
|
|
179
|
+
raise
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def _report_error(message: str) -> None:
|
|
183
|
+
"""Print one terminal-safe error line to standard error."""
|
|
184
|
+
visible = _CONTROL_CHARACTERS.sub(lambda match: f'\\x{ord(match.group()):02x}', message)
|
|
185
|
+
print(f'error: {visible}', file=sys.stderr)
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def _render_version() -> tuple[int, str]:
|
|
189
|
+
"""Render the installed distribution version without other work."""
|
|
190
|
+
try:
|
|
191
|
+
version = importlib.metadata.version('sg-mcp-server')
|
|
192
|
+
except importlib.metadata.PackageNotFoundError:
|
|
193
|
+
_report_error(_METADATA_MISSING)
|
|
194
|
+
return 2, ''
|
|
195
|
+
return 0, f'sg-mcp-server {version}\n'
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
async def _serve_stdio(server: Server) -> None:
|
|
199
|
+
"""Serve one MCP session over the process's standard input and output."""
|
|
200
|
+
with _protocol_output() as stdout:
|
|
201
|
+
async with stdio_server(stdin=_ProtocolInput(), stdout=stdout) as (
|
|
202
|
+
read_stream,
|
|
203
|
+
write_stream,
|
|
204
|
+
):
|
|
205
|
+
await server.run(read_stream, write_stream, server.create_initialization_options())
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
def _serve(server: Server) -> int:
|
|
209
|
+
"""Serve one client, treating a disconnect or an interrupt as a normal stop."""
|
|
210
|
+
try:
|
|
211
|
+
asyncio.run(_serve_stdio(server))
|
|
212
|
+
except KeyboardInterrupt:
|
|
213
|
+
return 0
|
|
214
|
+
except (OSError, BaseExceptionGroup) as error:
|
|
215
|
+
if not _client_disconnected(error):
|
|
216
|
+
raise
|
|
217
|
+
# The transport restored the closed pipe; keep the final flush off it.
|
|
218
|
+
silence_descriptor(sys.stdout)
|
|
219
|
+
return 0
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
def _run(arguments: list[str]) -> tuple[int, str]:
|
|
223
|
+
"""Parse arguments, then render the version or serve the tools."""
|
|
224
|
+
args = _build_parser().parse_args(arguments)
|
|
225
|
+
if args.version:
|
|
226
|
+
return _render_version()
|
|
227
|
+
try:
|
|
228
|
+
config = Config.from_env(os.environ)
|
|
229
|
+
server = build_server(config, connect(config), _tools.surface())
|
|
230
|
+
except ConfigError as error:
|
|
231
|
+
_report_error(str(error))
|
|
232
|
+
return 2, ''
|
|
233
|
+
except importlib.metadata.PackageNotFoundError:
|
|
234
|
+
# A subclass of ModuleNotFoundError, so it is matched first.
|
|
235
|
+
_report_error(_METADATA_MISSING)
|
|
236
|
+
return 2, ''
|
|
237
|
+
except ModuleNotFoundError as error:
|
|
238
|
+
if error.name != _SG_CLIENT_MODULE:
|
|
239
|
+
raise
|
|
240
|
+
_report_error(
|
|
241
|
+
'the SG client is not installed; reinstall sg-mcp-server with the official extra'
|
|
242
|
+
)
|
|
243
|
+
return 2, ''
|
|
244
|
+
return _serve(server), ''
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
def main(argv: Sequence[str] | None = None) -> int:
|
|
248
|
+
"""Run the sg-mcp-server command-line interface.
|
|
249
|
+
|
|
250
|
+
With no arguments, serve the read-only tools to one MCP client over
|
|
251
|
+
standard input and output. `--version` writes the installed version and
|
|
252
|
+
returns 0. `--help` and invalid arguments end in `SystemExit` from argument
|
|
253
|
+
parsing. A client disconnect, the end of input, and an interrupt while
|
|
254
|
+
serving each stop the server with status 0, once calls in progress finish.
|
|
255
|
+
If the reader closes standard output, unwritten output is discarded while
|
|
256
|
+
the status still reports the result.
|
|
257
|
+
|
|
258
|
+
Args:
|
|
259
|
+
argv: Arguments to parse, or `None` to use `sys.argv`.
|
|
260
|
+
|
|
261
|
+
Returns:
|
|
262
|
+
The process exit status.
|
|
263
|
+
"""
|
|
264
|
+
if sys.stdout is None:
|
|
265
|
+
# Started with standard output closed: nothing can be delivered.
|
|
266
|
+
_report_error('standard output is unavailable')
|
|
267
|
+
return 2
|
|
268
|
+
output = ''
|
|
269
|
+
try:
|
|
270
|
+
status, output = _run(sys.argv[1:] if argv is None else list(argv))
|
|
271
|
+
except KeyboardInterrupt:
|
|
272
|
+
status = 130
|
|
273
|
+
finally:
|
|
274
|
+
# Also flushes help or usage text that argparse wrote before raising SystemExit.
|
|
275
|
+
write_stdout(output)
|
|
276
|
+
return status
|
sg_mcp_server/_config.py
ADDED
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
"""Environment-driven configuration for the solo-mode server."""
|
|
2
|
+
|
|
3
|
+
import dataclasses
|
|
4
|
+
import urllib.parse
|
|
5
|
+
from collections.abc import Mapping
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
|
|
8
|
+
|
|
9
|
+
_DEFAULT_AUDIT_PATH = Path.home() / '.sg-mcp-server' / 'audit.jsonl'
|
|
10
|
+
DEFAULT_LIMIT = 50
|
|
11
|
+
HARD_LIMIT = 200
|
|
12
|
+
_SCHEMA_TTL_SECONDS = 900
|
|
13
|
+
_TIMEOUT_SECONDS = 60
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class ConfigError(Exception):
|
|
17
|
+
"""Raised when required configuration is missing or malformed.
|
|
18
|
+
|
|
19
|
+
The message is one lowercase clause that names the setting, suitable for an
|
|
20
|
+
`error:` line, and never repeats credentials from the site URL.
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@dataclasses.dataclass(frozen=True)
|
|
25
|
+
class Config:
|
|
26
|
+
"""Resolved connection, rendering, logging, and query-limit settings.
|
|
27
|
+
|
|
28
|
+
Instances have fixed attributes. The site URL is normalized to an HTTPS
|
|
29
|
+
origin, query limits satisfy `1 <= default_limit <= hard_limit`, and the SG
|
|
30
|
+
request timeout is positive.
|
|
31
|
+
|
|
32
|
+
Attributes:
|
|
33
|
+
site_url: HTTPS site origin without a trailing slash.
|
|
34
|
+
script_name: Flow Production Tracking script identity.
|
|
35
|
+
api_key: API key belonging to the script identity.
|
|
36
|
+
timezone: IANA timezone used for date rendering and relative dates.
|
|
37
|
+
audit_path: Destination for the security audit JSONL log.
|
|
38
|
+
default_limit: Row limit used when a query omits one.
|
|
39
|
+
hard_limit: Maximum permitted query row limit.
|
|
40
|
+
schema_ttl_seconds: Schema-cache lifetime in seconds.
|
|
41
|
+
diagnostics_path: Optional request-diagnostics JSONL destination;
|
|
42
|
+
`None` disables request diagnostics.
|
|
43
|
+
timeout_seconds: Positive timeout for each SG connection attempt and
|
|
44
|
+
data wait.
|
|
45
|
+
"""
|
|
46
|
+
|
|
47
|
+
site_url: str
|
|
48
|
+
script_name: str
|
|
49
|
+
api_key: str
|
|
50
|
+
timezone: str = 'UTC'
|
|
51
|
+
audit_path: Path = _DEFAULT_AUDIT_PATH
|
|
52
|
+
default_limit: int = DEFAULT_LIMIT
|
|
53
|
+
hard_limit: int = HARD_LIMIT
|
|
54
|
+
schema_ttl_seconds: int = _SCHEMA_TTL_SECONDS
|
|
55
|
+
diagnostics_path: Path | None = None
|
|
56
|
+
timeout_seconds: int = _TIMEOUT_SECONDS
|
|
57
|
+
|
|
58
|
+
def __post_init__(self) -> None:
|
|
59
|
+
"""Validate the site origin, timezone, and bounded query limits.
|
|
60
|
+
|
|
61
|
+
Raises:
|
|
62
|
+
ConfigError: If the site URL is not a valid bare HTTPS origin, the
|
|
63
|
+
timezone is unavailable or invalid, or limits do not satisfy `1
|
|
64
|
+
<= default_limit <= hard_limit`, or the timeout is below 1.
|
|
65
|
+
"""
|
|
66
|
+
site_url_message = (
|
|
67
|
+
'the SG_MCP_SITE_URL setting must be an HTTPS origin with a valid host and port, '
|
|
68
|
+
'without userinfo, a non-root path, a query, or a fragment'
|
|
69
|
+
)
|
|
70
|
+
try:
|
|
71
|
+
parsed_site_url = urllib.parse.urlsplit(self.site_url)
|
|
72
|
+
_ = parsed_site_url.port
|
|
73
|
+
except ValueError:
|
|
74
|
+
raise ConfigError(site_url_message) from None
|
|
75
|
+
if (
|
|
76
|
+
parsed_site_url.scheme != 'https'
|
|
77
|
+
or parsed_site_url.hostname is None
|
|
78
|
+
or parsed_site_url.username is not None
|
|
79
|
+
or parsed_site_url.password is not None
|
|
80
|
+
or parsed_site_url.path not in {'', '/'}
|
|
81
|
+
or parsed_site_url.query
|
|
82
|
+
or parsed_site_url.fragment
|
|
83
|
+
or '?' in self.site_url
|
|
84
|
+
or '#' in self.site_url
|
|
85
|
+
or parsed_site_url.netloc.endswith(':')
|
|
86
|
+
):
|
|
87
|
+
raise ConfigError(site_url_message)
|
|
88
|
+
normalized_site_url = urllib.parse.urlunsplit(
|
|
89
|
+
(parsed_site_url.scheme, parsed_site_url.netloc, '', '', '')
|
|
90
|
+
)
|
|
91
|
+
object.__setattr__(self, 'site_url', normalized_site_url)
|
|
92
|
+
|
|
93
|
+
try:
|
|
94
|
+
ZoneInfo(self.timezone)
|
|
95
|
+
except (ValueError, ZoneInfoNotFoundError):
|
|
96
|
+
message = 'the SG_MCP_TIMEZONE setting must name an available IANA timezone'
|
|
97
|
+
raise ConfigError(message) from None
|
|
98
|
+
|
|
99
|
+
if self.default_limit < 1 or self.hard_limit < self.default_limit:
|
|
100
|
+
message = (
|
|
101
|
+
'limits must satisfy 1 <= SG_MCP_DEFAULT_LIMIT <= SG_MCP_HARD_LIMIT '
|
|
102
|
+
f'(SG treats limit 0 as unlimited); got {self.default_limit} '
|
|
103
|
+
f'and {self.hard_limit}'
|
|
104
|
+
)
|
|
105
|
+
raise ConfigError(message)
|
|
106
|
+
|
|
107
|
+
if self.timeout_seconds < 1:
|
|
108
|
+
raise ConfigError(
|
|
109
|
+
'the SG_MCP_TIMEOUT_SECONDS setting must be a positive integer, '
|
|
110
|
+
f'got {self.timeout_seconds}'
|
|
111
|
+
)
|
|
112
|
+
|
|
113
|
+
@classmethod
|
|
114
|
+
def from_env(cls, env: Mapping[str, str]) -> 'Config':
|
|
115
|
+
"""Resolve server settings from the supported environment variables.
|
|
116
|
+
|
|
117
|
+
Args:
|
|
118
|
+
env: Environment mapping, normally `os.environ`. Requires nonblank
|
|
119
|
+
`SG_MCP_SITE_URL`, `SG_MCP_SCRIPT_NAME`, and `SG_MCP_API_KEY`;
|
|
120
|
+
these values are stripped. Optional `SG_MCP_TIMEZONE` and
|
|
121
|
+
`SG_MCP_AUDIT_PATH` override rendering and audit settings;
|
|
122
|
+
the audit path is stripped and must not be blank.
|
|
123
|
+
Optional `SG_MCP_DEFAULT_LIMIT`, `SG_MCP_HARD_LIMIT`,
|
|
124
|
+
`SG_MCP_SCHEMA_TTL_SECONDS`, and `SG_MCP_TIMEOUT_SECONDS` are
|
|
125
|
+
parsed as integers; the timeout must be at least 1. Unset
|
|
126
|
+
optional settings use `Config` defaults. An absent or blank
|
|
127
|
+
`SG_MCP_DIAGNOSTICS_PATH` disables request diagnostics.
|
|
128
|
+
|
|
129
|
+
Returns:
|
|
130
|
+
The resolved configuration with a normalized HTTPS site origin.
|
|
131
|
+
|
|
132
|
+
Raises:
|
|
133
|
+
ConfigError: If a required variable is absent, a numeric override
|
|
134
|
+
does not parse, the audit path is blank, the site URL violates
|
|
135
|
+
the HTTPS-origin rules, the timezone is unavailable or invalid,
|
|
136
|
+
the resolved limit combination is invalid, or the timeout is
|
|
137
|
+
below 1.
|
|
138
|
+
"""
|
|
139
|
+
|
|
140
|
+
def require(name: str) -> str:
|
|
141
|
+
value = env.get(name, '').strip()
|
|
142
|
+
if not value:
|
|
143
|
+
message = f'the {name} setting is required'
|
|
144
|
+
raise ConfigError(message)
|
|
145
|
+
return value
|
|
146
|
+
|
|
147
|
+
def integer(name: str, fallback: int) -> int:
|
|
148
|
+
raw = env.get(name)
|
|
149
|
+
if raw is None:
|
|
150
|
+
return fallback
|
|
151
|
+
try:
|
|
152
|
+
return int(raw)
|
|
153
|
+
except ValueError as error:
|
|
154
|
+
message = f'the {name} setting must be an integer, got {raw!r}'
|
|
155
|
+
raise ConfigError(message) from error
|
|
156
|
+
|
|
157
|
+
def optional_path(name: str) -> Path | None:
|
|
158
|
+
raw = env.get(name, '').strip()
|
|
159
|
+
return Path(raw) if raw else None
|
|
160
|
+
|
|
161
|
+
def audit_path() -> Path:
|
|
162
|
+
raw = env.get('SG_MCP_AUDIT_PATH')
|
|
163
|
+
if raw is None:
|
|
164
|
+
return _DEFAULT_AUDIT_PATH
|
|
165
|
+
value = raw.strip()
|
|
166
|
+
if not value:
|
|
167
|
+
# Path('') is the working directory, where every audit write would fail.
|
|
168
|
+
message = (
|
|
169
|
+
'the SG_MCP_AUDIT_PATH setting must not be blank; unset it to use the default'
|
|
170
|
+
)
|
|
171
|
+
raise ConfigError(message)
|
|
172
|
+
return Path(value)
|
|
173
|
+
|
|
174
|
+
return cls(
|
|
175
|
+
site_url=require('SG_MCP_SITE_URL'),
|
|
176
|
+
script_name=require('SG_MCP_SCRIPT_NAME'),
|
|
177
|
+
api_key=require('SG_MCP_API_KEY'),
|
|
178
|
+
timezone=env.get('SG_MCP_TIMEZONE', 'UTC'),
|
|
179
|
+
audit_path=audit_path(),
|
|
180
|
+
default_limit=integer('SG_MCP_DEFAULT_LIMIT', DEFAULT_LIMIT),
|
|
181
|
+
hard_limit=integer('SG_MCP_HARD_LIMIT', HARD_LIMIT),
|
|
182
|
+
schema_ttl_seconds=integer('SG_MCP_SCHEMA_TTL_SECONDS', _SCHEMA_TTL_SECONDS),
|
|
183
|
+
diagnostics_path=optional_path('SG_MCP_DIAGNOSTICS_PATH'),
|
|
184
|
+
timeout_seconds=integer('SG_MCP_TIMEOUT_SECONDS', _TIMEOUT_SECONDS),
|
|
185
|
+
)
|