1733 lines
82 KiB
Diff
1733 lines
82 KiB
Diff
# Review package: 24ceff3..HEAD
|
||||
|
|
|
|||
|
|
## Commits
|
|||
|
|
30bd535 docs(readme): CORS-Konfiguration und MCP-Endpoint mit curl-Handshake dokumentiert
|
|||
|
|
150c374 test: Prozessgrenzen-Tests für CORS-Tür und MCP-Handshake
|
|||
|
|
a577597 feat(api): MCP-Endpoint POST /mcp — schlankes eigenes JSON-RPC-Subset mit speak-Tool
|
|||
|
|
156c20d feat(api): CORS-Tür für POST /speak — OPTIONS-Preflight und ACAO aus Config
|
|||
|
|
cef6e6b feat(config): CORS-Erlaubnisliste server.allowed_origins (TALK_ALLOWED_ORIGINS)
|
|||
|
|
|
|||
|
|
## Files changed
|
|||
|
|
README.md | 63 ++++++-
|
|||
|
|
cmd/talkd/main.go | 13 +-
|
|||
|
|
internal/api/cors.go | 78 +++++++++
|
|||
|
|
internal/api/doc.go | 11 +-
|
|||
|
|
internal/api/mcp.go | 371 +++++++++++++++++++++++++++++++++++++++++
|
|||
|
|
internal/api/server.go | 96 ++++++++---
|
|||
|
|
internal/api/types.go | 11 +-
|
|||
|
|
internal/config/config.go | 44 +++++
|
|||
|
|
internal/config/config_test.go | 103 ++++++++++++
|
|||
|
|
test/cors_test.go | 195 ++++++++++++++++++++++
|
|||
|
|
test/harness_test.go | 72 +++++++-
|
|||
|
|
test/mcp_test.go | 300 +++++++++++++++++++++++++++++++++
|
|||
|
|
12 files changed, 1318 insertions(+), 39 deletions(-)
|
|||
|
|
|
|||
|
|
## Diff
|
|||
|
|
README.md --- 1/3 --- Text
|
|||
|
|
33 `talkd`, siehe unten), `mute`, `unm 33 `talkd`, siehe unten), `mute`, `unm
|
|||
|
|
.. ute`, `status`, `cancel`. Die übrig .. ute`, `status`, `cancel`. Die übrig
|
|||
|
|
.. en .. en
|
|||
|
|
34 Befehle sind Stubs und melden `not 34 Befehle sind Stubs und melden `not
|
|||
|
|
.. implemented yet` (Exit-Code ≠ 0). .. implemented yet` (Exit-Code ≠ 0).
|
|||
|
|
35 35
|
|||
|
|
36 ## Daemon talkd (Task 04 + 05) 36 ## Daemon talkd (Task 04–06)
|
|||
|
|
37 37
|
|||
|
|
38 `talkd` besitzt die Ansage-Queue un 38 `talkd` besitzt die Ansage-Queue un
|
|||
|
|
.. d spricht seriell ab — genau eine A .. d spricht seriell ab — genau eine A
|
|||
|
|
.. nsage .. nsage
|
|||
|
|
39 gleichzeitig, Reihenfolge `now` > ` 39 gleichzeitig, Reihenfolge `now` > `
|
|||
|
|
.. high` > `normal` > `low` (FIFO je .. high` > `normal` > `low` (FIFO je
|
|||
|
|
|
|||
|
|
README.md --- 2/3 --- Text
|
|||
|
|
43 | Endpunkt | Wirkung | 43 | Endpunkt | Wirkung |
|
|||
|
|
44 | --- | --- | 44 | --- | --- |
|
|||
|
|
45 | `POST /speak` | `{text, priority? 45 | `POST /speak` | `{text, priority?
|
|||
|
|
.. , provider?, voice?, lang?, now?}` .. , provider?, voice?, lang?, now?}`
|
|||
|
|
.. → `202 {"id":"…"}`; bei Mute: `202 .. → `202 {"id":"…"}`; bei Mute: `202
|
|||
|
|
.. {"id":"","muted":true}`; bei Gesetz .. {"id":"","muted":true}`; bei Gesetz
|
|||
|
|
.. -Verwurf: `202 {"id":"","discarded" .. -Verwurf: `202 {"id":"","discarded"
|
|||
|
|
.. :"rate_limit"\|"quiet_hours"}` (+ ` .. :"rate_limit"\|"quiet_hours"}` (+ `
|
|||
|
|
.. "toast":true` im Toast-Modus) — ver .. "toast":true` im Toast-Modus) — ver
|
|||
|
|
.. worfen, dokumentiertes Verhalten | .. worfen, dokumentiertes Verhalten |
|
|||
|
|
46 | `GET /status` | `{"version", "que 46 | `OPTIONS /speak` | CORS-Preflight
|
|||
|
|
.. ue", "queue_ids", "current", "muted .. → `204` mit Freigabe-Headern, wenn
|
|||
|
|
.. ", "provider", "rate_limit": {"inte .. die `Origin` in `server.allowed_or
|
|||
|
|
.. rval", "allow_high_burst", "next_sl .. igins` steht (siehe unten); sonst `
|
|||
|
|
.. ot"}, "quiet_hours": {"window", "ac .. 204` ohne CORS-Header |
|
|||
|
|
.. tive", "mode", "until"}}` | ..
|
|||
|
|
.. 47 | `GET /status` | `{"version", "que
|
|||
|
|
.. .. ue", "queue_ids", "current", "muted
|
|||
|
|
.. .. ", "provider", "rate_limit": {"inte
|
|||
|
|
.. .. rval", "allow_high_burst", "next_sl
|
|||
|
|
.. .. ot"}, "quiet_hours": {"window", "ac
|
|||
|
|
.. .. tive", "mode", "until"}, "mcp": tru
|
|||
|
|
.. .. e, "cors": [origins]}` |
|
|||
|
|
47 | `POST /cancel` | bricht die laufe 48 | `POST /cancel` | bricht die laufe
|
|||
|
|
.. nde Ansage ab + leert die Queue → ` .. nde Ansage ab + leert die Queue → `
|
|||
|
|
.. 200 {"cancelled":n}` | .. 200 {"cancelled":n}` |
|
|||
|
|
48 | `POST /mute` / `POST /unmute` | p 49 | `POST /mute` / `POST /unmute` | p
|
|||
|
|
.. ersistenter Mute-State → `200 {"mut .. ersistenter Mute-State → `200 {"mut
|
|||
|
|
.. ed":bool}` | .. ed":bool}` |
|
|||
|
|
.. 50 | `POST /mcp` | MCP (Streamable HTT
|
|||
|
|
.. .. P): JSON-RPC `initialize`, `notific
|
|||
|
|
.. .. ations/initialized`, `ping`, `tools
|
|||
|
|
.. .. /list`, `tools/call` — ein Tool `sp
|
|||
|
|
.. .. eak` (siehe unten) |
|
|||
|
|
.. 51 | `GET /mcp` | `405` — talkd bietet
|
|||
|
|
.. .. keine server-initiierten Streams a
|
|||
|
|
.. .. n |
|
|||
|
|
49 52
|
|||
|
|
50 - **CLI-Verhalten:** `talk say` (un 53 - **CLI-Verhalten:** `talk say` (un
|
|||
|
|
.. d `talk "Text"`) erkennt den Daemon .. d `talk "Text"`) erkennt den Daemon
|
|||
|
|
.. via .. via
|
|||
|
|
51 `GET /status`. Läuft er: enqueue 54 `GET /status`. Läuft er: enqueue
|
|||
|
|
.. + sofortige Rückkehr (non-blocking, .. + sofortige Rückkehr (non-blocking,
|
|||
|
|
|
|||
|
|
README.md --- 3/3 --- Text
|
|||
|
|
105 108 erlaubt), nächster slot …` und `ruhezeit: 18:00-08:00 (modus discard,
|
|||
|
|
106 109 aktiv bis 08:00)`.
|
|||
|
|
107 110
|
|||
|
|
... 111 ### CORS-Tür + MCP-Endpoint (Task 06)
|
|||
|
|
... 112
|
|||
|
|
... 113 **CORS** gilt bewusst nur für `POST /speak` — genau die eine Tür, die eine
|
|||
|
|
... 114 spätere Browser-Anbindung (z. B. LibreChat) braucht; Steuer-Endpunkte
|
|||
|
|
... 115 bleiben browserseitig unerreicht. Die Erlaubnis ist eine exakte Origin-
|
|||
|
|
... 116 Liste; `"*"` ist erlaubt, aber nicht Default; **leere Liste (Default) =
|
|||
|
|
... 117 gar keine CORS-Header = der Browser blockt**:
|
|||
|
|
... 118
|
|||
|
|
... 119 ```toml
|
|||
|
|
... 120 [server]
|
|||
|
|
... 121 allowed_origins = ["https://chat.az-gruppe.com"] # Env TALK_ALLOWED_ORIGINS
|
|||
|
|
... 122 ```
|
|||
|
|
... 123
|
|||
|
|
... 124 - `OPTIONS /speak` (Preflight) von gelisteter Origin → `204` mit
|
|||
|
|
... 125 `Access-Control-Allow-Origin` (die Origin bzw. `*`), `-Methods: POST,
|
|||
|
|
... 126 OPTIONS`, `-Headers: Content-Type`, `Max-Age: 600`.
|
|||
|
|
... 127 - Echter `POST /speak` mit gelisteter `Origin` trägt denselben ACAO-Header.
|
|||
|
|
... 128 - Ungelistete Origins bekommen saubere `204`/`202` **ohne** CORS-Header —
|
|||
|
|
... 129 die Blockade macht der Browser.
|
|||
|
|
... 130
|
|||
|
|
... 131 **MCP** (`POST /mcp`, Streamable HTTP): schlanke eigene Implementierung
|
|||
|
|
... 132 des JSON-RPC-Subsets ohne SDK — `initialize` (angefragte
|
|||
|
|
... 133 `protocolVersion` wird geechot; Ergebnis `protocolVersion`,
|
|||
|
|
... 134 `capabilities: {tools:{}}`, `serverInfo`), `notifications/initialized`
|
|||
|
|
... 135 (Notification → `202` ohne Body), `ping`, `tools/list`,
|
|||
|
|
... 136 `tools/call`. Der Server ist zustandslos (keine Session-IDs) und pusht
|
|||
|
|
... 137 nichts (daher `GET /mcp` → `405`). Ein Tool:
|
|||
|
|
... 138
|
|||
|
|
... 139 ```json
|
|||
|
|
... 140 {"name": "speak",
|
|||
|
|
... 141 "inputSchema": {"type": "object",
|
|||
|
|
... 142 "properties": {"text": {"type": "string"},
|
|||
|
|
... 143 "priority": {"enum": ["low","normal","high"]},
|
|||
|
|
... 144 "provider": {"type": "string"},
|
|||
|
|
... 145 "lang": {"enum": ["de","en"]},
|
|||
|
|
... 146 "now": {"type": "boolean"}},
|
|||
|
|
... 147 "required": ["text"]}}
|
|||
|
|
... 148 ```
|
|||
|
|
... 149
|
|||
|
|
... 150 `tools/call` fährt exakt denselben Aufnahme-Pfad wie `POST /speak`
|
|||
|
|
... 151 (inklusive Mute, Rate-Limit, Ruhezeiten). Ergebnis ist immer Text-Content:
|
|||
|
|
... 152 `"queued: <id>"` bei Aufnahme; `muted`/`discarded: <grund>` mit Erklärung
|
|||
|
|
... 153 — bewusst `isError: false`, denn ein Verwurf ist das korrekte
|
|||
|
|
... 154 dokumentierte Ergebnis, kein Protokollfehler. Validierungsfehler
|
|||
|
|
... 155 (fehlender Text, ungültige Priorität, unbekanntes Tool) sind dagegen
|
|||
|
|
... 156 JSON-RPC-Fehler (`-32602`). Handshake als curl-Sequenz:
|
|||
|
|
... 157
|
|||
|
|
... 158 ```sh
|
|||
|
|
... 159 curl -s http://127.0.0.1:7800/mcp -H 'Content-Type: application/json' \
|
|||
|
|
... 160 -d '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18","capabilities":{},"clientInfo":{"name":"curl","version":"0"}}}'
|
|||
|
|
... 161 curl -s http://127.0.0.1:7800/mcp -H 'Content-Type: application/json' \
|
|||
|
|
... 162 -d '{"jsonrpc":"2.0","method":"notifications/initialized"}'
|
|||
|
|
... 163 curl -s http://127.0.0.1:7800/mcp -H 'Content-Type: application/json' \
|
|||
|
|
... 164 -d '{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"speak","arguments":{"text":"Build fertig"}}}'
|
|||
|
|
... 165 ```
|
|||
|
|
... 166
|
|||
|
|
108 167 ### Provider-Kette (Task 03)
|
|||
|
|
109 168
|
|||
|
|
|
|||
|
|
cmd/talkd/main.go --- 1/3 --- Go
|
|||
|
|
5 // Default 1 Ansage / 30 s; low übe 5 // Default 1 Ansage / 30 s; low übe
|
|||
|
|
. r Budget wird verworfen, normal par . r Budget wird verworfen, normal par
|
|||
|
|
. kt, . kt,
|
|||
|
|
6 // high darf mit queue.allow_high_b 6 // high darf mit queue.allow_high_b
|
|||
|
|
. urst verstoßen) plus Ruhezeiten . urst verstoßen) plus Ruhezeiten
|
|||
|
|
7 // (quiet_hours/quiet_mode; now:tru 7 // (quiet_hours/quiet_mode; now:tru
|
|||
|
|
. e bricht bewusst aus beiden aus). . e bricht bewusst aus beiden aus).
|
|||
|
|
8 // CORS/MCP folgen in Task 06. 8 // Task 06 ergänzt die CORS-Tür für
|
|||
|
|
. . POST /speak (server.allowed_origin
|
|||
|
|
. . s /
|
|||
|
|
. 9 // TALK_ALLOWED_ORIGINS; leer = kei
|
|||
|
|
. . n CORS) und den MCP-Endpoint POST /
|
|||
|
|
. . mcp
|
|||
|
|
. 10 // (Streamable HTTP, ein speak-Tool
|
|||
|
|
. .. ).
|
|||
|
|
9 package main 11 package main
|
|||
|
|
10 12
|
|||
|
|
11 import ( 13 import (
|
|||
|
|
|
|||
|
|
cmd/talkd/main.go --- 2/3 --- Go
|
|||
|
|
120 122 Mute: mute,
|
|||
|
|
121 123 Version: versionString(),
|
|||
|
|
122 124 KnownProviders: provider.Names(),
|
|||
|
|
... 125 AllowedOrigins: cfg.Server.AllowedOrigins,
|
|||
|
|
123 126 Provider: func() string {
|
|||
|
|
124 127 if cur, ok := q.Current(); ok {
|
|||
|
|
125 128 return resolveProviderName(baseChain, cur)
|
|||
|
|
|
|||
|
|
cmd/talkd/main.go --- 3/3 --- Go
|
|||
|
|
139 srv := &http.Server{Handler: ha 142 srv := &http.Server{Handler: ha
|
|||
|
|
... ndler, ReadHeaderTimeout: 5 * time. ... ndler, ReadHeaderTimeout: 5 * time.
|
|||
|
|
... Second} ... Second}
|
|||
|
|
140 errCh := make(chan error, 1) 143 errCh := make(chan error, 1)
|
|||
|
|
141 go func() { errCh <- srv.Serve( 144 go func() { errCh <- srv.Serve(
|
|||
|
|
... ln) }() ... ln) }()
|
|||
|
|
... 145 corsLog := "zu (kein CORS)"
|
|||
|
|
... 146 if len(cfg.Server.AllowedOrigin
|
|||
|
|
... ... s) > 0 {
|
|||
|
|
... 147 corsLog = fmt.Sprintf("%v",
|
|||
|
|
... ... cfg.Server.AllowedOrigins)
|
|||
|
|
... 148 }
|
|||
|
|
142 log.Printf("listening on http:/ 149 log.Printf("listening on http:/
|
|||
|
|
... /%s (version %s, audio sink %s, mut ... /%s (version %s, audio sink %s, mut
|
|||
|
|
... e=%v, rate-limit %s, high-burst=%v, ... e=%v, rate-limit %s, high-burst=%v,
|
|||
|
|
... ruhezeit=%q modus %s)", ... ruhezeit=%q modus %s, cors=%s, mcp
|
|||
|
|
... ... =/mcp)",
|
|||
|
|
143 addr, versionString(), sink 150 addr, versionString(), sink
|
|||
|
|
... .Name(), mute.Muted(), ... .Name(), mute.Muted(),
|
|||
|
|
144 cfg.Queue.RateLimit, cfg.Qu 151 cfg.Queue.RateLimit, cfg.Qu
|
|||
|
|
... eue.AllowHighBurst, cfg.QuietHours, ... eue.AllowHighBurst, cfg.QuietHours,
|
|||
|
|
... cfg.QuietMode) ... cfg.QuietMode, corsLog)
|
|||
|
|
145 152
|
|||
|
|
146 sig := make(chan os.Signal, 1) 153 sig := make(chan os.Signal, 1)
|
|||
|
|
147 signal.Notify(sig, os.Interrupt 154 signal.Notify(sig, os.Interrupt
|
|||
|
|
... , syscall.SIGTERM) ... , syscall.SIGTERM)
|
|||
|
|
|
|||
|
|
internal/api/cors.go --- Go
|
|||
|
|
1 package api
|
|||
|
|
2
|
|||
|
|
3 import (
|
|||
|
|
4 "net/http"
|
|||
|
|
5 )
|
|||
|
|
6
|
|||
|
|
7 // CORS-Tür für POST /speak (Task 06) — und NUR für diesen Endpunkt: Ein
|
|||
|
|
8 // Browser (z. B. eine spätere LibreChat-Integration) darf genau die Ansage-
|
|||
|
|
9 // Schnittstelle cross-origin rufen, keine Steuer-Endpunkte. Die Erlaubnis
|
|||
|
|
10 // kommt ausschließlich aus der Config-Liste server.allowed_origins
|
|||
|
|
11 // (Env TALK_ALLOWED_ORIGINS): exakte Origins wie
|
|||
|
|
12 // "https://chat.az-gruppe.com"; der Eintrag "*" erlaubt jede Origin
|
|||
|
|
13 // ausdrücklich. Bewusster Default: LEER — dann sendet der Daemon gar keine
|
|||
|
|
14 // CORS-Header und der Browser blockt (Tür zu, bis sie konfiguriert wird).
|
|||
|
|
15 //
|
|||
|
|
16 // Ungelistete Origins bekommen bewusst trotzdem 204/202 ohne CORS-Header:
|
|||
|
|
17 // HTTP-seitig ist die Anfrage sauber, die Blockade macht der Browser (so
|
|||
|
|
18 // verrät der Daemon nichts über seine Erlaubnisliste).
|
|||
|
|
19
|
|||
|
|
20 // CORS-Antwort-Header (Namen zentral, damit Preflight und echter POST
|
|||
|
|
21 // dieselben setzen).
|
|||
|
|
22 const (
|
|||
|
|
23 headerAllowOrigin = "Access-Control-Allow-Origin"
|
|||
|
|
24 headerAllowMethods = "Access-Control-Allow-Methods"
|
|||
|
|
25 headerAllowHeaders = "Access-Control-Allow-Headers"
|
|||
|
|
26 headerMaxAge = "Access-Control-Max-Age"
|
|||
|
|
27 headerVary = "Vary"
|
|||
|
|
28 )
|
|||
|
|
29
|
|||
|
|
30 // preflightAge ist die Preflight-Cache-Dauer: 10 Minuten reichen für eine
|
|||
|
|
31 // Chat-Session und halten den OPTIONS-Chatter klein.
|
|||
|
|
32 const preflightAge = "600"
|
|||
|
|
33
|
|||
|
|
34 // corsACAOLiefert den Wert für Access-Control-Allow-Origin: bei Wildcard-
|
|||
|
|
35 // Konfiguration das literale "*", sonst die exakt gematchte Origin (die
|
|||
|
|
36 // identisch mit dem Config-Eintrag ist). Leer heißt: nicht erlaubt.
|
|||
|
|
37 func (h *handler) corsACAO(r *http.Request) string {
|
|||
|
|
38 origin := r.Header.Get("Origin")
|
|||
|
|
39 if origin == "" {
|
|||
|
|
40 return ""
|
|||
|
|
41 }
|
|||
|
|
42 for _, allowed := range h.allowedOrigins {
|
|||
|
|
43 if allowed == "*" {
|
|||
|
|
44 return "*"
|
|||
|
|
45 }
|
|||
|
|
46 if allowed == origin {
|
|||
|
|
47 return allowed
|
|||
|
|
48 }
|
|||
|
|
49 }
|
|||
|
|
50 return ""
|
|||
|
|
51 }
|
|||
|
|
52
|
|||
|
|
53 // setSpeakCors setzt die CORS-Header auf echten POST-/speak-Antworten,
|
|||
|
|
54 // wenn die Anfrage-Origin erlaubt ist. Vary: Origin steht immer (auch bei
|
|||
|
|
55 // geschlossener Tür), damit Caches die Antwort nie fälschlich origin-
|
|||
|
|
56 // übergreifend wiederverwenden.
|
|||
|
|
57 func (h *handler) setSpeakCors(w http.ResponseWriter, r *http.Request) {
|
|||
|
|
58 w.Header().Add(headerVary, "Origin")
|
|||
|
|
59 if acao := h.corsACAO(r); acao != "" {
|
|||
|
|
60 w.Header().Set(headerAllowOrigin, acao)
|
|||
|
|
61 }
|
|||
|
|
62 }
|
|||
|
|
63
|
|||
|
|
64 // speakPreflight behandelt OPTIONS /speak (CORS-Preflight): 204 mit den
|
|||
|
|
65 // Freigabe-Headern bei gelisteter Origin, 204 ohne jegliche CORS-Header
|
|||
|
|
66 // sonst (ungelistet, fehlend oder Tür komplett zu) — der Browser verwirft
|
|||
|
|
67 // den eigentlichen Request, während der Daemon für Nicht-Browser-Clients
|
|||
|
|
68 // erreichbar bleibt.
|
|||
|
|
69 func (h *handler) speakPreflight(w http.ResponseWriter, r *http.Request) {
|
|||
|
|
70 w.Header().Add(headerVary, "Origin")
|
|||
|
|
71 if acao := h.corsACAO(r); acao != "" {
|
|||
|
|
72 w.Header().Set(headerAllowOrigin, acao)
|
|||
|
|
73 w.Header().Set(headerAllowMethods, "POST, OPTIONS")
|
|||
|
|
74 w.Header().Set(headerAllowHeaders, "Content-Type")
|
|||
|
|
75 w.Header().Set(headerMaxAge, preflightAge)
|
|||
|
|
76 }
|
|||
|
|
77 w.WriteHeader(http.StatusNoContent)
|
|||
|
|
78 }
|
|||
|
|
79
|
|||
|
|
|
|||
|
|
internal/api/doc.go --- Go
|
|||
|
|
1 // Package api definiert die HTTP-Ob 1 // Package api definiert die HTTP-Ob
|
|||
|
|
. erfläche des Daemons auf 127.0.0.1: . erfläche des Daemons auf 127.0.0.1:
|
|||
|
|
2 // POST /speak (enqueue, 202 {id}; b 2 // POST /speak (enqueue, 202 {id}; b
|
|||
|
|
. ei Mute 202 {muted:true}; bei Gesetz . ei Mute 202 {muted:true}; bei Gesetz
|
|||
|
|
. - . -
|
|||
|
|
3 // Verwurf 202 {discarded:"rate_limi 3 // Verwurf 202 {discarded:"rate_limi
|
|||
|
|
. t"|"quiet_hours"} — Task 05), . t"|"quiet_hours"} — Task 05),
|
|||
|
|
4 // GET /status (inkl. Rate-Limit- un 4 // GET /status (inkl. Gesetz-State,
|
|||
|
|
. d Ruhezeit-State), POST /cancel, . mcp/cors — Task 06), POST /cancel,
|
|||
|
|
5 // POST /mute, POST /unmute. CORS fü 5 // POST /mute, POST /unmute. Task 06
|
|||
|
|
. r /speak und der MCP-Endpoint . ergänzt die CORS-Tür für POST /spea
|
|||
|
|
. . k
|
|||
|
|
. 6 // (OPTIONS-Preflight + ACAO aus ser
|
|||
|
|
. . ver.allowed_origins, siehe cors.go)
|
|||
|
|
. 7 // und den MCP-Endpoint POST /mcp al
|
|||
|
|
. . s schlankes eigenes JSON-RPC-Subset
|
|||
|
|
. 8 // (initialize, notifications/initia
|
|||
|
|
. . lized, tools/list, tools/call mit de
|
|||
|
|
. . m
|
|||
|
|
6 // (Streamable HTTP) folgen in Task 9 // speak-Tool — siehe mcp.go). Der C
|
|||
|
|
. 06. Der Client-Teil ist die CLI-Seit . lient-Teil ist die CLI-Seite derselb
|
|||
|
|
. e . en
|
|||
|
|
7 // derselben Vertragsschnittstelle. 10 // Vertragsschnittstelle.
|
|||
|
|
8 package api 11 package api
|
|||
|
|
|
|||
|
|
internal/api/mcp.go --- Go
|
|||
|
|
1 package api
|
|||
|
|
2
|
|||
|
|
3 import (
|
|||
|
|
4 "encoding/json"
|
|||
|
|
5 "io"
|
|||
|
|
6 "net/http"
|
|||
|
|
7 "strconv"
|
|||
|
|
8 "strings"
|
|||
|
|
9
|
|||
|
|
10 "code.m3ta.dev/m3tam3re/talk/internal/queue"
|
|||
|
|
11 )
|
|||
|
|
12
|
|||
|
|
13 // MCP-Endpoint (Task 06): Streamable HTTP auf POST /mcp — eine eigene,
|
|||
|
|
14 // schlanke Implementierung des JSON-RPC-2.0-Subsets, das MCP tatsächlich
|
|||
|
|
15 // braucht (bewusst KEIN SDK-Dependency; der Protokollumfang hier ist vier
|
|||
|
|
16 // Methoden plus ping). Semantik nach MCP 2025-06-18, mit zwei
|
|||
|
|
17 // dokumentierten Vereinfachungen, die der Transport hergibt:
|
|||
|
|
18 //
|
|||
|
|
19 // - Zustandslos: Jeder POST ist unabhängig; es gibt keine Session-IDs
|
|||
|
|
20 // (Mcp-Session-Id wird ignoriert) und damit auch keine Durchsetzung
|
|||
|
|
21 // der Initialize-vor-allem-Regel — ein curl kann tools/call direkt
|
|||
|
|
22 // rufen. MCP-Clients stört das nicht: Sie fahren den Handshake
|
|||
|
|
23 // freiwillig.
|
|||
|
|
24 // - Keine server-initiierten Streams: talkd pusht nie (die Tool-Liste
|
|||
|
|
25 // ist statisch). GET /mcp antwortet daher 405 — die vom Streamable-
|
|||
|
|
26 // HTTP-Transport vorgesehene Antwort für Server ohne SSE-Stream.
|
|||
|
|
27 //
|
|||
|
|
28 // Antworten: Requests → 200 application/json (JSON-RPC-Fehlerobjekte
|
|||
|
|
29 // ebenfalls 200 — der HTTP-Teil war ja erfolgreich); Notifications → 202
|
|||
|
|
30 // ohne Body (JSON-RPC 2.0). Batches (Arrays) sind seit 2025-06-18 nicht
|
|||
|
|
31 // mehr Teil von MCP → -32600.
|
|||
|
|
32
|
|||
|
|
33 // mcpProtocolVersion ist die Version, die talkd selbst spricht, wenn der
|
|||
|
|
34 // Client keine angibt (initialize ohne protocolVersion). Angefragte
|
|||
|
|
35 // Versionen werden akzeptiert und zurückgegeben (Echo) — inhaltlich
|
|||
|
|
36 // implementiert ist der oben dokumentierte 2025-06-18-Umfang.
|
|||
|
|
37 const mcpProtocolVersion = "2025-06-18"
|
|||
|
|
38
|
|||
|
|
39 // JSON-RPC-2.0-Fehlercodes (benutztes Subset).
|
|||
|
|
40 const (
|
|||
|
|
41 rpcParseError = -32700
|
|||
|
|
42 rpcInvalidRequest = -32600
|
|||
|
|
43 rpcMethodNotFound = -32601
|
|||
|
|
44 rpcInvalidParams = -32602
|
|||
|
|
45 rpcInternalError = -32603
|
|||
|
|
46 )
|
|||
|
|
47
|
|||
|
|
48 // mcpServerName ist der serverInfo-Name im Initialize-Ergebnis.
|
|||
|
|
49 const mcpServerName = "talkd"
|
|||
|
|
50
|
|||
|
|
51 // jsonRPCRequest ist eine eingehende JSON-RPC-Nachricht. ID als
|
|||
|
|
52 // RawMessage: So bleibt zwischen „Feld fehlt" (Notification) und id:0
|
|||
|
|
53 // bzw. id:string unterscheidbar.
|
|||
|
|
54 type jsonRPCRequest struct {
|
|||
|
|
55 JSONRPC string `json:"jsonrpc"`
|
|||
|
|
56 ID json.RawMessage `json:"id"`
|
|||
|
|
57 Method string `json:"method"`
|
|||
|
|
58 Params json.RawMessage `json:"params"`
|
|||
|
|
59 }
|
|||
|
|
60
|
|||
|
|
61 // jsonRPCResponse ist eine ausgehende JSON-RPC-Nachricht. Genau eines aus
|
|||
|
|
62 // Result/Error ist gesetzt. ID ist nie weggelassen — bei Parse-Fehlern
|
|||
|
|
63 // ohne bekannte ID null (JSON-RPC 2.0).
|
|||
|
|
64 type jsonRPCResponse struct {
|
|||
|
|
65 JSONRPC string `json:"jsonrpc"`
|
|||
|
|
66 ID json.RawMessage `json:"id"`
|
|||
|
|
67 Result any `json:"result,omitempty"`
|
|||
|
|
68 Error *rpcError `json:"error,omitempty"`
|
|||
|
|
69 }
|
|||
|
|
70
|
|||
|
|
71 // rpcError ist das Fehlerobjekt einer JSON-RPC-Antwort.
|
|||
|
|
72 type rpcError struct {
|
|||
|
|
73 Code int `json:"code"`
|
|||
|
|
74 Message string `json:"message"`
|
|||
|
|
75 }
|
|||
|
|
76
|
|||
|
|
77 // mcpInitializeParams sind die relevanten Teile der initialize-Parameter
|
|||
|
|
78 // (Client-Capabilities interessieren talkd nicht — Tools genügen).
|
|||
|
|
79 type mcpInitializeParams struct {
|
|||
|
|
80 ProtocolVersion string `json:"protocolVersion"`
|
|||
|
|
81 }
|
|||
|
|
82
|
|||
|
|
83 // mcpServerInfo beschreibt den Server im Initialize-Ergebnis.
|
|||
|
|
84 type mcpServerInfo struct {
|
|||
|
|
85 Name string `json:"name"`
|
|||
|
|
86 Version string `json:"version"`
|
|||
|
|
87 }
|
|||
|
|
88
|
|||
|
|
89 // mcpCapabilities meldet im Initialize-Ergebnis, was talkd anbietet:
|
|||
|
|
90 // Tools (speak) — sonst nichts.
|
|||
|
|
91 type mcpCapabilities struct {
|
|||
|
|
92 Tools map[string]any `json:"tools"`
|
|||
|
|
93 }
|
|||
|
|
94
|
|||
|
|
95 // mcpInitializeResult ist das Ergebnis von initialize: die (geechote)
|
|||
|
|
96 // Protokollversion, die Fähigkeiten und die Server-Info — 2025-06-18-Form.
|
|||
|
|
97 type mcpInitializeResult struct {
|
|||
|
|
98 ProtocolVersion string `json:"protocolVersion"`
|
|||
|
|
99 Capabilities mcpCapabilities `json:"capabilities"`
|
|||
|
|
100 ServerInfo mcpServerInfo `json:"serverInfo"`
|
|||
|
|
101 }
|
|||
|
|
102
|
|||
|
|
103 // mcpToolsListResult ist das Ergebnis von tools/list: genau ein Tool
|
|||
|
|
104 // (speak); ohne nextCursor (die Liste passt immer in eine Seite).
|
|||
|
|
105 type mcpToolsListResult struct {
|
|||
|
|
106 Tools []mcpTool `json:"tools"`
|
|||
|
|
107 }
|
|||
|
|
108
|
|||
|
|
109 // mcpTool ist ein Tool-Eintrag in tools/list.
|
|||
|
|
110 type mcpTool struct {
|
|||
|
|
111 Name string `json:"name"`
|
|||
|
|
112 Description string `json:"description"`
|
|||
|
|
113 InputSchema mcpSchema `json:"inputSchema"`
|
|||
|
|
114 }
|
|||
|
|
115
|
|||
|
|
116 // mcpSchema ist das JSON-Schema-Objekt eines Tool-Inputs.
|
|||
|
|
117 type mcpSchema struct {
|
|||
|
|
118 Type string `json:"type"`
|
|||
|
|
119 Properties map[string]mcpProperty `json:"properties"`
|
|||
|
|
120 Required []string `json:"required"`
|
|||
|
|
121 }
|
|||
|
|
122
|
|||
|
|
123 // mcpProperty ist eine einzelne Schema-Property.
|
|||
|
|
124 type mcpProperty struct {
|
|||
|
|
125 Type string `json:"type"`
|
|||
|
|
126 Description string `json:"description,omitempty"`
|
|||
|
|
127 Enum []string `json:"enum,omitempty"`
|
|||
|
|
128 }
|
|||
|
|
129
|
|||
|
|
130 // mcpToolCallParams sind die Parameter von tools/call.
|
|||
|
|
131 type mcpToolCallParams struct {
|
|||
|
|
132 Name string `json:"name"`
|
|||
|
|
133 Arguments json.RawMessage `json:"arguments"`
|
|||
|
|
134 }
|
|||
|
|
135
|
|||
|
|
136 // mcpContent ist ein Content-Block eines Tool-Ergebnisses (hier: Text).
|
|||
|
|
137 type mcpContent struct {
|
|||
|
|
138 Type string `json:"type"`
|
|||
|
|
139 Text string `json:"text"`
|
|||
|
|
140 }
|
|||
|
|
141
|
|||
|
|
142 // mcpToolResult ist das Ergebnis von tools/call. isError:false (weggelassen)
|
|||
|
|
143 // auch bei muted/discarded — das ist ein ERGEBNIS der Aufnahme, kein
|
|||
|
|
144 // Protokollfehler (bewusste Entscheidung, siehe speakToolResult).
|
|||
|
|
145 type mcpToolResult struct {
|
|||
|
|
146 Content []mcpContent `json:"content"`
|
|||
|
|
147 IsError bool `json:"isError,omitempty"`
|
|||
|
|
148 }
|
|||
|
|
149
|
|||
|
|
150 // serveMCP behandelt POST /mcp: eine JSON-RPC-Nachricht pro Request.
|
|||
|
|
151 func (h *handler) serveMCP(w http.ResponseWriter, r *http.Request) {
|
|||
|
|
152 body, err := io.ReadAll(http.MaxBytesReader(w, r.Body, maxBodyBytes))
|
|||
|
|
153 if err != nil {
|
|||
|
|
154 writeRPCError(w, nil, rpcParseError, "Body lesen: "+err.Error())
|
|||
|
|
155 return
|
|||
|
|
156 }
|
|||
|
|
157 if strings.HasPrefix(strings.TrimLeft(string(body), " \t\r\n"), "[") {
|
|||
|
|
158 writeRPCError(w, nil, rpcInvalidRequest,
|
|||
|
|
159 "Batch-Requests werden nicht unterstützt (MCP 2025-06-18 hat Batches entfernt) — eine Nachricht pro POST")
|
|||
|
|
160 return
|
|||
|
|
161 }
|
|||
|
|
162
|
|||
|
|
163 var req jsonRPCRequest
|
|||
|
|
164 if err := json.Unmarshal(body, &req); err != nil {
|
|||
|
|
165 writeRPCError(w, nil, rpcParseError, "ungültiges JSON: "+err.Error())
|
|||
|
|
166 return
|
|||
|
|
167 }
|
|||
|
|
168 if req.JSONRPC != "2.0" {
|
|||
|
|
169 writeRPCError(w, nil, rpcInvalidRequest,
|
|||
|
|
170 `jsonrpc muss "2.0" sein (erhalten: `+strconv.Quote(req.JSONRPC)+`)`)
|
|||
|
|
171 return
|
|||
|
|
172 }
|
|||
|
|
173 if isNotification(req.ID) {
|
|||
|
|
174 // Notifications (notifications/initialized, …) bekommen nie eine
|
|||
|
|
175 // JSON-RPC-Antwort — nur den 202-Vermerk des Transports. Unbekannte
|
|||
|
|
176 // Notifications werden still ignoriert (JSON-RPC 2.0).
|
|||
|
|
177 w.WriteHeader(http.StatusAccepted)
|
|||
|
|
178 return
|
|||
|
|
179 }
|
|||
|
|
180
|
|||
|
|
181 switch req.Method {
|
|||
|
|
182 case "initialize":
|
|||
|
|
183 h.mcpInitialize(w, req)
|
|||
|
|
184 case "notifications/initialized":
|
|||
|
|
185 // Mit ID gesendet: kein regulärer Client — die korrekte Antwort
|
|||
|
|
186 // ist ein leeres Ergebnis, kein Fehler.
|
|||
|
|
187 writeRPCResult(w, req.ID, struct{}{})
|
|||
|
|
188 case "ping":
|
|||
|
|
189 writeRPCResult(w, req.ID, struct{}{})
|
|||
|
|
190 case "tools/list":
|
|||
|
|
191 writeRPCResult(w, req.ID, mcpToolsListResult{Tools: []mcpTool{speakTool()}})
|
|||
|
|
192 case "tools/call":
|
|||
|
|
193 h.mcpToolsCall(w, req)
|
|||
|
|
194 default:
|
|||
|
|
195 writeRPCError(w, req.ID, rpcMethodNotFound,
|
|||
|
|
196 "Methode "+strconv.Quote(req.Method)+" ist nicht implementiert (verfügbar: initialize, notifications/initialized, ping, tools/list, tools/call)")
|
|||
|
|
197 }
|
|||
|
|
198 }
|
|||
|
|
199
|
|||
|
|
200 // mcpGet behandelt GET /mcp: 405 mit Allow — talkd bietet keine
|
|||
|
|
201 // server-initiierten Streams an (die dafür vorgesehene Streamable-HTTP-
|
|||
|
|
202 // Antwort), Tools-Änderungen gibt es nicht.
|
|||
|
|
203 func (h *handler) mcpGet(w http.ResponseWriter, r *http.Request) {
|
|||
|
|
204 w.Header().Set("Allow", "POST, OPTIONS")
|
|||
|
|
205 w.WriteHeader(http.StatusMethodNotAllowed)
|
|||
|
|
206 }
|
|||
|
|
207
|
|||
|
|
208 // mcpInitialize behandelt initialize: Die angefragte Protokollversion wird
|
|||
|
|
209 // geechot (akzeptiert) — leer → die eigene (2025-06-18). Capabilities:
|
|||
|
|
210 // Tools; serverInfo: talkd + Daemon-Version.
|
|||
|
|
211 func (h *handler) mcpInitialize(w http.ResponseWriter, req jsonRPCRequest) {
|
|||
|
|
212 var params mcpInitializeParams
|
|||
|
|
213 if len(req.Params) > 0 {
|
|||
|
|
214 if err := json.Unmarshal(req.Params, ¶ms); err != nil {
|
|||
|
|
215 writeRPCError(w, req.ID, rpcInvalidParams, "initialize-params: "+err.Error())
|
|||
|
|
216 return
|
|||
|
|
217 }
|
|||
|
|
218 }
|
|||
|
|
219 version := params.ProtocolVersion
|
|||
|
|
220 if version == "" {
|
|||
|
|
221 version = mcpProtocolVersion
|
|||
|
|
222 }
|
|||
|
|
223 writeRPCResult(w, req.ID, mcpInitializeResult{
|
|||
|
|
224 ProtocolVersion: version,
|
|||
|
|
225 Capabilities: mcpCapabilities{Tools: map[string]any{}},
|
|||
|
|
226 ServerInfo: mcpServerInfo{Name: mcpServerName, Version: h.version},
|
|||
|
|
227 })
|
|||
|
|
228 }
|
|||
|
|
229
|
|||
|
|
230 // mcpToolsCall behandelt tools/call: genau ein Tool (speak), dessen
|
|||
|
|
231 // Argumente denselben Aufnahme-Pfad wie POST /speak fahren — inklusive
|
|||
|
|
232 // Mute und Gesetz (Rate-Limit/Ruhezeit).
|
|||
|
|
233 func (h *handler) mcpToolsCall(w http.ResponseWriter, req jsonRPCRequest) {
|
|||
|
|
234 var params mcpToolCallParams
|
|||
|
|
235 if err := json.Unmarshal(req.Params, ¶ms); err != nil {
|
|||
|
|
236 writeRPCError(w, req.ID, rpcInvalidParams, "tools/call-params: "+err.Error())
|
|||
|
|
237 return
|
|||
|
|
238 }
|
|||
|
|
239 if params.Name != "speak" {
|
|||
|
|
240 writeRPCError(w, req.ID, rpcInvalidParams,
|
|||
|
|
241 "unbekanntes Tool "+strconv.Quote(params.Name)+" (verfügbar: speak)")
|
|||
|
|
242 return
|
|||
|
|
243 }
|
|||
|
|
244
|
|||
|
|
245 args := params.Arguments
|
|||
|
|
246 if len(args) == 0 {
|
|||
|
|
247 args = json.RawMessage("{}")
|
|||
|
|
248 }
|
|||
|
|
249 var speakReq SpeakRequest
|
|||
|
|
250 if err := json.Unmarshal(args, &speakReq); err != nil {
|
|||
|
|
251 writeRPCError(w, req.ID, rpcInvalidParams, "arguments: "+err.Error())
|
|||
|
|
252 return
|
|||
|
|
253 }
|
|||
|
|
254
|
|||
|
|
255 adm := h.admitSpeak(speakReq)
|
|||
|
|
256 if adm.status != http.StatusAccepted {
|
|||
|
|
257 // Validierungs-/Interner-Fehler (HTTP-Äquivalent 400/503): als
|
|||
|
|
258 // JSON-RPC-Fehler melden — das war kein Aufnahme-Ergebnis.
|
|||
|
|
259 code := rpcInvalidParams
|
|||
|
|
260 if adm.status != http.StatusBadRequest {
|
|||
|
|
261 code = rpcInternalError
|
|||
|
|
262 }
|
|||
|
|
263 writeRPCError(w, req.ID, code, adm.errMsg)
|
|||
|
|
264 return
|
|||
|
|
265 }
|
|||
|
|
266 writeRPCResult(w, req.ID, speakToolResult(adm.resp))
|
|||
|
|
267 }
|
|||
|
|
268
|
|||
|
|
269 // speakTool ist die Tool-Beschreibung von speak: Input-Schema aus dem
|
|||
|
|
270 // Task-06-Brief (text Pflicht; priority/provider/lang/now optional).
|
|||
|
|
271 func speakTool() mcpTool {
|
|||
|
|
272 return mcpTool{
|
|||
|
|
273 Name: "speak",
|
|||
|
|
274 Description: "Spricht eine kurze Ansage (Status, nächster Schritt, " +
|
|||
|
|
275 "Freigabe-Bedarf — nie Langantworten). Läuft über den Daemon: " +
|
|||
|
|
276 "Queue, Rate-Limit, Mute und Ruhezeiten gelten.",
|
|||
|
|
277 InputSchema: mcpSchema{
|
|||
|
|
278 Type: "object",
|
|||
|
|
279 Properties: map[string]mcpProperty{
|
|||
|
|
280 "text": {
|
|||
|
|
281 Type: "string",
|
|||
|
|
282 Description: "Der Ansage-Text (1–2 Sätze Etikette)",
|
|||
|
|
283 },
|
|||
|
|
284 "priority": {
|
|||
|
|
285 Type: "string",
|
|||
|
|
286 Enum: []string{"low", "normal", "high"},
|
|||
|
|
287 Description: "Abarbeitungs-Priorität (Default normal)",
|
|||
|
|
288 },
|
|||
|
|
289 "provider": {
|
|||
|
|
290 Type: "string",
|
|||
|
|
291 Description: "Provider pin (elevenlabs, openai, edge, osbuiltin); leer = Default-Kette",
|
|||
|
|
292 },
|
|||
|
|
293 "lang": {
|
|||
|
|
294 Type: "string",
|
|||
|
|
295 Enum: []string{"de", "en"},
|
|||
|
|
296 Description: "Sprache pin; leer = automatische Erkennung",
|
|||
|
|
297 },
|
|||
|
|
298 "now": {
|
|||
|
|
299 Type: "boolean",
|
|||
|
|
300 Description: "Springt vor die Warteschlange und bricht bewusst aus Rate-Limit und Ruhezeit aus",
|
|||
|
|
301 },
|
|||
|
|
302 },
|
|||
|
|
303 Required: []string{"text"},
|
|||
|
|
304 },
|
|||
|
|
305 }
|
|||
|
|
306 }
|
|||
|
|
307
|
|||
|
|
308 // speakToolResult übersetzt eine /speak-Aufnahme-Antwort in das MCP-Tool-
|
|||
|
|
309 // Ergebnis. Mute- und Gesetz-Verwürfe sind bewusst KEIN isError: Sie sind
|
|||
|
|
310 // das korrekte dokumentierte Ergebnis der Aufnahme (kein Protokollfehler)
|
|||
|
|
311 // — der Agent soll den Grund lesen, nicht crashen. Erfolgreiche Aufnahme:
|
|||
|
|
312 // "queued: <id>".
|
|||
|
|
313 func speakToolResult(resp SpeakResponse) mcpToolResult {
|
|||
|
|
314 switch {
|
|||
|
|
315 case resp.Muted:
|
|||
|
|
316 return mcpToolResult{Content: []mcpContent{{
|
|||
|
|
317 Type: "text",
|
|||
|
|
318 Text: "muted: Ansage verworfen (Mute ist aktiv)",
|
|||
|
|
319 }}}
|
|||
|
|
320 case resp.Discarded == queue.DiscardRateLimit:
|
|||
|
|
321 return mcpToolResult{Content: []mcpContent{{
|
|||
|
|
322 Type: "text",
|
|||
|
|
323 Text: "discarded: rate_limit — Ansage verworfen (Priorität low über dem Fenster-Budget)",
|
|||
|
|
324 }}}
|
|||
|
|
325 case resp.Discarded == queue.DiscardQuietHours:
|
|||
|
|
326 text := "discarded: quiet_hours — Ruhezeit aktiv, Ansage verworfen"
|
|||
|
|
327 if resp.Toast {
|
|||
|
|
328 text += " (durch Toast ersetzt, kein Audio)"
|
|||
|
|
329 }
|
|||
|
|
330 return mcpToolResult{Content: []mcpContent{{Type: "text", Text: text}}}
|
|||
|
|
331 case resp.Discarded != "":
|
|||
|
|
332 return mcpToolResult{Content: []mcpContent{{
|
|||
|
|
333 Type: "text",
|
|||
|
|
334 Text: "discarded: " + resp.Discarded,
|
|||
|
|
335 }}}
|
|||
|
|
336 default:
|
|||
|
|
337 return mcpToolResult{Content: []mcpContent{{
|
|||
|
|
338 Type: "text",
|
|||
|
|
339 Text: "queued: " + resp.ID,
|
|||
|
|
340 }}}
|
|||
|
|
341 }
|
|||
|
|
342 }
|
|||
|
|
343
|
|||
|
|
344 // isNotification meldet, ob die ID eine Notification anzeigt (Feld fehlt
|
|||
|
|
345 // oder explizit null — beides nach JSON-RPC 2.0 ohne Antwort).
|
|||
|
|
346 func isNotification(id json.RawMessage) bool {
|
|||
|
|
347 return len(id) == 0 || strings.TrimSpace(string(id)) == "null"
|
|||
|
|
348 }
|
|||
|
|
349
|
|||
|
|
350 // writeRPCResult schreibt eine erfolgreiche JSON-RPC-Antwort (200,
|
|||
|
|
351 // application/json).
|
|||
|
|
352 func writeRPCResult(w http.ResponseWriter, id json.RawMessage, result any) {
|
|||
|
|
353 writeRPC(w, jsonRPCResponse{JSONRPC: "2.0", ID: id, Result: result})
|
|||
|
|
354 }
|
|||
|
|
355
|
|||
|
|
356 // writeRPCError schreibt eine JSON-RPC-Fehler-Antwort; id null, wenn die
|
|||
|
|
357 // Anfrage-ID unbekannt ist (Parse-Fehler).
|
|||
|
|
358 func writeRPCError(w http.ResponseWriter, id json.RawMessage, code int, msg string) {
|
|||
|
|
359 writeRPC(w, jsonRPCResponse{
|
|||
|
|
360 JSONRPC: "2.0",
|
|||
|
|
361 ID: id,
|
|||
|
|
362 Error: &rpcError{Code: code, Message: msg},
|
|||
|
|
363 })
|
|||
|
|
364 }
|
|||
|
|
365
|
|||
|
|
366 // writeRPC schreibt die JSON-RPC-Antwort in den Response-Writer.
|
|||
|
|
367 func writeRPC(w http.ResponseWriter, resp jsonRPCResponse) {
|
|||
|
|
368 w.Header().Set("Content-Type", "application/json")
|
|||
|
|
369 w.WriteHeader(http.StatusOK)
|
|||
|
|
370 _ = json.NewEncoder(w).Encode(resp)
|
|||
|
|
371 }
|
|||
|
|
372
|
|||
|
|
|
|||
|
|
internal/api/server.go --- 1/7 --- Go
|
|||
|
|
24 // Mute ist der persistente Mut 24 // Mute ist der persistente Mut
|
|||
|
|
.. e-State (Datei mute im Config-Dir). .. e-State (Datei mute im Config-Dir).
|
|||
|
|
25 Mute queue.MuteStore 25 Mute queue.MuteStore
|
|||
|
|
26 26
|
|||
|
|
27 // Version ist die Daemon-Versi 27 // Version ist die Daemon-Versi
|
|||
|
|
.. on für GET /status. .. on für GET /status und serverInfo.
|
|||
|
|
28 Version string 28 Version string
|
|||
|
|
29 29
|
|||
|
|
30 // KnownProviders sind die gült 30 // KnownProviders sind die gült
|
|||
|
|
.. igen Provider-Namen (Validierung de .. igen Provider-Namen (Validierung de
|
|||
|
|
.. s .. s
|
|||
|
|
|
|||
|
|
internal/api/server.go --- 2/7 --- Go
|
|||
|
|
35 35 // Pin des laufenden Items bzw. erste verfügbare Ketten-Stufe). Optional.
|
|||
|
|
36 36 Provider func() string
|
|||
|
|
.. 37
|
|||
|
|
.. 38 // AllowedOrigins ist die CORS-Erlaubnisliste für POST /speak (leer =
|
|||
|
|
.. 39 // keine CORS-Header, der Browser blockt; siehe cors.go).
|
|||
|
|
.. 40 AllowedOrigins []string
|
|||
|
|
37 41 }
|
|||
|
|
38 42
|
|||
|
|
39 43 // NewHandler liefert die HTTP-Routen des Daemons. Methoden-Bindung per
|
|||
|
|
|
|||
|
|
internal/api/server.go --- 3/7 --- Go
|
|||
|
|
45 49 version: cfg.Version,
|
|||
|
|
46 50 known: make(map[string]bool, len(cfg.KnownProviders)),
|
|||
|
|
47 51 provider: cfg.Provider,
|
|||
|
|
.. 52 allowedOrigins: cfg.AllowedOrigins,
|
|||
|
|
48 53 }
|
|||
|
|
49 54 for _, name := range cfg.KnownProviders {
|
|||
|
|
50 55 h.known[name] = true
|
|||
|
|
51 56 }
|
|||
|
|
52 57 mux := http.NewServeMux()
|
|||
|
|
53 58 mux.HandleFunc("POST /speak", h.speak)
|
|||
|
|
.. 59 mux.HandleFunc("OPTIONS /speak", h.speakPreflight) // CORS-Preflight (Task 06)
|
|||
|
|
54 60 mux.HandleFunc("GET /status", h.status)
|
|||
|
|
55 61 mux.HandleFunc("POST /cancel", h.cancel)
|
|||
|
|
56 62 mux.HandleFunc("POST /mute", h.muteEndpoint(true))
|
|||
|
|
57 63 mux.HandleFunc("POST /unmute", h.muteEndpoint(false))
|
|||
|
|
.. 64 mux.HandleFunc("POST /mcp", h.serveMCP) // MCP Streamable HTTP (Task 06)
|
|||
|
|
.. 65 mux.HandleFunc("GET /mcp", h.mcpGet) // 405: keine server-initiierten Streams
|
|||
|
|
58 66 return mux
|
|||
|
|
59 67 }
|
|||
|
|
60 68
|
|||
|
|
|
|||
|
|
internal/api/server.go --- 4/7 --- Go
|
|||
|
|
65 version string 73 version string
|
|||
|
|
66 known map[string]bool 74 known map[string]bool
|
|||
|
|
67 provider func() string 75 provider func() string
|
|||
|
|
.. 76 allowedOrigins []string
|
|||
|
|
68 } 77 }
|
|||
|
|
69 78
|
|||
|
|
70 // speak behandelt POST /speak: val 79 // speak behandelt POST /speak: val
|
|||
|
|
.. idieren, bei Mute verwerfen (202, .. idieren, bei Mute verwerfen (202,
|
|||
|
|
71 // dokumentiertes Verhalten — bewus 80 // dokumentiertes Verhalten — bewus
|
|||
|
|
.. st kein Fehler, damit Aufrufer kein .. st kein Fehler, damit Aufrufer kein
|
|||
|
|
.. e .. e
|
|||
|
|
72 // Sonderbehandlung brauchen), sons 81 // Sonderbehandlung brauchen), sons
|
|||
|
|
.. t enqueue → 202 {id}. Das Gesetz .. t enqueue → 202 {id}. Das Gesetz
|
|||
|
|
73 // (Rate-Limit-Budget, Ruhezeit) ka 82 // (Rate-Limit-Budget, Ruhezeit) ka
|
|||
|
|
.. nn beim Eintritt ebenfalls verwerfe .. nn beim Eintritt ebenfalls verwerfe
|
|||
|
|
.. n — .. n —
|
|||
|
|
74 // die Antwort nennt dann den Grund 83 // die Antwort nennt dann den Grund
|
|||
|
|
.. ({"discarded":"…"}). .. ({"discarded":"…"}). CORS-Header n
|
|||
|
|
.. .. ur
|
|||
|
|
.. 84 // hier und nur bei gelisteter Orig
|
|||
|
|
.. .. in (siehe cors.go).
|
|||
|
|
75 func (h *handler) speak(w http.Resp 85 func (h *handler) speak(w http.Resp
|
|||
|
|
.. onseWriter, r *http.Request) { .. onseWriter, r *http.Request) {
|
|||
|
|
76 var req SpeakRequest 86 h.setSpeakCors(w, r)
|
|||
|
|
.. 87
|
|||
|
|
.. 88 var req SpeakRequest
|
|||
|
|
.. 89 if err := json.NewDecoder(http.
|
|||
|
|
.. .. MaxBytesReader(w, r.Body, maxBodyBy
|
|||
|
|
.. .. tes)).Decode(&req); err != nil {
|
|||
|
|
.. 90 writeError(w, http.StatusBa
|
|||
|
|
.. .. dRequest, "ungültiger JSON-Body: "+
|
|||
|
|
.. .. err.Error())
|
|||
|
|
.. 91 return
|
|||
|
|
.. 92 }
|
|||
|
|
.. 93 adm := h.admitSpeak(req)
|
|||
|
|
.. 94 if adm.errMsg != "" {
|
|||
|
|
.. 95 writeError(w, adm.status, a
|
|||
|
|
.. .. dm.errMsg)
|
|||
|
|
.. 96 return
|
|||
|
|
.. 97 }
|
|||
|
|
.. 98 writeJSON(w, adm.status, adm.re
|
|||
|
|
.. .. sp)
|
|||
|
|
.. 99 }
|
|||
|
|
.. 100
|
|||
|
|
.. 101 // speakAdmission ist das Ergebnis
|
|||
|
|
.. ... des gemeinsamen Aufnahme-Pfads von
|
|||
|
|
.. 102 // POST /speak und dem MCP-Tool spe
|
|||
|
|
.. ... ak: HTTP-Status-Äquivalent plus Bod
|
|||
|
|
.. ... y —
|
|||
|
|
.. 103 // 202 mit {id}/{muted}/{discarded}
|
|||
|
|
.. ... als Aufnahme-Ergebnis, 400/503 mit
|
|||
|
|
.. 104 // errMsg als Fehler (MCP übersetzt
|
|||
|
|
.. ... beides, siehe mcp.go).
|
|||
|
|
.. 105 type speakAdmission struct {
|
|||
|
|
.. 106 status int
|
|||
|
|
.. 107 resp SpeakResponse
|
|||
|
|
.. 108 errMsg string
|
|||
|
|
.. 109 }
|
|||
|
|
.. 110
|
|||
|
|
.. 111 // admitSpeak ist der eine Aufnahme
|
|||
|
|
.. ... -Pfad hinter POST /speak und dem
|
|||
|
|
.. 112 // MCP-Tool speak (Task 06): Validi
|
|||
|
|
.. ... erung, Mute-Check, Enqueue inklusiv
|
|||
|
|
.. ... e
|
|||
|
|
.. 113 // Gesetz (Rate-Limit-Budget, Ruhez
|
|||
|
|
.. ... eit) — identische Semantik für beid
|
|||
|
|
.. ... e
|
|||
|
|
.. 114 // Türen, kein Contract-Drift.
|
|||
|
|
77 if err := json.NewDecoder(http. 115 func (h *handler) admitSpeak(req Sp
|
|||
|
|
.. MaxBytesReader(w, r.Body, maxBodyBy ... eakRequest) speakAdmission {
|
|||
|
|
.. tes)).Decode(&req); err != nil { ...
|
|||
|
|
78 writeError(w, http.StatusBa ...
|
|||
|
|
.. dRequest, "ungültiger JSON-Body: "+ ...
|
|||
|
|
.. err.Error()) ...
|
|||
|
|
79 return ...
|
|||
|
|
80 } ...
|
|||
|
|
81 if strings.TrimSpace(req.Text) 116 if strings.TrimSpace(req.Text)
|
|||
|
|
.. == "" { ... == "" {
|
|||
|
|
82 writeError(w, http.StatusBa 117 return speakAdmission{statu
|
|||
|
|
.. dRequest, "text fehlt oder ist leer ... s: http.StatusBadRequest, errMsg: "
|
|||
|
|
.. ") ... text fehlt oder ist leer"}
|
|||
|
|
83 return ...
|
|||
|
|
84 } 118 }
|
|||
|
|
85 if req.Priority == "" { 119 if req.Priority == "" {
|
|||
|
|
86 req.Priority = string(queue 120 req.Priority = string(queue
|
|||
|
|
.. .PriorityNormal) ... .PriorityNormal)
|
|||
|
|
87 } 121 }
|
|||
|
|
88 if !queue.Priority(req.Priority 122 if !queue.Priority(req.Priority
|
|||
|
|
.. ).Valid() { ... ).Valid() {
|
|||
|
|
89 writeError(w, http.StatusBa 123 return speakAdmission{statu
|
|||
|
|
.. dRequest, fmt.Sprintf("priority: un ... s: http.StatusBadRequest,
|
|||
|
|
.. gültiger Wert %q (erlaubt: low, nor ...
|
|||
|
|
.. mal, high)", req.Priority)) ...
|
|||
|
|
90 return 124 errMsg: fmt.Sprintf("pr
|
|||
|
|
.. ... iority: ungültiger Wert %q (erlaubt
|
|||
|
|
.. ... : low, normal, high)", req.Priority
|
|||
|
|
.. ... )}
|
|||
|
|
91 } 125 }
|
|||
|
|
92 if req.Provider != "" && !h.kno 126 if req.Provider != "" && !h.kno
|
|||
|
|
.. wn[req.Provider] { ... wn[req.Provider] {
|
|||
|
|
93 writeError(w, http.StatusBa 127 return speakAdmission{statu
|
|||
|
|
.. dRequest, fmt.Sprintf("provider: un ... s: http.StatusBadRequest,
|
|||
|
|
.. bekannter Wert %q", req.Provider)) ...
|
|||
|
|
94 return 128 errMsg: fmt.Sprintf("pr
|
|||
|
|
.. ... ovider: unbekannter Wert %q", req.P
|
|||
|
|
.. ... rovider)}
|
|||
|
|
95 } 129 }
|
|||
|
|
96 if req.Lang != "" && req.Lang ! 130 if req.Lang != "" && req.Lang !
|
|||
|
|
.. = "de" && req.Lang != "en" { ... = "de" && req.Lang != "en" {
|
|||
|
|
97 writeError(w, http.StatusBa 131 return speakAdmission{statu
|
|||
|
|
.. dRequest, fmt.Sprintf("lang: ungült ... s: http.StatusBadRequest,
|
|||
|
|
.. iger Wert %q (erlaubt: de, en)", re ...
|
|||
|
|
.. q.Lang)) ...
|
|||
|
|
98 return 132 errMsg: fmt.Sprintf("la
|
|||
|
|
.. ... ng: ungültiger Wert %q (erlaubt: de
|
|||
|
|
.. ... , en)", req.Lang)}
|
|||
|
|
99 } 133 }
|
|||
|
|
100 134
|
|||
|
|
101 if h.mute.Muted() { 135 if h.mute.Muted() {
|
|||
|
|
102 writeJSON(w, http.StatusAcc 136 return speakAdmission{statu
|
|||
|
|
... epted, SpeakResponse{Muted: true}) ... s: http.StatusAccepted, resp: Speak
|
|||
|
|
... ... Response{Muted: true}}
|
|||
|
|
103 return ...
|
|||
|
|
104 } 137 }
|
|||
|
|
105 138
|
|||
|
|
106 adm, err := h.queue.Enqueue(que 139 adm, err := h.queue.Enqueue(que
|
|||
|
|
... ue.Item{ ... ue.Item{
|
|||
|
|
|
|||
|
|
internal/api/server.go --- 5/7 --- Go
|
|||
|
|
112 Now: req.Now, 145 Now: req.Now,
|
|||
|
|
113 }) 146 })
|
|||
|
|
114 if err != nil { 147 if err != nil {
|
|||
|
|
115 writeError(w, http.StatusSe 148 return speakAdmission{statu
|
|||
|
|
... rviceUnavailable, "enqueue: "+err.E ... s: http.StatusServiceUnavailable, e
|
|||
|
|
... rror()) ... rrMsg: "enqueue: " + err.Error()}
|
|||
|
|
116 return ...
|
|||
|
|
117 } 149 }
|
|||
|
|
118 if adm.Discarded != "" { 150 if adm.Discarded != "" {
|
|||
|
|
119 // Gesetz-Verwurf (Rate-Lim 151 // Gesetz-Verwurf (Rate-Lim
|
|||
|
|
... it-Budget / Ruhezeit): dokumentiert ... it-Budget / Ruhezeit): dokumentiert
|
|||
|
|
... es ... es
|
|||
|
|
120 // Verhalten wie bei Mute — 152 // Verhalten wie bei Mute —
|
|||
|
|
... 202 mit Grund statt Fehler, damit ... 202 mit Grund statt Fehler, damit
|
|||
|
|
121 // Aufrufer keine Sonderbeh 153 // Aufrufer keine Sonderbeh
|
|||
|
|
... andlung brauchen. ... andlung brauchen.
|
|||
|
|
122 writeJSON(w, http.StatusAcc 154 return speakAdmission{statu
|
|||
|
|
... epted, SpeakResponse{Discarded: adm ... s: http.StatusAccepted,
|
|||
|
|
... .Discarded, Toast: adm.Toast}) ...
|
|||
|
|
123 return 155 resp: SpeakResponse{Dis
|
|||
|
|
... ... carded: adm.Discarded, Toast: adm.T
|
|||
|
|
... ... oast}}
|
|||
|
|
124 } 156 }
|
|||
|
|
125 writeJSON(w, http.StatusAccepte 157 return speakAdmission{status: h
|
|||
|
|
... d, SpeakResponse{ID: adm.ID}) ... ttp.StatusAccepted, resp: SpeakResp
|
|||
|
|
... ... onse{ID: adm.ID}}
|
|||
|
|
126 } 158 }
|
|||
|
|
127 159
|
|||
|
|
128 // status behandelt GET /status: Qu 160 // status behandelt GET /status: Qu
|
|||
|
|
... eue-Länge (+ wartende IDs), aktuell ... eue-Länge (+ wartende IDs), aktuell
|
|||
|
|
... es ... es
|
|||
|
|
129 // Item, Mute-State, aktiver Provid 161 // Item, Mute-State, aktiver Provid
|
|||
|
|
... er, Version — und seit Task 05 den ... er, Version — und seit Task 05 den
|
|||
|
|
|
|||
|
|
internal/api/server.go --- 6/7 --- Go
|
|||
|
|
139 171 Queue: len(pending),
|
|||
|
|
140 172 QueueIDs: ids,
|
|||
|
|
141 173 Muted: h.mute.Muted(),
|
|||
|
|
... 174 MCP: true,
|
|||
|
|
... 175 Cors: corsList(h.allowedOrigins),
|
|||
|
|
142 176 }
|
|||
|
|
143 177 if cur, ok := h.queue.Current(); ok {
|
|||
|
|
144 178 st.Current = &CurrentItem{
|
|||
|
|
|
|||
|
|
internal/api/server.go --- 7/7 --- Go
|
|||
|
|
155 189 writeJSON(w, http.StatusOK, st)
|
|||
|
|
156 190 }
|
|||
|
|
... 191
|
|||
|
|
... 192 // corsList kopiert die Erlaubnisliste als JSON-[] statt null: Der Status
|
|||
|
|
... 193 // soll "cors": [] zeigen, wenn die Tür zu ist.
|
|||
|
|
... 194 func corsList(origins []string) []string {
|
|||
|
|
... 195 out := make([]string, len(origins))
|
|||
|
|
... 196 copy(out, origins)
|
|||
|
|
... 197 return out
|
|||
|
|
... 198 }
|
|||
|
|
157 199
|
|||
|
|
158 200 // lawState überführt die Gesetz-Sicht der Queue in die JSON-Form von
|
|||
|
|
159 201 // /status: Zeitstempel nur, wenn sie in der Zukunft liegen (null = Slot
|
|||
|
|
|
|||
|
|
internal/api/types.go --- 1/2 --- Go
|
|||
|
|
55 // Item (null im Leerlauf), Mute-Sta 55 // Item (null im Leerlauf), Mute-Sta
|
|||
|
|
.. te, aktiver Provider, Daemon-Version .. te, aktiver Provider, Daemon-Version
|
|||
|
|
.. . .. .
|
|||
|
|
56 // QueueIDs ist die Ergänzung der wa 56 // QueueIDs ist die Ergänzung der wa
|
|||
|
|
.. rtenden IDs in Abarbeitungs-Reihenfo .. rtenden IDs in Abarbeitungs-Reihenfo
|
|||
|
|
.. lge .. lge
|
|||
|
|
57 // — die CLI braucht sie für ihr --w 57 // — die CLI braucht sie für ihr --w
|
|||
|
|
.. ait-Polling. Task 05 ergänzt additiv .. ait-Polling. Task 05 ergänzt additiv
|
|||
|
|
58 // den Gesetz-State (rate_limit, qui 58 // den Gesetz-State (rate_limit, qui
|
|||
|
|
.. et_hours). .. et_hours); Task 06 additiv mcp/cors
|
|||
|
|
.. 59 // (Compat-Felder: MCP-Endpoint vorh
|
|||
|
|
.. .. anden, konfigurierte CORS-Origins).
|
|||
|
|
59 type StatusResponse struct { 60 type StatusResponse struct {
|
|||
|
|
60 Version string `jso 61 Version string `jso
|
|||
|
|
.. n:"version"` .. n:"version"`
|
|||
|
|
61 Queue int `jso 62 Queue int `jso
|
|||
|
|
.. n:"queue"` .. n:"queue"`
|
|||
|
|
|
|||
|
|
internal/api/types.go --- 2/2 --- Go
|
|||
|
|
66 67 RateLimit *RateLimitState `json:"rate_limit,omitempty"`
|
|||
|
|
67 68 QuietHours *QuietHoursState `json:"quiet_hours,omitempty"`
|
|||
|
|
.. 69
|
|||
|
|
.. 70 // MCP meldet, dass der Daemon den MCP-Endpoint anbietet (immer true;
|
|||
|
|
.. 71 // Feld existiert für additive Compat-Sicht von Clients/Browsern).
|
|||
|
|
.. 72 MCP bool `json:"mcp"`
|
|||
|
|
.. 73
|
|||
|
|
.. 74 // Cors ist die konfigurierte CORS-Erlaubnisliste von POST /speak
|
|||
|
|
.. 75 // (leeres [] = Tür zu). Immer vorhanden (auch leer), nicht omitempty.
|
|||
|
|
.. 76 Cors []string `json:"cors"`
|
|||
|
|
68 77 }
|
|||
|
|
69 78
|
|||
|
|
70 79 // RateLimitState ist der Rate-Limit-State in GET /status (Task 05).
|
|||
|
|
|
|||
|
|
internal/config/config.go --- 1/5 --- Go
|
|||
|
|
6 6 "os"
|
|||
|
|
7 7 "path/filepath"
|
|||
|
|
8 8 "strconv"
|
|||
|
|
. 9 "strings"
|
|||
|
|
9 10 "time"
|
|||
|
|
10 11
|
|||
|
|
11 12 "github.com/BurntSushi/toml"
|
|||
|
|
|
|||
|
|
internal/config/config.go --- 2/5 --- Go
|
|||
|
|
86 87 // `[server] port = 7800`, Env-Override TALK_PORT, Default 7800.
|
|||
|
|
87 88 Port int `toml:"port"`
|
|||
|
|
.. 89
|
|||
|
|
.. 90 // AllowedOrigins ist die CORS-Erlaubnisliste für POST /speak (Task 06):
|
|||
|
|
.. 91 // exakte Origins wie "https://chat.az-gruppe.com". Der Eintrag "*"
|
|||
|
|
.. 92 // erlaubt jede Origin ausdrücklich. Bewusster Default: LEER — ohne
|
|||
|
|
.. 93 // Einträge sendet der Daemon gar keine CORS-Header und der Browser
|
|||
|
|
.. 94 // blockt Cross-Origin-Anfragen; die Tür ist zu, bis sie konfiguriert
|
|||
|
|
.. 95 // wird (Env-Override TALK_ALLOWED_ORIGINS, kommagetrennt; "off" leert).
|
|||
|
|
.. 96 AllowedOrigins []string `toml:"allowed_origins"`
|
|||
|
|
88 97 }
|
|||
|
|
89 98
|
|||
|
|
90 99 // Queue sind die Rate-Limit-Gesetze des Daemons (TOML `[queue]`).
|
|||
|
|
|
|||
|
|
internal/config/config.go --- 3/5 --- Go
|
|||
|
|
178 187 }
|
|||
|
|
179 188 cfg.Server.Port = port
|
|||
|
|
180 189 }
|
|||
|
|
... 190 if v := getenv("TALK_ALLOWED_ORIGINS"); v != "" {
|
|||
|
|
... 191 if v == "off" {
|
|||
|
|
... 192 cfg.Server.AllowedOrigins = nil
|
|||
|
|
... 193 } else {
|
|||
|
|
... 194 origins, err := splitOrigins(v)
|
|||
|
|
... 195 if err != nil {
|
|||
|
|
... 196 return cfg, fmt.Errorf("TALK_ALLOWED_ORIGINS: %w", err)
|
|||
|
|
... 197 }
|
|||
|
|
... 198 cfg.Server.AllowedOrigins = origins
|
|||
|
|
... 199 }
|
|||
|
|
... 200 }
|
|||
|
|
181 201 if v := getenv("TALK_RATE_LIMIT"); v != "" {
|
|||
|
|
182 202 d, err := time.ParseDuration(v)
|
|||
|
|
183 203 if err != nil || d < 0 {
|
|||
|
|
|
|||
|
|
internal/config/config.go --- 4/5 --- Go
|
|||
|
|
215 235 return cfg, fmt.Errorf("audio.sink: ungültiger Wert %q (erlaubt: %q, %q)",
|
|||
|
|
216 236 cfg.Audio.Sink, SinkOto, SinkNull)
|
|||
|
|
217 237 }
|
|||
|
|
... 238 // allowed_origins aus der Datei prüfen (Env wurde oben geparst): Eine
|
|||
|
|
... 239 // leere Liste ist gültig (Tür zu), einzelne Einträge müssen aber
|
|||
|
|
... 240 // substanziell sein — klarer Fehler statt stiller Ignoranz.
|
|||
|
|
... 241 for _, o := range cfg.Server.AllowedOrigins {
|
|||
|
|
... 242 if strings.TrimSpace(o) == "" {
|
|||
|
|
... 243 return cfg, fmt.Errorf("server.allowed_origins: leerer Eintrag in %v", cfg.Server.AllowedOrigins)
|
|||
|
|
... 244 }
|
|||
|
|
... 245 }
|
|||
|
|
218 246 // quiet_hours aus der Datei validieren (Env wurde oben geprüft) und
|
|||
|
|
219 247 // quiet_mode leer (Datei ohne Wert) auf den Default heben.
|
|||
|
|
220 248 if cfg.QuietHours != "" {
|
|||
|
|
|
|||
|
|
internal/config/config.go --- 5/5 --- Go
|
|||
|
|
228 256 return cfg, nil
|
|||
|
|
229 257 }
|
|||
|
|
258
|
|||
|
|
259 // splitOrigins zerlegt eine kommagetrennte Origin-Liste (Env-Form von
|
|||
|
|
260 // server.allowed_origins, z. B. "https://a.example, https://b.example").
|
|||
|
|
261 // Whitespace um Einträge toleriert, leere Einträge sind ein klarer Fehler.
|
|||
|
|
262 func splitOrigins(v string) ([]string, error) {
|
|||
|
|
263 parts := strings.Split(v, ",")
|
|||
|
|
264 origins := make([]string, 0, len(parts))
|
|||
|
|
265 for _, p := range parts {
|
|||
|
|
266 p = strings.TrimSpace(p)
|
|||
|
|
267 if p == "" {
|
|||
|
|
268 return nil, fmt.Errorf("leerer Eintrag in %q (komma-getrennte Origins oder \"off\")", v)
|
|||
|
|
269 }
|
|||
|
|
270 origins = append(origins, p)
|
|||
|
|
271 }
|
|||
|
|
272 return origins, nil
|
|||
|
|
273 }
|
|||
|
|
|
|||
|
|
internal/config/config_test.go --- Go
|
|||
|
|
356 356 })
|
|||
|
|
357 357 }
|
|||
|
|
... 358
|
|||
|
|
... 359 func TestLoadAllowedOrigins(t *testing.T) {
|
|||
|
|
... 360 writeTOML := func(t *testing.T, content string) string {
|
|||
|
|
... 361 t.Helper()
|
|||
|
|
... 362 path := filepath.Join(t.TempDir(), "config.toml")
|
|||
|
|
... 363 if err := os.WriteFile(path, []byte(content), 0o644); err != nil {
|
|||
|
|
... 364 t.Fatal(err)
|
|||
|
|
... 365 }
|
|||
|
|
... 366 return path
|
|||
|
|
... 367 }
|
|||
|
|
... 368
|
|||
|
|
... 369 t.Run("Default: leere Liste (kein CORS)", func(t *testing.T) {
|
|||
|
|
... 370 cfg, err := Load(Options{
|
|||
|
|
... 371 File: filepath.Join(t.TempDir(), "gibt-es-nicht.toml"),
|
|||
|
|
... 372 Getenv: env(nil),
|
|||
|
|
... 373 })
|
|||
|
|
... 374 if err != nil {
|
|||
|
|
... 375 t.Fatalf("Load: %v", err)
|
|||
|
|
... 376 }
|
|||
|
|
... 377 if len(cfg.Server.AllowedOrigins) != 0 {
|
|||
|
|
... 378 t.Errorf("AllowedOrigins = %v, want leer (Tür zu)", cfg.Server.AllowedOrigins)
|
|||
|
|
... 379 }
|
|||
|
|
... 380 })
|
|||
|
|
... 381
|
|||
|
|
... 382 t.Run("TOML-Liste", func(t *testing.T) {
|
|||
|
|
... 383 cfg, err := Load(Options{
|
|||
|
|
... 384 File: writeTOML(t, "[server]\nallowed_origins = [\"https://chat.az-gruppe.com\"]\n"),
|
|||
|
|
... 385 Getenv: env(nil),
|
|||
|
|
... 386 })
|
|||
|
|
... 387 if err != nil {
|
|||
|
|
... 388 t.Fatalf("Load: %v", err)
|
|||
|
|
... 389 }
|
|||
|
|
... 390 want := []string{"https://chat.az-gruppe.com"}
|
|||
|
|
... 391 if strings.Join(cfg.Server.AllowedOrigins, "|") != strings.Join(want, "|") {
|
|||
|
|
... 392 t.Errorf("AllowedOrigins = %v, want %v", cfg.Server.AllowedOrigins, want)
|
|||
|
|
... 393 }
|
|||
|
|
... 394 })
|
|||
|
|
... 395
|
|||
|
|
... 396 t.Run("TALK_ALLOWED_ORIGINS schlägt Datei (mehrere, mit Leerzeichen)", func(t *testing.T) {
|
|||
|
|
... 397 cfg, err := Load(Options{
|
|||
|
|
... 398 File: writeTOML(t, "[server]\nallowed_origins = [\"https://datei.example\"]\n"),
|
|||
|
|
... 399 Getenv: env(map[string]string{"TALK_ALLOWED_ORIGINS": "https://chat.az-gruppe.com, https://grid.ag"}),
|
|||
|
|
... 400 })
|
|||
|
|
... 401 if err != nil {
|
|||
|
|
... 402 t.Fatalf("Load: %v", err)
|
|||
|
|
... 403 }
|
|||
|
|
... 404 want := []string{"https://chat.az-gruppe.com", "https://grid.ag"}
|
|||
|
|
... 405 if strings.Join(cfg.Server.AllowedOrigins, "|") != strings.Join(want, "|") {
|
|||
|
|
... 406 t.Errorf("AllowedOrigins = %v, want %v", cfg.Server.AllowedOrigins, want)
|
|||
|
|
... 407 }
|
|||
|
|
... 408 })
|
|||
|
|
... 409
|
|||
|
|
... 410 t.Run("TALK_ALLOWED_ORIGINS=off leert die Datei-Liste", func(t *testing.T) {
|
|||
|
|
... 411 cfg, err := Load(Options{
|
|||
|
|
... 412 File: writeTOML(t, "[server]\nallowed_origins = [\"https://datei.example\"]\n"),
|
|||
|
|
... 413 Getenv: env(map[string]string{"TALK_ALLOWED_ORIGINS": "off"}),
|
|||
|
|
... 414 })
|
|||
|
|
... 415 if err != nil {
|
|||
|
|
... 416 t.Fatalf("Load: %v", err)
|
|||
|
|
... 417 }
|
|||
|
|
... 418 if len(cfg.Server.AllowedOrigins) != 0 {
|
|||
|
|
... 419 t.Errorf("AllowedOrigins = %v, want leer (off)", cfg.Server.AllowedOrigins)
|
|||
|
|
... 420 }
|
|||
|
|
... 421 })
|
|||
|
|
... 422
|
|||
|
|
... 423 t.Run("Wildcard ist erlaubter Wert", func(t *testing.T) {
|
|||
|
|
... 424 cfg, err := Load(Options{
|
|||
|
|
... 425 File: filepath.Join(t.TempDir(), "gibt-es-nicht.toml"),
|
|||
|
|
... 426 Getenv: env(map[string]string{"TALK_ALLOWED_ORIGINS": "*"}),
|
|||
|
|
... 427 })
|
|||
|
|
... 428 if err != nil {
|
|||
|
|
... 429 t.Fatalf("Load: %v", err)
|
|||
|
|
... 430 }
|
|||
|
|
... 431 if strings.Join(cfg.Server.AllowedOrigins, "|") != "*" {
|
|||
|
|
... 432 t.Errorf("AllowedOrigins = %v, want [*]", cfg.Server.AllowedOrigins)
|
|||
|
|
... 433 }
|
|||
|
|
... 434 })
|
|||
|
|
... 435
|
|||
|
|
... 436 t.Run("leere Einträge sind klare Fehler", func(t *testing.T) {
|
|||
|
|
... 437 for _, tc := range []struct {
|
|||
|
|
... 438 name string
|
|||
|
|
... 439 file string
|
|||
|
|
... 440 envv string
|
|||
|
|
... 441 }{
|
|||
|
|
... 442 {"env", "", "https://a.example,,https://b.example"},
|
|||
|
|
... 443 {"datei", "[server]\nallowed_origins = [\"https://a.example\", \"\"]\n", ""},
|
|||
|
|
... 444 } {
|
|||
|
|
... 445 t.Run(tc.name, func(t *testing.T) {
|
|||
|
|
... 446 file := filepath.Join(t.TempDir(), "gibt-es-nicht.toml")
|
|||
|
|
... 447 if tc.file != "" {
|
|||
|
|
... 448 file = writeTOML(t, tc.file)
|
|||
|
|
... 449 }
|
|||
|
|
... 450 _, err := Load(Options{
|
|||
|
|
... 451 File: file,
|
|||
|
|
... 452 Getenv: env(map[string]string{"TALK_ALLOWED_ORIGINS": tc.envv}),
|
|||
|
|
... 453 })
|
|||
|
|
... 454 if err == nil || !strings.Contains(err.Error(), "allowed_origins") && !strings.Contains(err.Error(), "ALLOWED_ORIGINS") {
|
|||
|
|
... 455 t.Fatalf("Fehler = %v, will klaren allowed_origins-Fehler", err)
|
|||
|
|
... 456 }
|
|||
|
|
... 457 })
|
|||
|
|
... 458 }
|
|||
|
|
... 459 })
|
|||
|
|
... 460 }
|
|||
|
|
358 461
|
|||
|
|
359 462 // TestLoadGesetze deckt die Task-05-Felder ab: Rate-Limit (Default 30 s,
|
|||
|
|
360 463 // "0s" = unbegrenzt), High-Burst (Default an), Ruhezeiten (Default aus;
|
|||
|
|
|
|||
|
|
test/cors_test.go --- Go
|
|||
|
|
1 package test
|
|||
|
|
2
|
|||
|
|
3 // Prozessgrenzen-Tests für die CORS-Tür (Task 06): OPTIONS-Preflight und
|
|||
|
|
4 // echter POST auf /speak mit Headern aus der Config-Liste
|
|||
|
|
5 // TALK_ALLOWED_ORIGINS — beobachtet als Black Box über rohe HTTP-Requests.
|
|||
|
|
6 // Bindend: exakte Origins; "*" nur explizit; leere Liste = gar keine
|
|||
|
|
7 // CORS-Header (der Browser blockt); CORS nur auf /speak.
|
|||
|
|
8
|
|||
|
|
9 import (
|
|||
|
|
10 "net/http"
|
|||
|
|
11 "testing"
|
|||
|
|
12 )
|
|||
|
|
13
|
|||
|
|
14 const azOrigin = "https://chat.az-gruppe.com"
|
|||
|
|
15
|
|||
|
|
16 // TestCORSPraeflightGelisted (Abnahmekriterium): Preflight von gelisteter
|
|||
|
|
17 // Origin → 204 mit Freigabe-Headern; ungeliste Origin → 204 OHNE
|
|||
|
|
18 // Access-Control-Allow-Origin.
|
|||
|
|
19 func TestCORSPraeflightGelisted(t *testing.T) {
|
|||
|
|
20 root := t.TempDir()
|
|||
|
|
21 fake := newFakeProvider(t, 0)
|
|||
|
|
22 d := startDaemon(t, root, fake.srv.URL, map[string]string{
|
|||
|
|
23 "TALK_ALLOWED_ORIGINS": azOrigin,
|
|||
|
|
24 })
|
|||
|
|
25
|
|||
|
|
26 raw := preflight(t, d, azOrigin)
|
|||
|
|
27 if raw.status != http.StatusNoContent {
|
|||
|
|
28 t.Fatalf("Preflight-Status = %d, want 204", raw.status)
|
|||
|
|
29 }
|
|||
|
|
30 if got := raw.header.Get("Access-Control-Allow-Origin"); got != azOrigin {
|
|||
|
|
31 t.Errorf("ACAO = %q, want %q", got, azOrigin)
|
|||
|
|
32 }
|
|||
|
|
33 if methods := raw.header.Get("Access-Control-Allow-Methods"); methods != "POST, OPTIONS" {
|
|||
|
|
34 t.Errorf("Access-Control-Allow-Methods = %q, want %q", methods, "POST, OPTIONS")
|
|||
|
|
35 }
|
|||
|
|
36 if headers := raw.header.Get("Access-Control-Allow-Headers"); headers != "Content-Type" {
|
|||
|
|
37 t.Errorf("Access-Control-Allow-Headers = %q, want %q", headers, "Content-Type")
|
|||
|
|
38 }
|
|||
|
|
39 if vary := raw.header.Get("Vary"); vary != "Origin" {
|
|||
|
|
40 t.Errorf("Vary = %q, want Origin (Cache-Korrektheit)", vary)
|
|||
|
|
41 }
|
|||
|
|
42
|
|||
|
|
43 // Ungelistete Origin: sauberer 204, aber bewusst ohne CORS-Header —
|
|||
|
|
44 // der Browser verwirft den geplanten POST, der Daemon bleibt erreichbar.
|
|||
|
|
45 raw = preflight(t, d, "https://evil.example")
|
|||
|
|
46 if raw.status != http.StatusNoContent {
|
|||
|
|
47 t.Fatalf("Preflight (ungelisted) Status = %d, want 204", raw.status)
|
|||
|
|
48 }
|
|||
|
|
49 if got := raw.header.Get("Access-Control-Allow-Origin"); got != "" {
|
|||
|
|
50 t.Errorf("ACAO für ungeliste Origin = %q, want leer", got)
|
|||
|
|
51 }
|
|||
|
|
52 }
|
|||
|
|
53
|
|||
|
|
54 // TestCORSPostGelisted: Auch der echte (non-preflight) POST trägt ACAO,
|
|||
|
|
55 // wenn die Anfrage-Origin gelistet ist — sonst nicht.
|
|||
|
|
56 func TestCORSPostGelisted(t *testing.T) {
|
|||
|
|
57 root := t.TempDir()
|
|||
|
|
58 fake := newFakeProvider(t, 0)
|
|||
|
|
59 d := startDaemon(t, root, fake.srv.URL, map[string]string{
|
|||
|
|
60 "TALK_ALLOWED_ORIGINS": azOrigin,
|
|||
|
|
61 })
|
|||
|
|
62
|
|||
|
|
63 raw := doRequest(t, http.MethodPost, d.baseURL+"/speak", map[string]string{
|
|||
|
|
64 "Origin": azOrigin,
|
|||
|
|
65 "Content-Type": "application/json",
|
|||
|
|
66 }, `{"text":"browser sagt hallo"}`)
|
|||
|
|
67 if raw.status != http.StatusAccepted {
|
|||
|
|
68 t.Fatalf("POST /speak Status = %d, want 202", raw.status)
|
|||
|
|
69 }
|
|||
|
|
70 if got := raw.header.Get("Access-Control-Allow-Origin"); got != azOrigin {
|
|||
|
|
71 t.Errorf("ACAO auf POST = %q, want %q", got, azOrigin)
|
|||
|
|
72 }
|
|||
|
|
73 fake.waitCompleted(t, 1)
|
|||
|
|
74
|
|||
|
|
75 raw = doRequest(t, http.MethodPost, d.baseURL+"/speak", map[string]string{
|
|||
|
|
76 "Origin": "https://evil.example",
|
|||
|
|
77 "Content-Type": "application/json",
|
|||
|
|
78 }, `{"text":"böser browser"}`)
|
|||
|
|
79 if raw.status != http.StatusAccepted {
|
|||
|
|
80 t.Fatalf("POST /speak (ungelisted) Status = %d, want 202 (HTTP bleibt sauber)", raw.status)
|
|||
|
|
81 }
|
|||
|
|
82 if got := raw.header.Get("Access-Control-Allow-Origin"); got != "" {
|
|||
|
|
83 t.Errorf("ACAO für ungeliste Origin = %q, want leer", got)
|
|||
|
|
84 }
|
|||
|
|
85 }
|
|||
|
|
86
|
|||
|
|
87 // TestCORSLeereListe: Ohne allowed_origins (Default) sendet der Daemon
|
|||
|
|
88 // GAR KEINE CORS-Header — Tür zu, der Browser blockt.
|
|||
|
|
89 func TestCORSLeereListe(t *testing.T) {
|
|||
|
|
90 root := t.TempDir()
|
|||
|
|
91 fake := newFakeProvider(t, 0)
|
|||
|
|
92 d := startDaemon(t, root, fake.srv.URL, nil) // keine Origins konfiguriert
|
|||
|
|
93
|
|||
|
|
94 raw := preflight(t, d, azOrigin)
|
|||
|
|
95 if raw.status != http.StatusNoContent {
|
|||
|
|
96 t.Fatalf("Preflight-Status = %d, want 204", raw.status)
|
|||
|
|
97 }
|
|||
|
|
98 for _, h := range []string{
|
|||
|
|
99 "Access-Control-Allow-Origin",
|
|||
|
|
100 "Access-Control-Allow-Methods",
|
|||
|
|
101 "Access-Control-Allow-Headers",
|
|||
|
|
102 } {
|
|||
|
|
103 if got := raw.header.Get(h); got != "" {
|
|||
|
|
104 t.Errorf("%s = %q bei leerer Liste, want nicht gesetzt", h, got)
|
|||
|
|
105 }
|
|||
|
|
106 }
|
|||
|
|
107
|
|||
|
|
108 raw = doRequest(t, http.MethodPost, d.baseURL+"/speak", map[string]string{
|
|||
|
|
109 "Origin": azOrigin,
|
|||
|
|
110 "Content-Type": "application/json",
|
|||
|
|
111 }, `{"text":"ohne cors"}`)
|
|||
|
|
112 if raw.status != http.StatusAccepted {
|
|||
|
|
113 t.Fatalf("POST /speak Status = %d, want 202", raw.status)
|
|||
|
|
114 }
|
|||
|
|
115 if got := raw.header.Get("Access-Control-Allow-Origin"); got != "" {
|
|||
|
|
116 t.Errorf("ACAO auf POST bei leerer Liste = %q, want leer", got)
|
|||
|
|
117 }
|
|||
|
|
118 }
|
|||
|
|
119
|
|||
|
|
120 // TestCORSWildcard: Der Eintrag "*" erlaubt jede Origin ausdrücklich —
|
|||
|
|
121 // ACAO ist dann das literale "*".
|
|||
|
|
122 func TestCORSWildcard(t *testing.T) {
|
|||
|
|
123 root := t.TempDir()
|
|||
|
|
124 fake := newFakeProvider(t, 0)
|
|||
|
|
125 d := startDaemon(t, root, fake.srv.URL, map[string]string{
|
|||
|
|
126 "TALK_ALLOWED_ORIGINS": "*",
|
|||
|
|
127 })
|
|||
|
|
128
|
|||
|
|
129 for _, origin := range []string{"https://irgendwo.example", "http://localhost:5173"} {
|
|||
|
|
130 raw := preflight(t, d, origin)
|
|||
|
|
131 if raw.status != http.StatusNoContent {
|
|||
|
|
132 t.Fatalf("Preflight von %s: Status = %d, want 204", origin, raw.status)
|
|||
|
|
133 }
|
|||
|
|
134 if got := raw.header.Get("Access-Control-Allow-Origin"); got != "*" {
|
|||
|
|
135 t.Errorf("ACAO von %s = %q, want *", origin, got)
|
|||
|
|
136 }
|
|||
|
|
137 }
|
|||
|
|
138 }
|
|||
|
|
139
|
|||
|
|
140 // TestCORSNurAufSpeak: Kein anderer Endpunkt trägt CORS-Header — OPTIONS
|
|||
|
|
141 // auf /status ist 405 (kein Preflight-Support), GET /status ohne ACAO.
|
|||
|
|
142 func TestCORSNurAufSpeak(t *testing.T) {
|
|||
|
|
143 root := t.TempDir()
|
|||
|
|
144 fake := newFakeProvider(t, 0)
|
|||
|
|
145 d := startDaemon(t, root, fake.srv.URL, map[string]string{
|
|||
|
|
146 "TALK_ALLOWED_ORIGINS": azOrigin,
|
|||
|
|
147 })
|
|||
|
|
148
|
|||
|
|
149 raw := doRequest(t, http.MethodOptions, d.baseURL+"/status", map[string]string{
|
|||
|
|
150 "Origin": azOrigin,
|
|||
|
|
151 "Access-Control-Request-Method": http.MethodGet,
|
|||
|
|
152 }, "")
|
|||
|
|
153 if raw.status != http.StatusMethodNotAllowed {
|
|||
|
|
154 t.Fatalf("OPTIONS /status Status = %d, want 405 (kein CORS-Support)", raw.status)
|
|||
|
|
155 }
|
|||
|
|
156 if got := raw.header.Get("Access-Control-Allow-Origin"); got != "" {
|
|||
|
|
157 t.Errorf("OPTIONS /status ACAO = %q, want leer", got)
|
|||
|
|
158 }
|
|||
|
|
159
|
|||
|
|
160 raw = doRequest(t, http.MethodGet, d.baseURL+"/status", map[string]string{
|
|||
|
|
161 "Origin": azOrigin,
|
|||
|
|
162 }, "")
|
|||
|
|
163 if raw.status != http.StatusOK {
|
|||
|
|
164 t.Fatalf("GET /status Status = %d, want 200", raw.status)
|
|||
|
|
165 }
|
|||
|
|
166 if got := raw.header.Get("Access-Control-Allow-Origin"); got != "" {
|
|||
|
|
167 t.Errorf("GET /status ACAO = %q, want leer (CORS nur auf /speak)", got)
|
|||
|
|
168 }
|
|||
|
|
169 }
|
|||
|
|
170
|
|||
|
|
171 // TestStatusCorsFeld: GET /status trägt die CORS-Origins als cors-Feld —
|
|||
|
|
172 // gelistet als [], wenn die Tür zu ist (Compat-Anforderung Task 06).
|
|||
|
|
173 func TestStatusCorsFeld(t *testing.T) {
|
|||
|
|
174 t.Run("gelistet", func(t *testing.T) {
|
|||
|
|
175 root := t.TempDir()
|
|||
|
|
176 fake := newFakeProvider(t, 0)
|
|||
|
|
177 d := startDaemon(t, root, fake.srv.URL, map[string]string{
|
|||
|
|
178 "TALK_ALLOWED_ORIGINS": azOrigin + ", https://grid.ag",
|
|||
|
|
179 })
|
|||
|
|
180 st := getStatus(t, d)
|
|||
|
|
181 if len(st.Cors) != 2 || st.Cors[0] != azOrigin || st.Cors[1] != "https://grid.ag" {
|
|||
|
|
182 t.Errorf("cors = %v, want beide gelisteten Origins", st.Cors)
|
|||
|
|
183 }
|
|||
|
|
184 })
|
|||
|
|
185
|
|||
|
|
186 t.Run("leer = [] (nicht null)", func(t *testing.T) {
|
|||
|
|
187 root := t.TempDir()
|
|||
|
|
188 fake := newFakeProvider(t, 0)
|
|||
|
|
189 d := startDaemon(t, root, fake.srv.URL, nil)
|
|||
|
|
190 st := getStatus(t, d)
|
|||
|
|
191 if st.Cors == nil || len(st.Cors) != 0 {
|
|||
|
|
192 t.Errorf("cors = %v, want [] bei geschlossener Tür", st.Cors)
|
|||
|
|
193 }
|
|||
|
|
194 })
|
|||
|
|
195 }
|
|||
|
|
196
|
|||
|
|
|
|||
|
|
test/harness_test.go --- 1/3 --- Go
|
|||
|
|
13 13 "bytes"
|
|||
|
|
14 14 "encoding/json"
|
|||
|
|
15 15 "fmt"
|
|||
|
|
.. 16 "io"
|
|||
|
|
16 17 "net"
|
|||
|
|
17 18 "net/http"
|
|||
|
|
18 19 "net/http/httptest"
|
|||
|
|
|
|||
|
|
test/harness_test.go --- 2/3 --- Go
|
|||
|
|
403 404 } `json:"current"`
|
|||
|
|
404 405 Muted bool `json:"muted"`
|
|||
|
|
405 406 Provider string `json:"provider"`
|
|||
|
|
... 407 MCP bool `json:"mcp"`
|
|||
|
|
... 408 Cors []string `json:"cors"`
|
|||
|
|
406 409 }
|
|||
|
|
407 410
|
|||
|
|
408 411 // getStatus ruft GET /status auf (Deadline: eine Sekunde — der Daemon ist
|
|||
|
|
|
|||
|
|
test/harness_test.go --- 3/3 --- Go
|
|||
|
|
472 475 return resp.StatusCode, m
|
|||
|
|
473 476 }
|
|||
|
|
477
|
|||
|
|
478 // rawResponse ist eine vollständig gelesene HTTP-Antwort (Status, Header,
|
|||
|
|
479 // Body als String) — Beobachtungsform der rohen CORS-/MCP-Tests.
|
|||
|
|
480 type rawResponse struct {
|
|||
|
|
481 status int
|
|||
|
|
482 header http.Header
|
|||
|
|
483 body string
|
|||
|
|
484 }
|
|||
|
|
485
|
|||
|
|
486 // doRequest macht einen rohen HTTP-Request mit beliebigen Headern und
|
|||
|
|
487 // optionalem Body und liest die Antwort komplett (CORS-Preflights und
|
|||
|
|
488 // MCP-JSON-RPC-Payloads sind literal, kein Client-SDK dazwischen).
|
|||
|
|
489 func doRequest(t *testing.T, method, url string, headers map[string]string, body string) rawResponse {
|
|||
|
|
490 t.Helper()
|
|||
|
|
491 var rd io.Reader
|
|||
|
|
492 if body != "" {
|
|||
|
|
493 rd = strings.NewReader(body)
|
|||
|
|
494 }
|
|||
|
|
495 req, err := http.NewRequest(method, url, rd)
|
|||
|
|
496 if err != nil {
|
|||
|
|
497 t.Fatalf("%s %s bauen: %v", method, url, err)
|
|||
|
|
498 }
|
|||
|
|
499 for k, v := range headers {
|
|||
|
|
500 req.Header.Set(k, v)
|
|||
|
|
501 }
|
|||
|
|
502 resp, err := http.DefaultClient.Do(req)
|
|||
|
|
503 if err != nil {
|
|||
|
|
504 t.Fatalf("%s %s: %v", method, url, err)
|
|||
|
|
505 }
|
|||
|
|
506 defer resp.Body.Close()
|
|||
|
|
507 raw, err := io.ReadAll(resp.Body)
|
|||
|
|
508 if err != nil {
|
|||
|
|
509 t.Fatalf("%s %s Antwort lesen: %v", method, url, err)
|
|||
|
|
510 }
|
|||
|
|
511 return rawResponse{status: resp.StatusCode, header: resp.Header, body: string(raw)}
|
|||
|
|
512 }
|
|||
|
|
513
|
|||
|
|
514 // preflight macht einen OPTIONS-Preflight gegen /speak (so wie ein Browser
|
|||
|
|
515 // es vor einem echten POST tut).
|
|||
|
|
516 func preflight(t *testing.T, d *daemonProc, origin string) rawResponse {
|
|||
|
|
517 t.Helper()
|
|||
|
|
518 return doRequest(t, http.MethodOptions, d.baseURL+"/speak", map[string]string{
|
|||
|
|
519 "Origin": origin,
|
|||
|
|
520 "Access-Control-Request-Method": http.MethodPost,
|
|||
|
|
521 }, "")
|
|||
|
|
522 }
|
|||
|
|
523
|
|||
|
|
524 // mcpPost POSTet einen literalen JSON-RPC-Payload an /mcp.
|
|||
|
|
525 func mcpPost(t *testing.T, d *daemonProc, payload string) rawResponse {
|
|||
|
|
526 t.Helper()
|
|||
|
|
527 return doRequest(t, http.MethodPost, d.baseURL+"/mcp", map[string]string{
|
|||
|
|
528 "Content-Type": "application/json",
|
|||
|
|
529 }, payload)
|
|||
|
|
530 }
|
|||
|
|
531
|
|||
|
|
532 // mcpDecode dekodiert eine JSON-RPC-Antwort in eine generische Map (für
|
|||
|
|
533 // Feld-Zugriffe der literalen Payload-Tests).
|
|||
|
|
534 func mcpDecode(t *testing.T, raw rawResponse) map[string]any {
|
|||
|
|
535 t.Helper()
|
|||
|
|
536 var m map[string]any
|
|||
|
|
537 if err := json.Unmarshal([]byte(raw.body), &m); err != nil {
|
|||
|
|
538 t.Fatalf("JSON-RPC-Antwort dekodieren: %v — Body: %s", err, raw.body)
|
|||
|
|
539 }
|
|||
|
|
540 return m
|
|||
|
|
541 }
|
|||
|
|
|
|||
|
|
test/mcp_test.go --- Go
|
|||
|
|
1 package test
|
|||
|
|
2
|
|||
|
|
3 // Prozessgrenzen-Tests für den MCP-Endpoint (Task 06): der komplette
|
|||
|
|
4 // Handshake (initialize → notifications/initialized → tools/list →
|
|||
|
|
5 // tools/call) als rohe JSON-RPC-HTTP-Payloads gegen einen echten talkd —
|
|||
|
|
6 // literal, ohne Client-SDK. tools/call muss sich exakt wie POST /speak
|
|||
|
|
7 // verhalten: dieselbe Queue-Aufnahme inklusive Mute und Gesetz.
|
|||
|
|
8
|
|||
|
|
9 import (
|
|||
|
|
10 "fmt"
|
|||
|
|
11 "net/http"
|
|||
|
|
12 "strings"
|
|||
|
|
13 "testing"
|
|||
|
|
14 "time"
|
|||
|
|
15 )
|
|||
|
|
16
|
|||
|
|
17 // mcpToolText liest content[0].text aus einem tools/call-Ergebnis.
|
|||
|
|
18 func mcpToolText(t *testing.T, m map[string]any) string {
|
|||
|
|
19 t.Helper()
|
|||
|
|
20 result, ok := m["result"].(map[string]any)
|
|||
|
|
21 if !ok {
|
|||
|
|
22 t.Fatalf("tools/call ohne result: %v", m)
|
|||
|
|
23 }
|
|||
|
|
24 content, ok := result["content"].([]any)
|
|||
|
|
25 if !ok || len(content) == 0 {
|
|||
|
|
26 t.Fatalf("tools/call ohne content: %v", result)
|
|||
|
|
27 }
|
|||
|
|
28 first, ok := content[0].(map[string]any)
|
|||
|
|
29 if !ok {
|
|||
|
|
30 t.Fatalf("content[0] ist kein Objekt: %v", content[0])
|
|||
|
|
31 }
|
|||
|
|
32 text, _ := first["text"].(string)
|
|||
|
|
33 return text
|
|||
|
|
34 }
|
|||
|
|
35
|
|||
|
|
36 // mcpIsError liest isError (fehlt = false — bewusste Semantik).
|
|||
|
|
37 func mcpIsError(m map[string]any) bool {
|
|||
|
|
38 if result, ok := m["result"].(map[string]any); ok {
|
|||
|
|
39 if e, ok := result["isError"].(bool); ok {
|
|||
|
|
40 return e
|
|||
|
|
41 }
|
|||
|
|
42 }
|
|||
|
|
43 return false
|
|||
|
|
44 }
|
|||
|
|
45
|
|||
|
|
46 // TestMCPHandshake (Abnahmekriterium): die curl-Sequenz initialize →
|
|||
|
|
47 // notifications/initialized → tools/list → tools/call sieht das speak-Tool
|
|||
|
|
48 // und enqueued erfolgreich — die Ansage läuft durch Queue und Fake-Provider.
|
|||
|
|
49 func TestMCPHandshake(t *testing.T) {
|
|||
|
|
50 root := t.TempDir()
|
|||
|
|
51 fake := newFakeProvider(t, 0)
|
|||
|
|
52 d := startDaemon(t, root, fake.srv.URL, nil)
|
|||
|
|
53
|
|||
|
|
54 // 1. initialize: angefragte Version wird geechot; capabilities nennen
|
|||
|
|
55 // tools; serverInfo ist talkd.
|
|||
|
|
56 raw := mcpPost(t, d, `{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18","capabilities":{},"clientInfo":{"name":"curl-test","version":"0"}}}`)
|
|||
|
|
57 if raw.status != http.StatusOK {
|
|||
|
|
58 t.Fatalf("initialize: Status %d — Body: %s", raw.status, raw.body)
|
|||
|
|
59 }
|
|||
|
|
60 if ct := raw.header.Get("Content-Type"); !strings.HasPrefix(ct, "application/json") {
|
|||
|
|
61 t.Errorf("initialize Content-Type = %q, want application/json", ct)
|
|||
|
|
62 }
|
|||
|
|
63 m := mcpDecode(t, raw)
|
|||
|
|
64 if m["jsonrpc"] != "2.0" || fmt.Sprint(m["id"]) != "1" {
|
|||
|
|
65 t.Fatalf("initialize envelope = %v (jsonrpc/id prüfen)", m)
|
|||
|
|
66 }
|
|||
|
|
67 result, ok := m["result"].(map[string]any)
|
|||
|
|
68 if !ok {
|
|||
|
|
69 t.Fatalf("initialize ohne result: %v", m)
|
|||
|
|
70 }
|
|||
|
|
71 if result["protocolVersion"] != "2025-06-18" {
|
|||
|
|
72 t.Errorf("protocolVersion = %v, want Echo der angefragten 2025-06-18", result["protocolVersion"])
|
|||
|
|
73 }
|
|||
|
|
74 caps, ok := result["capabilities"].(map[string]any)
|
|||
|
|
75 if !ok {
|
|||
|
|
76 t.Fatalf("capabilities = %v, want Objekt", result["capabilities"])
|
|||
|
|
77 }
|
|||
|
|
78 if _, ok := caps["tools"]; !ok {
|
|||
|
|
79 t.Errorf("capabilities ohne tools: %v", caps)
|
|||
|
|
80 }
|
|||
|
|
81 serverInfo, _ := result["serverInfo"].(map[string]any)
|
|||
|
|
82 if serverInfo["name"] != "talkd" {
|
|||
|
|
83 t.Errorf("serverInfo.name = %v, want talkd", serverInfo["name"])
|
|||
|
|
84 }
|
|||
|
|
85 if v, _ := serverInfo["version"].(string); v == "" {
|
|||
|
|
86 t.Error("serverInfo.version ist leer")
|
|||
|
|
87 }
|
|||
|
|
88
|
|||
|
|
89 // Andere angefragte Version wird ebenso geechot (akzeptiert).
|
|||
|
|
90 raw = mcpPost(t, d, `{"jsonrpc":"2.0","id":2,"method":"initialize","params":{"protocolVersion":"2025-03-26"}}`)
|
|||
|
|
91 m = mcpDecode(t, raw)
|
|||
|
|
92 if m["result"].(map[string]any)["protocolVersion"] != "2025-03-26" {
|
|||
|
|
93 t.Errorf("protocolVersion = %v, want Echo 2025-03-26", m["result"])
|
|||
|
|
94 }
|
|||
|
|
95
|
|||
|
|
96 // 2. notifications/initialized: Notification → 202 ohne Body.
|
|||
|
|
97 raw = mcpPost(t, d, `{"jsonrpc":"2.0","method":"notifications/initialized"}`)
|
|||
|
|
98 if raw.status != http.StatusAccepted {
|
|||
|
|
99 t.Fatalf("notifications/initialized: Status %d, want 202", raw.status)
|
|||
|
|
100 }
|
|||
|
|
101 if raw.body != "" {
|
|||
|
|
102 t.Errorf("notifications/initialized Body = %q, want leer", raw.body)
|
|||
|
|
103 }
|
|||
|
|
104
|
|||
|
|
105 // 3. tools/list: genau das speak-Tool mit dem Brief-Schema.
|
|||
|
|
106 raw = mcpPost(t, d, `{"jsonrpc":"2.0","id":3,"method":"tools/list"}`)
|
|||
|
|
107 m = mcpDecode(t, raw)
|
|||
|
|
108 result = m["result"].(map[string]any)
|
|||
|
|
109 tools, ok := result["tools"].([]any)
|
|||
|
|
110 if !ok || len(tools) != 1 {
|
|||
|
|
111 t.Fatalf("tools = %v, want genau ein Tool", result["tools"])
|
|||
|
|
112 }
|
|||
|
|
113 tool := tools[0].(map[string]any)
|
|||
|
|
114 if tool["name"] != "speak" {
|
|||
|
|
115 t.Errorf("tool.name = %v, want speak", tool["name"])
|
|||
|
|
116 }
|
|||
|
|
117 schema, ok := tool["inputSchema"].(map[string]any)
|
|||
|
|
118 if !ok {
|
|||
|
|
119 t.Fatalf("inputSchema = %v, want Objekt", tool["inputSchema"])
|
|||
|
|
120 }
|
|||
|
|
121 if schema["type"] != "object" {
|
|||
|
|
122 t.Errorf("inputSchema.type = %v, want object", schema["type"])
|
|||
|
|
123 }
|
|||
|
|
124 props := schema["properties"].(map[string]any)
|
|||
|
|
125 for _, name := range []string{"text", "priority", "provider", "lang", "now"} {
|
|||
|
|
126 if _, ok := props[name]; !ok {
|
|||
|
|
127 t.Errorf("inputSchema ohne property %q: %v", name, props)
|
|||
|
|
128 }
|
|||
|
|
129 }
|
|||
|
|
130 if len(props) != 5 {
|
|||
|
|
131 t.Errorf("inputSchema hat %d properties (%v), want genau die 5 aus dem Brief", len(props), props)
|
|||
|
|
132 }
|
|||
|
|
133 if req, ok := schema["required"].([]any); !ok || len(req) != 1 || req[0] != "text" {
|
|||
|
|
134 t.Errorf("required = %v, want [text]", schema["required"])
|
|||
|
|
135 }
|
|||
|
|
136
|
|||
|
|
137 // 4. tools/call: Aufnahme wie POST /speak — "queued: <id>" und die
|
|||
|
|
138 // Ansage taucht am Fake-Provider auf (gleiche Queue, kein Parallelweg).
|
|||
|
|
139 raw = mcpPost(t, d, `{"jsonrpc":"2.0","id":4,"method":"tools/call","params":{"name":"speak","arguments":{"text":"hallo über mcp"}}}`)
|
|||
|
|
140 if raw.status != http.StatusOK {
|
|||
|
|
141 t.Fatalf("tools/call: Status %d — Body: %s", raw.status, raw.body)
|
|||
|
|
142 }
|
|||
|
|
143 m = mcpDecode(t, raw)
|
|||
|
|
144 if fmt.Sprint(m["id"]) != "4" {
|
|||
|
|
145 t.Errorf("tools/call id = %v, want Echo 4", m["id"])
|
|||
|
|
146 }
|
|||
|
|
147 text := mcpToolText(t, m)
|
|||
|
|
148 if !strings.HasPrefix(text, "queued: ") || len(text) <= len("queued: ") {
|
|||
|
|
149 t.Fatalf("tools/call text = %q, want \"queued: <id>\"", text)
|
|||
|
|
150 }
|
|||
|
|
151 if mcpIsError(m) {
|
|||
|
|
152 t.Errorf("tools/call isError = true bei erfolgreicher Aufnahme: %v", m)
|
|||
|
|
153 }
|
|||
|
|
154 hits := fake.waitCompleted(t, 1)
|
|||
|
|
155 if hits[0].Text != "hallo über mcp" {
|
|||
|
|
156 t.Errorf("Fake-Treffer = %q, will die MCP-Ansage", hits[0].Text)
|
|||
|
|
157 }
|
|||
|
|
158 }
|
|||
|
|
159
|
|||
|
|
160 // TestMCPToolsCallGesetze: muted und Gesetz-Verwürfe sind Tool-ERGEBNISSE
|
|||
|
|
161 // (isError:false mit erklärendem Text), keine Protokollfehler — und die
|
|||
|
|
162 // Ansage bleibt stumm.
|
|||
|
|
163 func TestMCPToolsCallGesetze(t *testing.T) {
|
|||
|
|
164 t.Run("muted", func(t *testing.T) {
|
|||
|
|
165 root := t.TempDir()
|
|||
|
|
166 fake := newFakeProvider(t, 0)
|
|||
|
|
167 d := startDaemon(t, root, fake.srv.URL, nil)
|
|||
|
|
168 postPath(t, d, "/mute")
|
|||
|
|
169
|
|||
|
|
170 m := mcpDecode(t, mcpPost(t, d, `{"jsonrpc":"2.0","id":7,"method":"tools/call","params":{"name":"speak","arguments":{"text":"stille bitte"}}}`))
|
|||
|
|
171 if mcpIsError(m) {
|
|||
|
|
172 t.Fatalf("muted ist isError:true — soll Ergebnis sein: %v", m)
|
|||
|
|
173 }
|
|||
|
|
174 if text := mcpToolText(t, m); !strings.HasPrefix(text, "muted") {
|
|||
|
|
175 t.Errorf("text = %q, will muted-Erklärung", text)
|
|||
|
|
176 }
|
|||
|
|
177 fake.assertNoHits(t, 300*time.Millisecond)
|
|||
|
|
178 })
|
|||
|
|
179
|
|||
|
|
180 t.Run("rate_limit verwirft low", func(t *testing.T) {
|
|||
|
|
181 root := t.TempDir()
|
|||
|
|
182 fake := newFakeProvider(t, 0)
|
|||
|
|
183 d := startDaemon(t, root, fake.srv.URL, map[string]string{"TALK_RATE_LIMIT": "30s"})
|
|||
|
|
184
|
|||
|
|
185 // Erste low-Ansage spielt (Fenster frei) und bucht es.
|
|||
|
|
186 m := mcpDecode(t, mcpPost(t, d, `{"jsonrpc":"2.0","id":8,"method":"tools/call","params":{"name":"speak","arguments":{"text":"erste","priority":"low"}}}`))
|
|||
|
|
187 if text := mcpToolText(t, m); !strings.HasPrefix(text, "queued: ") {
|
|||
|
|
188 t.Fatalf("erste low: text = %q, want queued", text)
|
|||
|
|
189 }
|
|||
|
|
190 fake.waitCompleted(t, 1)
|
|||
|
|
191
|
|||
|
|
192 // Zweite low innerhalb des Fensters: Verwurf als Ergebnis.
|
|||
|
|
193 m = mcpDecode(t, mcpPost(t, d, `{"jsonrpc":"2.0","id":9,"method":"tools/call","params":{"name":"speak","arguments":{"text":"zweite","priority":"low"}}}`))
|
|||
|
|
194 if mcpIsError(m) {
|
|||
|
|
195 t.Fatalf("Verwurf ist isError:true — soll Ergebnis sein: %v", m)
|
|||
|
|
196 }
|
|||
|
|
197 if text := mcpToolText(t, m); !strings.HasPrefix(text, "discarded: rate_limit") {
|
|||
|
|
198 t.Errorf("text = %q, want discarded: rate_limit …", text)
|
|||
|
|
199 }
|
|||
|
|
200 fake.assertNoFurtherArrivals(t, 300*time.Millisecond)
|
|||
|
|
201 })
|
|||
|
|
202
|
|||
|
|
203 t.Run("quiet_hours verwirft", func(t *testing.T) {
|
|||
|
|
204 root := t.TempDir()
|
|||
|
|
205 fake := newFakeProvider(t, 0)
|
|||
|
|
206 d := startDaemon(t, root, fake.srv.URL, map[string]string{
|
|||
|
|
207 "TALK_QUIET_HOURS": fensterUm(),
|
|||
|
|
208 })
|
|||
|
|
209
|
|||
|
|
210 m := mcpDecode(t, mcpPost(t, d, `{"jsonrpc":"2.0","id":10,"method":"tools/call","params":{"name":"speak","arguments":{"text":"nachts"}}}`))
|
|||
|
|
211 if mcpIsError(m) {
|
|||
|
|
212 t.Fatalf("Ruhezeit-Verwurf ist isError:true — soll Ergebnis sein: %v", m)
|
|||
|
|
213 }
|
|||
|
|
214 if text := mcpToolText(t, m); !strings.HasPrefix(text, "discarded: quiet_hours") {
|
|||
|
|
215 t.Errorf("text = %q, want discarded: quiet_hours …", text)
|
|||
|
|
216 }
|
|||
|
|
217 fake.assertNoHits(t, 300*time.Millisecond)
|
|||
|
|
218 })
|
|||
|
|
219 }
|
|||
|
|
220
|
|||
|
|
221 // TestMCPFehlerPfade: Validierungsfehler als JSON-RPC-Fehler (-32602),
|
|||
|
|
222 // unbekannte Methode -32601, kaputtes JSON -32700, falsche jsonrpc-Version
|
|||
|
|
223 // und Batches -32600, GET /mcp 405 (keine server-initiierten Streams).
|
|||
|
|
224 func TestMCPFehlerPfade(t *testing.T) {
|
|||
|
|
225 root := t.TempDir()
|
|||
|
|
226 fake := newFakeProvider(t, 0)
|
|||
|
|
227 d := startDaemon(t, root, fake.srv.URL, nil)
|
|||
|
|
228
|
|||
|
|
229 cases := []struct {
|
|||
|
|
230 name string
|
|||
|
|
231 payload string
|
|||
|
|
232 code float64
|
|||
|
|
233 msgPart string
|
|||
|
|
234 }{
|
|||
|
|
235 {"text fehlt", `{"jsonrpc":"2.0","id":21,"method":"tools/call","params":{"name":"speak","arguments":{}}}`, -32602, "text"},
|
|||
|
|
236 {"text leer", `{"jsonrpc":"2.0","id":22,"method":"tools/call","params":{"name":"speak","arguments":{"text":" "}}}`, -32602, "text"},
|
|||
|
|
237 {"ungültige priority", `{"jsonrpc":"2.0","id":23,"method":"tools/call","params":{"name":"speak","arguments":{"text":"x","priority":"mega"}}}`, -32602, "priority"},
|
|||
|
|
238 {"unbekanntes Tool", `{"jsonrpc":"2.0","id":24,"method":"tools/call","params":{"name":"sing","arguments":{"text":"x"}}}`, -32602, "sing"},
|
|||
|
|
239 {"unbekannte Methode", `{"jsonrpc":"2.0","id":25,"method":"resources/list"}`, -32601, "resources/list"},
|
|||
|
|
240 {"kaputtes JSON", `{"jsonrpc":"2.0","id":`, -32700, "JSON"},
|
|||
|
|
241 {"falsche jsonrpc-Version", `{"jsonrpc":"1.0","id":26,"method":"ping"}`, -32600, "2.0"},
|
|||
|
|
242 {"Batch (seit 2025-06-18 entfernt)", `[{"jsonrpc":"2.0","id":27,"method":"ping"}]`, -32600, "Batch"},
|
|||
|
|
243 }
|
|||
|
|
244 for _, tc := range cases {
|
|||
|
|
245 t.Run(tc.name, func(t *testing.T) {
|
|||
|
|
246 raw := mcpPost(t, d, tc.payload)
|
|||
|
|
247 if raw.status != http.StatusOK {
|
|||
|
|
248 t.Fatalf("Status = %d, want 200 (JSON-RPC-Fehler im Body)", raw.status)
|
|||
|
|
249 }
|
|||
|
|
250 m := mcpDecode(t, raw)
|
|||
|
|
251 rpcErr, ok := m["error"].(map[string]any)
|
|||
|
|
252 if !ok {
|
|||
|
|
253 t.Fatalf("kein error-Objekt: %v", m)
|
|||
|
|
254 }
|
|||
|
|
255 if rpcErr["code"] != tc.code {
|
|||
|
|
256 t.Errorf("code = %v, want %v (Body: %s)", rpcErr["code"], tc.code, raw.body)
|
|||
|
|
257 }
|
|||
|
|
258 if msg, _ := rpcErr["message"].(string); !strings.Contains(msg, tc.msgPart) {
|
|||
|
|
259 t.Errorf("message = %q, will Teil %q", msg, tc.msgPart)
|
|||
|
|
260 }
|
|||
|
|
261 if _, hasResult := m["result"]; hasResult {
|
|||
|
|
262 t.Errorf("error-Antwort mit result: %v", m)
|
|||
|
|
263 }
|
|||
|
|
264 })
|
|||
|
|
265 }
|
|||
|
|
266
|
|||
|
|
267 t.Run("ping", func(t *testing.T) {
|
|||
|
|
268 m := mcpDecode(t, mcpPost(t, d, `{"jsonrpc":"2.0","id":28,"method":"ping"}`))
|
|||
|
|
269 if _, ok := m["result"]; !ok {
|
|||
|
|
270 t.Fatalf("ping ohne result: %v", m)
|
|||
|
|
271 }
|
|||
|
|
272 })
|
|||
|
|
273
|
|||
|
|
274 t.Run("GET /mcp → 405 (keine server-initiierten Streams)", func(t *testing.T) {
|
|||
|
|
275 raw := doRequest(t, http.MethodGet, d.baseURL+"/mcp", nil, "")
|
|||
|
|
276 if raw.status != http.StatusMethodNotAllowed {
|
|||
|
|
277 t.Fatalf("GET /mcp Status = %d, want 405", raw.status)
|
|||
|
|
278 }
|
|||
|
|
279 if allow := raw.header.Get("Allow"); !strings.Contains(allow, "POST") {
|
|||
|
|
280 t.Errorf("Allow = %q, will POST nennen", allow)
|
|||
|
|
281 }
|
|||
|
|
282 })
|
|||
|
|
283
|
|||
|
|
284 t.Run("Notification mit unbekannter Methode → still 202", func(t *testing.T) {
|
|||
|
|
285 raw := mcpPost(t, d, `{"jsonrpc":"2.0","method":"notifications/roots/list_changed"}`)
|
|||
|
|
286 if raw.status != http.StatusAccepted || raw.body != "" {
|
|||
|
|
287 t.Fatalf("Status %d Body %q, want 202 ohne Body", raw.status, raw.body)
|
|||
|
|
288 }
|
|||
|
|
289 })
|
|||
|
|
290 }
|
|||
|
|
291
|
|||
|
|
292 // TestStatusMCPFeld: /status meldet mcp:true (Compat-Feld Task 06).
|
|||
|
|
293 func TestStatusMCPFeld(t *testing.T) {
|
|||
|
|
294 root := t.TempDir()
|
|||
|
|
295 fake := newFakeProvider(t, 0)
|
|||
|
|
296 d := startDaemon(t, root, fake.srv.URL, nil)
|
|||
|
|
297 if st := getStatus(t, d); !st.MCP {
|
|||
|
|
298 t.Error("mcp = false, want true (Endpoint ist vorhanden)")
|
|||
|
|
299 }
|
|||
|
|
300 }
|
|||
|
|
301
|
|||
|
|
|