echoact 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.
Files changed (80) hide show
  1. echoact/__init__.py +3 -0
  2. echoact/__main__.py +117 -0
  3. echoact/app.py +315 -0
  4. echoact/audio/__init__.py +0 -0
  5. echoact/audio/devices.py +192 -0
  6. echoact/audio/player.py +611 -0
  7. echoact/audio/wav.py +854 -0
  8. echoact/config/__init__.py +0 -0
  9. echoact/config/budget.py +370 -0
  10. echoact/config/settings.py +1244 -0
  11. echoact/db/__init__.py +0 -0
  12. echoact/db/backup.py +2429 -0
  13. echoact/db/migrations.py +434 -0
  14. echoact/db/schema.sql +214 -0
  15. echoact/db/store.py +2062 -0
  16. echoact/diagnostics.py +902 -0
  17. echoact/domain.py +487 -0
  18. echoact/engine/__init__.py +0 -0
  19. echoact/engine/container.py +843 -0
  20. echoact/engine/protocol.py +241 -0
  21. echoact/engine/runtime.py +324 -0
  22. echoact/engine/supervisor.py +961 -0
  23. echoact/engine/worker.py +659 -0
  24. echoact/errors.py +281 -0
  25. echoact/instance.py +172 -0
  26. echoact/jobs/__init__.py +0 -0
  27. echoact/jobs/engine.py +776 -0
  28. echoact/jobs/request.py +300 -0
  29. echoact/mcp/__init__.py +0 -0
  30. echoact/mcp/__main__.py +50 -0
  31. echoact/mcp/client.py +202 -0
  32. echoact/mcp/config.py +112 -0
  33. echoact/mcp/server.py +340 -0
  34. echoact/models/__init__.py +0 -0
  35. echoact/models/catalog.py +273 -0
  36. echoact/models/manifest.py +278 -0
  37. echoact/models/registry.py +1551 -0
  38. echoact/paths.py +93 -0
  39. echoact/policy.py +189 -0
  40. echoact/security/__init__.py +0 -0
  41. echoact/security/credentials.py +930 -0
  42. echoact/security/ratelimit.py +534 -0
  43. echoact/service/__init__.py +20 -0
  44. echoact/service/app.py +182 -0
  45. echoact/service/deps.py +563 -0
  46. echoact/service/errors.py +241 -0
  47. echoact/service/routes.py +1125 -0
  48. echoact/service/schemas.py +509 -0
  49. echoact/service/server.py +270 -0
  50. echoact/text/__init__.py +0 -0
  51. echoact/text/language.py +44 -0
  52. echoact/text/loader.py +577 -0
  53. echoact/text/normalize.py +924 -0
  54. echoact/text/segment.py +499 -0
  55. echoact/text/sniff.py +1202 -0
  56. echoact/ui/__init__.py +0 -0
  57. echoact/ui/bridge.py +50 -0
  58. echoact/ui/controls.py +360 -0
  59. echoact/ui/credential_dialog.py +131 -0
  60. echoact/ui/fonts.py +94 -0
  61. echoact/ui/i18n.py +260 -0
  62. echoact/ui/icons.py +440 -0
  63. echoact/ui/library.py +1642 -0
  64. echoact/ui/licence.py +162 -0
  65. echoact/ui/main_window.py +1202 -0
  66. echoact/ui/mcp_setup.py +494 -0
  67. echoact/ui/models_view.py +1142 -0
  68. echoact/ui/notifications.py +202 -0
  69. echoact/ui/reading.py +494 -0
  70. echoact/ui/settings_view.py +2258 -0
  71. echoact/ui/status_view.py +1193 -0
  72. echoact/ui/theme.py +579 -0
  73. echoact/util/__init__.py +0 -0
  74. echoact/util/ids.py +62 -0
  75. echoact/util/logging.py +127 -0
  76. echoact-0.1.0.dist-info/METADATA +162 -0
  77. echoact-0.1.0.dist-info/RECORD +80 -0
  78. echoact-0.1.0.dist-info/WHEEL +4 -0
  79. echoact-0.1.0.dist-info/entry_points.txt +3 -0
  80. echoact-0.1.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,300 @@
1
+ """What a generation request is, and what makes one valid.
2
+
3
+ Every entry path validates here. F-37 requires the GUI, REST, and MCP to
4
+ apply the same limits and the same support policy, and the cheapest way to
5
+ guarantee that is to give them one function rather than three that agree
6
+ today.
7
+
8
+ Nothing in this module touches the engine, the database, or the disk: it
9
+ answers "would this be accepted?", which is exactly what F-88's pre-flight
10
+ estimate needs and what the job engine needs before it takes the slot.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ from dataclasses import dataclass, field
16
+ from typing import Any
17
+
18
+ from ..domain import (
19
+ Gender,
20
+ JobKind,
21
+ Language,
22
+ RequestPath,
23
+ RetentionMode,
24
+ Segment,
25
+ SpeakingStyle,
26
+ VoiceSettings,
27
+ )
28
+ from ..errors import Code, EchoActError
29
+ from ..models.manifest import Manifest, ModelEntry
30
+ from ..policy import (
31
+ BOUNDED_WAIT_CEILING_S,
32
+ ESTIMATE_REAL_TIME_FACTOR,
33
+ MAX_INPUT_CODEPOINTS,
34
+ )
35
+ from ..text.segment import estimate_seconds, segment_text
36
+ from ..util import ids
37
+
38
+
39
+ @dataclass(frozen=True, slots=True)
40
+ class JobRequest:
41
+ """One accepted-or-refused generation request.
42
+
43
+ ``idempotency_key`` is not optional. A.3 records why: with a single
44
+ generation slot, a client that retries after a slow or dropped response
45
+ would otherwise queue a second rendering of the same text behind the
46
+ first, and automated clients retry as a matter of course. The GUI mints
47
+ one per press for the same reason -- a double-click is a retry too.
48
+ """
49
+
50
+ text: str
51
+ settings: VoiceSettings
52
+ request_path: RequestPath
53
+ owner_client_id: str
54
+ idempotency_key: str
55
+ kind: JobKind = JobKind.SPEECH
56
+ retention: RetentionMode = RetentionMode.ONE_OFF
57
+ client_label: str | None = None
58
+ #: F-88. ``None`` means answer as soon as the job is accepted.
59
+ wait_s: float | None = None
60
+
61
+ def digest(self) -> str:
62
+ """F-49's request-match discriminator.
63
+
64
+ Hashed rather than stored: 4.2 keeps "only the information needed
65
+ for duplicate prevention ... with no source text", so the record
66
+ must be able to tell "same content" from "different content"
67
+ without holding the content.
68
+ """
69
+ from ..db.store import request_match_digest
70
+
71
+ return request_match_digest(
72
+ self.kind.value,
73
+ self.text,
74
+ repr(sorted(self.settings.to_dict().items())),
75
+ self.retention.value,
76
+ )
77
+
78
+
79
+ @dataclass(frozen=True, slots=True)
80
+ class Estimate:
81
+ """F-88's pre-flight answer.
82
+
83
+ Explicitly approximate, and derived from the throughput A.5 measured
84
+ rather than from a trial synthesis: the requirement says an estimate
85
+ must not create a job or load a model.
86
+ """
87
+
88
+ valid: bool
89
+ codepoints: int
90
+ segment_count: int
91
+ audio_ms: int
92
+ synthesis_ms: int
93
+ first_audio_ms: int
94
+ slot_free: bool
95
+ model_ready: bool
96
+ approximate: bool = True
97
+ problems: tuple[dict[str, Any], ...] = field(default_factory=tuple)
98
+
99
+ def to_dict(self) -> dict[str, Any]:
100
+ return {
101
+ "valid": self.valid,
102
+ "codepoints": self.codepoints,
103
+ "segment_count": self.segment_count,
104
+ "audio_ms": self.audio_ms,
105
+ "synthesis_ms": self.synthesis_ms,
106
+ "first_audio_ms": self.first_audio_ms,
107
+ "slot_free": self.slot_free,
108
+ "model_ready": self.model_ready,
109
+ "approximate": self.approximate,
110
+ "problems": list(self.problems),
111
+ }
112
+
113
+
114
+ def validate_text(text: str) -> str:
115
+ """F-03. Returns the text unchanged when it is acceptable.
116
+
117
+ Whitespace-only input is refused rather than trimmed: A.5 found the
118
+ engine raising on it, so this is the guard the requirement says it is,
119
+ and trimming would quietly change the offsets every segment range is
120
+ measured against.
121
+ """
122
+ if not text or not text.strip():
123
+ raise EchoActError(Code.INPUT_EMPTY)
124
+ if len(text) > MAX_INPUT_CODEPOINTS:
125
+ raise EchoActError(
126
+ Code.INPUT_TOO_LONG,
127
+ detail={"codepoints": len(text), "limit": MAX_INPUT_CODEPOINTS},
128
+ )
129
+ return text
130
+
131
+
132
+ def validate_settings(settings: VoiceSettings, manifest: Manifest) -> ModelEntry:
133
+ """F-04 through F-08.
134
+
135
+ Nothing is substituted. F-54 is explicit that unknown models and
136
+ options are not arbitrarily replaced, so every mismatch is an error
137
+ naming what was wrong rather than a silent correction.
138
+ """
139
+ settings.validate() # tempo range, F-07
140
+
141
+ if not manifest.has(settings.model_id):
142
+ raise EchoActError(
143
+ Code.MODEL_UNKNOWN,
144
+ detail={"model_id": settings.model_id, "known": list(manifest.model_ids)},
145
+ )
146
+ entry = manifest.get(settings.model_id)
147
+
148
+ if settings.language is not Language.AUTO and not entry.supports_language(settings.language):
149
+ raise EchoActError(
150
+ Code.LANGUAGE_UNKNOWN,
151
+ detail={
152
+ "language": settings.language.value,
153
+ "supported": [lang.value for lang in entry.languages],
154
+ },
155
+ )
156
+ if not isinstance(settings.style, SpeakingStyle):
157
+ raise EchoActError(Code.STYLE_UNKNOWN, detail={"style": str(settings.style)})
158
+
159
+ voice = next((v for v in entry.voices if v.voice_id == settings.voice_id), None)
160
+ if voice is None:
161
+ raise EchoActError(
162
+ Code.VOICE_UNKNOWN,
163
+ detail={
164
+ "voice_id": settings.voice_id,
165
+ "available": [v.voice_id for v in entry.voices],
166
+ },
167
+ )
168
+ if voice.gender is not settings.gender:
169
+ # F-06 selects a gender and then a voice within it; a request whose
170
+ # two halves disagree is a mistake to report, not one to resolve by
171
+ # picking a side.
172
+ raise EchoActError(
173
+ Code.VOICE_GENDER_MISMATCH,
174
+ detail={"voice_id": voice.voice_id, "voice_gender": voice.gender.value},
175
+ )
176
+ return entry
177
+
178
+
179
+ def validate_request(request: JobRequest, manifest: Manifest) -> ModelEntry:
180
+ """Everything a request must satisfy before it can take the slot."""
181
+ if request.kind is None:
182
+ raise EchoActError(Code.JOB_KIND_MISSING)
183
+ if not isinstance(request.kind, JobKind):
184
+ raise EchoActError(Code.JOB_KIND_UNKNOWN, detail={"kind": str(request.kind)})
185
+ if not request.idempotency_key or not request.idempotency_key.strip():
186
+ raise EchoActError(Code.IDEMPOTENCY_KEY_MISSING)
187
+ if request.wait_s is not None and request.wait_s < 0:
188
+ raise EchoActError(
189
+ Code.INTERNAL, "A wait cannot be negative.", detail={"wait_s": request.wait_s}
190
+ )
191
+ validate_text(request.text)
192
+ return validate_settings(request.settings, manifest)
193
+
194
+
195
+ def clamp_wait(wait_s: float | None, *, ceiling_s: float = BOUNDED_WAIT_CEILING_S) -> float:
196
+ """4.1's bounded wait.
197
+
198
+ The service never waits longer than the caller asked *and* never longer
199
+ than the owner's ceiling, so this takes the smaller of the two rather
200
+ than substituting the default for an out-of-range value.
201
+ """
202
+ if wait_s is None:
203
+ return 0.0
204
+ return max(0.0, min(float(wait_s), ceiling_s))
205
+
206
+
207
+ def plan_segments(text: str, settings: VoiceSettings) -> list[Segment]:
208
+ """F-81, with fresh identifiers so the plan can be stored as-is."""
209
+ segments = segment_text(text, settings)
210
+ for seg in segments:
211
+ if not seg.segment_id:
212
+ seg.segment_id = ids.segment_id()
213
+ return segments
214
+
215
+
216
+ def estimate(
217
+ text: str,
218
+ settings: VoiceSettings,
219
+ manifest: Manifest,
220
+ *,
221
+ slot_free: bool,
222
+ model_ready: bool,
223
+ real_time_factor: float = ESTIMATE_REAL_TIME_FACTOR,
224
+ ) -> Estimate:
225
+ """F-88's pre-flight estimate.
226
+
227
+ Answers even when the request is invalid: a caller asking "would this
228
+ work?" is best served by the reasons it would not, all of them, rather
229
+ than by the first exception. That is also why validation problems are
230
+ collected instead of raised.
231
+ """
232
+ problems: list[dict[str, Any]] = []
233
+ for check in (
234
+ lambda: validate_text(text),
235
+ lambda: validate_settings(settings, manifest),
236
+ ):
237
+ try:
238
+ check()
239
+ except EchoActError as exc:
240
+ problems.append({"code": exc.code.value, "message": exc.message, **exc.detail})
241
+
242
+ if problems:
243
+ return Estimate(
244
+ valid=False,
245
+ codepoints=len(text),
246
+ segment_count=0,
247
+ audio_ms=0,
248
+ synthesis_ms=0,
249
+ first_audio_ms=0,
250
+ slot_free=slot_free,
251
+ model_ready=model_ready,
252
+ problems=tuple(problems),
253
+ )
254
+
255
+ segments = segment_text(text, settings)
256
+ tempo = settings.tempo
257
+ audio_s = 0.0
258
+ first_s = 0.0
259
+ for i, seg in enumerate(segments):
260
+ seconds = estimate_seconds(seg.spoken_text, seg.language, tempo)
261
+ seconds += seg.trailing_silence_ms / 1000.0
262
+ audio_s += seconds
263
+ if i == 0:
264
+ first_s = seconds
265
+
266
+ synthesis_s = audio_s * real_time_factor
267
+ # Time to the first sound, which is what F-12 makes the interesting
268
+ # number: the first segment has to be synthesised before anything plays,
269
+ # and a model that is not resident has to be loaded first. The load is
270
+ # not included here -- F-88 says an estimate never waits on model
271
+ # preparation, so quoting it would describe a different operation.
272
+ first_audio_s = first_s * real_time_factor
273
+
274
+ return Estimate(
275
+ valid=True,
276
+ codepoints=len(text),
277
+ segment_count=len(segments),
278
+ audio_ms=int(round(audio_s * 1000)),
279
+ synthesis_ms=int(round(synthesis_s * 1000)),
280
+ first_audio_ms=int(round(first_audio_s * 1000)),
281
+ slot_free=slot_free,
282
+ model_ready=model_ready,
283
+ )
284
+
285
+
286
+ def resolve_gender_voice(entry: ModelEntry, gender: Gender, voice_id: str | None) -> str:
287
+ """The first voice of a gender, for a caller that named only a gender.
288
+
289
+ Used by the GUI when the user switches gender, never to paper over an
290
+ invalid request: F-54 forbids substituting an unknown option, and this
291
+ is only reached when no voice was named at all.
292
+ """
293
+ voices = entry.voices_for(gender)
294
+ if not voices:
295
+ raise EchoActError(Code.VOICE_UNKNOWN, detail={"gender": gender.value})
296
+ if voice_id:
297
+ for v in voices:
298
+ if v.voice_id == voice_id:
299
+ return v.voice_id
300
+ return voices[0].voice_id
File without changes
@@ -0,0 +1,50 @@
1
+ """Entry point for the MCP server process.
2
+
3
+ Started by the MCP client, per F-58. It never starts EchoAct -- F-52 is
4
+ explicit -- and it exits with a message rather than waiting when the app
5
+ is not there to talk to.
6
+
7
+ Diagnostics go to stderr, which the revision this implements requires:
8
+ its logging feature is deprecated and §2.11 says so.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import sys
14
+
15
+ from ..errors import EchoActError
16
+ from .client import RestClient
17
+ from .config import from_environment
18
+ from .server import build, preflight
19
+
20
+
21
+ def main(argv: list[str] | None = None) -> int:
22
+ args = list(sys.argv[1:] if argv is None else argv)
23
+ try:
24
+ connection = from_environment(args)
25
+ except EchoActError as exc:
26
+ print(f"[echoact-mcp] {exc.code.value}: {exc.message}", file=sys.stderr, flush=True)
27
+ return 2
28
+
29
+ rest = RestClient(connection)
30
+ try:
31
+ preflight(rest)
32
+ except EchoActError as exc:
33
+ # Distinct and non-retrying, per F-52. Exiting rather than
34
+ # serving is the honest answer: every tool would fail the same
35
+ # way, and a client that saw tools listed would reasonably expect
36
+ # them to work.
37
+ print(f"[echoact-mcp] {exc.code.value}: {exc.message}", file=sys.stderr, flush=True)
38
+ rest.close()
39
+ return 3
40
+
41
+ server = build(connection, client=rest)
42
+ try:
43
+ server.run(transport="stdio", show_banner=False)
44
+ finally:
45
+ rest.close()
46
+ return 0
47
+
48
+
49
+ if __name__ == "__main__":
50
+ raise SystemExit(main())
echoact/mcp/client.py ADDED
@@ -0,0 +1,202 @@
1
+ """The MCP server's half of the loopback conversation.
2
+
3
+ F-58 makes the MCP server a client of the local REST service and nothing
4
+ else: no model, no database, no job state. So this is the whole of its
5
+ machinery, and its one interesting job is translating the ways the
6
+ conversation can fail into the distinct, non-retrying errors F-52 names:
7
+
8
+ * EchoAct is not running -- the connection is refused. Distinct because
9
+ the remedy is to start EchoAct, and because F-52 forbids this process
10
+ from starting it.
11
+ * REST is turned off -- there is no listener, or one that says so.
12
+ * MCP is disabled in EchoAct -- the service answers, but the owner has
13
+ not enabled this integration.
14
+ * The credential has expired or been revoked -- authentication fails for
15
+ a reason the user can act on, which is not the same as being wrong.
16
+
17
+ Every other failure keeps the service's own error code, because N-24
18
+ makes MCP a projection of REST and a behaviour difference between them a
19
+ defect. Re-deriving a code here would be exactly that difference.
20
+ """
21
+
22
+ from __future__ import annotations
23
+
24
+ from typing import Any
25
+
26
+ import httpx
27
+
28
+ from ..errors import Code, EchoActError, is_retryable
29
+ from .config import Connection
30
+
31
+ #: Codes that mean "this process cannot work until something changes",
32
+ #: as opposed to "try again". F-52 wants these reported as non-retrying.
33
+ TERMINAL_CODES = frozenset(
34
+ {
35
+ Code.APP_NOT_RUNNING,
36
+ Code.SERVICE_OFF,
37
+ Code.MCP_DISABLED,
38
+ Code.CREDENTIAL_EXPIRED,
39
+ Code.CREDENTIAL_REVOKED,
40
+ Code.UNAUTHENTICATED,
41
+ Code.HOST_NOT_ALLOWED,
42
+ }
43
+ )
44
+
45
+
46
+ class RestClient:
47
+ """A thin, synchronous client. Synchronous on purpose: this process
48
+ serves one stdio conversation and has nothing to overlap with."""
49
+
50
+ def __init__(self, connection: Connection, transport: httpx.BaseTransport | None = None):
51
+ self.connection = connection
52
+ self._http = httpx.Client(
53
+ base_url=connection.api_root,
54
+ headers=connection.headers(),
55
+ timeout=connection.timeout_s,
56
+ transport=transport,
57
+ follow_redirects=False,
58
+ )
59
+
60
+ def close(self) -> None:
61
+ self._http.close()
62
+
63
+ def __enter__(self) -> RestClient:
64
+ return self
65
+
66
+ def __exit__(self, *exc: object) -> None:
67
+ self.close()
68
+
69
+ # ------------------------------------------------------------------
70
+
71
+ def get(self, path: str, **params: Any) -> dict[str, Any]:
72
+ return self._json("GET", path, params={k: v for k, v in params.items() if v is not None})
73
+
74
+ def post(self, path: str, body: dict[str, Any] | None = None) -> dict[str, Any]:
75
+ return self._json("POST", path, json=body)
76
+
77
+ def get_bytes(self, path: str) -> tuple[bytes, str]:
78
+ """Fetch a result's audio. Returns (bytes, media type).
79
+
80
+ The MCP server resolves a resource URI on the client's behalf
81
+ (F-60), so the bytes pass through this process rather than the
82
+ client being handed an address -- which is what lets §2.11 keep
83
+ local paths and tokenised URLs out of the protocol entirely.
84
+ """
85
+ response = self._send("GET", path, headers={"Accept": "audio/wav"})
86
+ return response.content, response.headers.get("content-type", "audio/wav")
87
+
88
+ # ------------------------------------------------------------------
89
+
90
+ def _json(self, method: str, path: str, **kw: Any) -> dict[str, Any]:
91
+ response = self._send(method, path, **kw)
92
+ if not response.content:
93
+ return {}
94
+ try:
95
+ body = response.json()
96
+ except ValueError as exc:
97
+ raise EchoActError(
98
+ Code.INTERNAL, "EchoAct returned something that is not JSON.", cause=exc
99
+ ) from exc
100
+ return body if isinstance(body, dict) else {"result": body}
101
+
102
+ def _send(self, method: str, path: str, **kw: Any) -> httpx.Response:
103
+ try:
104
+ response = self._http.request(method, path, **kw)
105
+ except httpx.ConnectError as exc:
106
+ # The refused connection is the signal, and it is the one case
107
+ # this process must never try to fix by starting anything.
108
+ raise EchoActError(
109
+ Code.APP_NOT_RUNNING,
110
+ "EchoAct is not running, or its local service is turned off. "
111
+ "Start EchoAct and check Settings; this server cannot start it.",
112
+ cause=exc,
113
+ ) from exc
114
+ except httpx.TimeoutException as exc:
115
+ raise EchoActError(
116
+ Code.APP_NOT_RUNNING,
117
+ "EchoAct did not answer on the loopback address.",
118
+ cause=exc,
119
+ ) from exc
120
+ except httpx.HTTPError as exc:
121
+ raise EchoActError(
122
+ Code.INTERNAL, f"The local request failed ({type(exc).__name__}).", cause=exc
123
+ ) from exc
124
+
125
+ if response.status_code >= 400:
126
+ raise self._error(response)
127
+ return response
128
+
129
+ @staticmethod
130
+ def _error(response: httpx.Response) -> EchoActError:
131
+ """Carry the service's own code across, rather than inventing one."""
132
+ payload: dict[str, Any] = {}
133
+ try:
134
+ parsed = response.json()
135
+ if isinstance(parsed, dict):
136
+ payload = parsed
137
+ except ValueError:
138
+ pass
139
+
140
+ raw = str(payload.get("code", "")).strip()
141
+ try:
142
+ code = Code(raw)
143
+ except ValueError:
144
+ code = _code_for_status(response.status_code)
145
+
146
+ message = str(payload.get("message", "")) or None
147
+ retry_after = payload.get("retry_after_s")
148
+ if retry_after is None and "Retry-After" in response.headers:
149
+ try:
150
+ retry_after = float(response.headers["Retry-After"])
151
+ except ValueError:
152
+ retry_after = None
153
+ detail = {k: v for k, v in payload.items() if k not in {"code", "message", "retry_after_s"}}
154
+ return EchoActError(
155
+ code,
156
+ message,
157
+ detail=detail or None,
158
+ retry_after_s=float(retry_after) if retry_after is not None else None,
159
+ )
160
+
161
+
162
+ def _code_for_status(status: int) -> Code:
163
+ """Only reached when the answer carried no code of its own.
164
+
165
+ F-57 requires every EchoAct error to carry one, so this is the case
166
+ where something *else* is listening on the port. Statuses whose
167
+ meaning is unambiguous are mapped; the rest stay ``INTERNAL`` rather
168
+ than being guessed into a precise-looking code that would then be
169
+ wrong -- 422 in particular means "invalid voice settings" in this
170
+ contract and nothing at all in a stranger's.
171
+ """
172
+ return {
173
+ 401: Code.UNAUTHENTICATED,
174
+ 403: Code.FORBIDDEN,
175
+ 404: Code.NOT_FOUND,
176
+ 410: Code.RESULT_EXPIRED,
177
+ 413: Code.PAYLOAD_TOO_LARGE,
178
+ 429: Code.RATE_LIMITED,
179
+ 503: Code.SERVICE_OFF,
180
+ 507: Code.STORAGE_FULL,
181
+ }.get(status, Code.INTERNAL)
182
+
183
+
184
+ def describe(error: EchoActError) -> dict[str, Any]:
185
+ """What a tool returns when the call failed.
186
+
187
+ Keeps the service's code and adds the one thing an automated caller
188
+ most needs and F-57 already decided: whether trying again can work.
189
+ """
190
+ return {
191
+ "error": {
192
+ "code": error.code.value,
193
+ "message": error.message,
194
+ "retryable": error.retryable and error.code not in TERMINAL_CODES,
195
+ **({"retry_after_s": error.retry_after_s} if error.retry_after_s else {}),
196
+ **({"detail": error.detail} if error.detail else {}),
197
+ }
198
+ }
199
+
200
+
201
+ def is_terminal(error: EchoActError) -> bool:
202
+ return error.code in TERMINAL_CODES or not is_retryable(error.code)
echoact/mcp/config.py ADDED
@@ -0,0 +1,112 @@
1
+ """How the MCP server is told where EchoAct is and who it is.
2
+
3
+ F-58 puts the connection details in the MCP *client's* configuration and
4
+ has the app hand them to the user, so this process reads them from its
5
+ environment and its arguments and holds nothing of its own. There is no
6
+ config file to find, no discovery, and no fallback that would let it work
7
+ without a credential -- N-31's fail-closed rule reaches here too.
8
+
9
+ The credential is read from the environment rather than from a command
10
+ line by preference: an argument is visible in the process list to every
11
+ program on the machine, and N-17 keeps tokens out of places they can be
12
+ read from. A ``--token`` argument is still accepted, because some MCP
13
+ client configurations cannot set an environment variable, and the
14
+ docstring is the place to say which is worse.
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ import os
20
+ from dataclasses import dataclass
21
+
22
+ from ..errors import Code, EchoActError
23
+ from ..policy import REST_HOST, REST_PORT_DEFAULT
24
+
25
+ ENV_URL = "ECHOACT_URL"
26
+ ENV_TOKEN = "ECHOACT_TOKEN"
27
+ ENV_TIMEOUT = "ECHOACT_TIMEOUT_S"
28
+
29
+ DEFAULT_TIMEOUT_S = 30.0
30
+
31
+
32
+ @dataclass(frozen=True, slots=True)
33
+ class Connection:
34
+ """Everything this process needs, and nothing it could invent."""
35
+
36
+ base_url: str
37
+ token: str
38
+ timeout_s: float = DEFAULT_TIMEOUT_S
39
+
40
+ @property
41
+ def api_root(self) -> str:
42
+ return f"{self.base_url.rstrip('/')}/api/v1"
43
+
44
+ def headers(self) -> dict[str, str]:
45
+ # The Host header is what the service validates before it looks at
46
+ # the credential (N-17), so it has to be the loopback name the
47
+ # service expects rather than whatever httpx would infer.
48
+ return {
49
+ "Authorization": f"Bearer {self.token}",
50
+ "Accept": "application/json",
51
+ }
52
+
53
+
54
+ def default_url() -> str:
55
+ return f"http://{REST_HOST}:{REST_PORT_DEFAULT}"
56
+
57
+
58
+ def from_environment(argv: list[str] | None = None) -> Connection:
59
+ """Read the connection, or refuse to start.
60
+
61
+ A missing credential is a configuration error the user must fix, not a
62
+ condition to retry: F-52 wants distinct, non-retrying errors for the
63
+ ways this process can be unusable, and starting anonymously is not an
64
+ option N-31 leaves open.
65
+ """
66
+ args = list(argv or [])
67
+ token = os.environ.get(ENV_TOKEN, "")
68
+ url = os.environ.get(ENV_URL, "") or default_url()
69
+
70
+ for i, arg in enumerate(args):
71
+ if arg == "--token" and i + 1 < len(args):
72
+ token = args[i + 1]
73
+ elif arg.startswith("--token="):
74
+ token = arg.split("=", 1)[1]
75
+ elif arg == "--url" and i + 1 < len(args):
76
+ url = args[i + 1]
77
+ elif arg.startswith("--url="):
78
+ url = arg.split("=", 1)[1]
79
+
80
+ if not token:
81
+ raise EchoActError(
82
+ Code.UNAUTHENTICATED,
83
+ f"No EchoAct credential. Set {ENV_TOKEN} in this server's configuration; "
84
+ "EchoAct shows the value once, under Settings.",
85
+ )
86
+ if not _is_loopback(url):
87
+ # N-17 and Section 7: EchoAct is never reachable off this machine,
88
+ # so a URL that points elsewhere is a misconfiguration to report
89
+ # rather than an address to try.
90
+ raise EchoActError(
91
+ Code.HOST_NOT_ALLOWED,
92
+ f"EchoAct only listens on the loopback address; {url!r} is not one.",
93
+ )
94
+
95
+ timeout = DEFAULT_TIMEOUT_S
96
+ raw = os.environ.get(ENV_TIMEOUT)
97
+ if raw:
98
+ try:
99
+ timeout = max(1.0, float(raw))
100
+ except ValueError:
101
+ pass
102
+ return Connection(base_url=url, token=token, timeout_s=timeout)
103
+
104
+
105
+ def _is_loopback(url: str) -> bool:
106
+ from urllib.parse import urlparse
107
+
108
+ try:
109
+ host = (urlparse(url).hostname or "").lower()
110
+ except ValueError:
111
+ return False
112
+ return host in {"127.0.0.1", "localhost", "::1"}