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.
@@ -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())
@@ -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
@@ -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
+ )