diff --git a/docs/README.md b/docs/README.md index ac341d3..18dcc63 100644 --- a/docs/README.md +++ b/docs/README.md @@ -15,7 +15,7 @@ Monitoring und einen Bugtracker, den Coding-Agenten selbständig bedienen. | **[Änderungen](../public/docs/changelog.json)** | Was sich je Fassung geändert hat und was zu tun ist. Öffentlich unter `/docs/changelog.php`, maschinenlesbar über `GET /api/updateservice/v1/changelog?since=X` | | **[Release-Anleitung für Agenten](../public/docs/release.md)** | Ein Projekt veröffentlichungsfähig machen: Vorlage kopieren, konfigurieren, ausliefern | | **[Linux-Testumgebung](./TESTUMGEBUNG.md)** | **Die geteilte Test-VM.** Wie ein Agent dort selbständig testet: DEV-Lizenz holen, Auslieferung prüfen, Befunde melden — mit `dc-testkit` | -| **[Testinstanz & automatisierter Testlauf](./TESTINSTANZ.md)** | **`dc-dev.mhdf.de` — die zweite Installation zum Kaputtmachen.** 124 Fälle gegen API, Rechte, Migrationen und Zugangssperre (`tests/e2e/run.py`). **`scripts/deploy.py` führt sie vor jedem Produktiv-Upload aus** und bricht bei Fehlschlägen ab (`--ohne-test` umgeht das) | +| **[Testinstanz & automatisierter Testlauf](./TESTINSTANZ.md)** | **`dc-dev.mhdf.de` — die zweite Installation zum Kaputtmachen.** 128 Fälle gegen API, Rechte, Migrationen und Zugangssperre (`tests/e2e/run.py`). **`scripts/deploy.py` führt sie vor jedem Produktiv-Upload aus** und bricht bei Fehlschlägen ab (`--ohne-test` umgeht das) | | [Agent-Prompt-Vorlage](./AGENT_PROMPT_TEMPLATE.md) | Textbaustein für `CLAUDE.md` / `AGENTS.md` eines Projekts | | [Agenten-Handbuch](../public/docs/bugtracker.md) | Vollständige Beschreibung des Bugtracker-Workflows, öffentlich unter `/docs/` | @@ -27,7 +27,7 @@ Monitoring und einen Bugtracker, den Coding-Agenten selbständig bedienen. - **[Erstinstallation](./SETUP_INTEGRATION_GUIDE.md)** — `setup.json`, Installationskonto, `update-agent --action install` - **[Bugtracker](./BUGTRACKER_INTEGRATION_GUIDE.md)** — Anbindung aus Anwendungen heraus - **[Supervisor](./SUPERVISOR_INTEGRATION_GUIDE.md)** — Befunde: was ist ungewöhnlich? Läuft serverseitig auf ohnehin eingehenden Daten, **ohne Einbau in die Anwendungen**. Enthält auch den Rückbau begonnener Vorbereitungen -- **[MCP-Adapter](../mcp/README.md)** — Claude Code und andere Werkzeuge direkt an Bugtracker, Roadmap und Supervisor anschließen +- **[MCP-Adapter](../mcp/README.md)** — Claude Code **und Claude Desktop** an Bugtracker, Roadmap und Supervisor anschließen — 15 Werkzeuge, 4 Ressourcen, 4 Prompts Hintergrund zu Supervisor und MCP, inklusive der Frage, warum der Client keinen Quelltext sieht: **[archiv/SUPERVISOR_MCP_DESIGN.md](./archiv/SUPERVISOR_MCP_DESIGN.md)** diff --git a/mcp/README.md b/mcp/README.md index 0b04ca8..de18fea 100644 --- a/mcp/README.md +++ b/mcp/README.md @@ -34,7 +34,7 @@ dieselbe, liesse sich nicht mehr sagen, wer was gemeldet hat — und das Signal "drei Projekte melden dasselbe" waere wertlos. Also **je Projekt ein eigenes Token**. -**2. In `.mcp.json` des Projekts eintragen:** +**2a. Claude Code — in `.mcp.json` des Projekts:** ```json { @@ -48,11 +48,54 @@ Token**. } ``` +**2b. Claude Desktop — in `claude_desktop_config.json`:** + +Die Datei liegt unter Windows in `%APPDATA%\Claude\`, unter macOS in +`~/Library/Application Support/Claude/`. Gibt es sie noch nicht, anlegen. +Danach **Claude Desktop vollständig beenden und neu starten** — die +Konfiguration wird nur beim Start gelesen, ein Fensterschließen genügt nicht. + +```json +{ + "mcpServers": { + "deploymentcenter": { + "command": "node", + "args": ["J:\\Softwareprojekte\\Deploymentcenter\\mcp\\deploymentcenter-mcp.mjs"], + "env": { + "DC_TOKEN": "dc_...", + "DC_PROJECT": "deploymentcenter" + } + } + } +} +``` + +Zwei Unterschiede zu Claude Code, beide wichtig: + +- **Absoluter Pfad, Backslashes verdoppelt.** Desktop startet den Prozess ohne + Projektverzeichnis; ein relativer Pfad findet nichts. In JSON ist `\` ein + Fluchtzeichen, `C:\pfad` also `C:\\pfad`. Vorwärtsschrägstriche gehen unter + Windows ebenso und ersparen das Verdoppeln. +- **`DC_PROJECT` setzen.** Desktop arbeitet nicht in einem Projekt. Die Angabe + bestimmt, auf welches Projekt die Ressourcen zeigen. Ohne sie nehmen sie das + Heimatprojekt des Tokens — was auch in Ordnung ist, solange man nur eines + betrachtet. + +Findet Desktop `node` nicht, den vollen Pfad eintragen (`where node` unter +Windows, `which node` sonst). `command: "node"` setzt voraus, dass Node im PATH +des Anmeldedienstes steht, und das ist nicht überall so. + +**Prüfen:** Nach dem Neustart erscheinen unten im Eingabefeld die vier +Schrägstrich-Befehle (`arbeit-aufnehmen`, `befunde-sichten`, `fehler-melden`, +`roadmap-pflegen`) und im Anhang-Menü die vier Ressourcen. Fehlt beides, steht +der Grund im Desktop-Log: `%APPDATA%\Claude\logs\mcp-server-deploymentcenter.log`. + Umgebungsvariablen: | Variable | Vorgabe | Zweck | |---|---|---| | `DC_TOKEN` | — | Pflicht. Werkbank-Token. | +| `DC_PROJECT` | Heimatprojekt des Tokens | Auf welches Projekt die Ressourcen zeigen. Für Desktop empfohlen. | | `DC_BASE_URL` | `https://dc.mhdf.de` | Andere Instanz oder lokale Entwicklung. | | `DC_TIMEOUT_MS` | `15000` | Zeitgrenze je Aufruf. | @@ -127,6 +170,40 @@ Endpunkte. Werkzeugdefinitionen liegen dauerhaft im Kontextfenster, anders als eine Doku, die man einmal liest — ein aufgeblaehter Adapter waere schlechter als die Prompt-Vorlage, die er ersetzt. +## Ressourcen und Prompts + +Werkzeuge ruft ein Modell von sich aus auf. Ressourcen und Prompts wählt ein +**Mensch** — in Claude Desktop über das Anhang-Menü bzw. als +Schrägstrich-Befehl. Das ist der Unterschied, der sie lohnend macht. + +**Ressourcen** (lesend, live aus dem Deploymentcenter): + +| URI | Inhalt | +|---|---| +| `dc://roadmap` | Planungsstand als Markdown — derselbe Block, den `cli/roadmap.php` schreibt | +| `dc://naechste-schritte` | Was als Nächstes ansteht und wer gerade woran sitzt | +| `dc://projekte` | Projektlandschaft mit Beziehungen | +| `dc://befunde` | Offene Befunde je Detektor | + +**Prompts** (tragen die Hausregeln mit, nicht nur den Text): + +| Befehl | Wofür | +|---|---| +| `arbeit-aufnehmen` | Erst nachsehen, wer schon dransitzt, dann übernehmen | +| `befunde-sichten` | Befunde einstufen — echt, Artefakt oder Rauschen | +| `fehler-melden` | Ins richtige Projekt melden, mit `evidence` bei fremden | +| `roadmap-pflegen` | Einplanen und zurückstellen, mit der Auslöser-Regel | + +Der Wert der Prompts liegt darin, dass die Regeln mitkommen: dass vor dem +Anfangen nachgesehen wird, dass eine Fremdmeldung eine Begründung braucht, dass +ein verworfenes Vorhaben nicht ohne neues Argument wiederkommt. Ohne sie stehen +diese Regeln nur in der Werkzeugbeschreibung — und die liest ein Modell erst, +wenn es das Werkzeug schon gewählt hat. + +Ressourcen laufen durch dieselbe Abgrenzung wie Werkzeugergebnisse. Sie sind +sogar eher ein Einfallstor: Sie landen ungefragt im Kontext, weil ein Mensch +sie angehängt hat. + ## Was der Agent darf und was nicht | | eigenes Projekt | fremdes Projekt | diff --git a/mcp/deploymentcenter-mcp.mjs b/mcp/deploymentcenter-mcp.mjs index a848ca5..9dd720c 100644 --- a/mcp/deploymentcenter-mcp.mjs +++ b/mcp/deploymentcenter-mcp.mjs @@ -34,9 +34,33 @@ const BASE_URL = (process.env.DC_BASE_URL || 'https://dc.mhdf.de').replace(/\/+$ const TOKEN = process.env.DC_TOKEN || ''; const TIMEOUT_MS = Number(process.env.DC_TIMEOUT_MS || 15000); +// Auf welches Projekt zeigen die Ressourcen? +// +// Leer heisst: das Heimatprojekt des Tokens - der Normalfall, wenn der Adapter +// im Projektverzeichnis laeuft. Zu setzen ist es dort, wo ein Mensch am +// Ueberblick sitzt statt an einem Projekt: In Claude Desktop haengt man die +// Roadmap eines anderen Projekts an, ohne dafuer das Token zu tauschen. +// Werkzeuge nehmen project_slug ohnehin je Aufruf entgegen; Ressourcen haben +// keine Argumente, deshalb hier. +const PROJECT = process.env.DC_PROJECT || ''; + const SERVER_NAME = 'deploymentcenter'; -const SERVER_VERSION = '1.0.0'; -const DEFAULT_PROTOCOL = '2024-11-05'; +const SERVER_VERSION = '1.1.0'; + +// Protokollfassungen, die dieser Adapter tatsaechlich bedient - neueste zuerst. +// +// Zuvor wurde bei "initialize" schlicht zurueckgegeben, was der Client +// verlangte. Das ist keine Aushandlung, sondern ein Echo: Ein Client, der +// 2025-06-18 anfragt, bekam 2025-06-18 bestaetigt und durfte daraufhin +// annehmen, was diese Fassung mitbringt - obwohl der Adapter fuer 2024-11-05 +// geschrieben war. Fehler dieser Art zeigen sich erst spaeter und an ganz +// anderer Stelle. +// +// Jetzt gilt: Kennt der Adapter die verlangte Fassung, bestaetigt er sie. +// Kennt er sie nicht, nennt er seine neueste - und der Client entscheidet, +// ob ihm das genuegt. Genau so ist die Aushandlung gedacht. +const PROTOCOL_VERSIONS = ['2025-06-18', '2025-03-26', '2024-11-05']; +const DEFAULT_PROTOCOL = PROTOCOL_VERSIONS[0]; // --------------------------------------------------------------------------- // Vertrauensgrenze @@ -555,6 +579,203 @@ const TOOLS = [ const TOOL_BY_NAME = new Map(TOOLS.map((tool) => [tool.name, tool])); +// --------------------------------------------------------------------------- +// Ressourcen +// --------------------------------------------------------------------------- +// Werkzeuge muss ein Modell von sich aus aufrufen. Ressourcen haengt ein +// MENSCH an - in Claude Desktop ueber das Anhang-Menue. Das ist der +// Unterschied, der sie lohnend macht: Wer eine Frage zum Projektstand stellt, +// legt die Roadmap daneben, statt zu hoffen, dass das Modell dc_roadmap +// aufruft. +// +// Bewusst wenige und nur lesende. Eine Ressource je Endpunkt waere dieselbe +// Aufblaehung, die bei den Werkzeugen schon abgelehnt wurde. +const RESOURCES = [ + { + uri: 'dc://roadmap', + name: 'Roadmap des Projekts', + description: + 'Der Planungsstand als Markdown, so wie ihn cli/roadmap.php in die ' + + 'Projektdatei schreibt: Themenbereiche, Reihenfolge, Zielfassung, wer ' + + 'woran sitzt. Der Live-Stand - nicht die Kopie im Repository.', + mimeType: 'text/markdown', + read: async () => { + const antwort = await call('GET', '/api/bugtracker/v1/roadmap.php', { + query: { action: 'render', project_slug: PROJECT }, + }); + return { mimeType: 'text/markdown', text: String(antwort.markdown ?? '') }; + }, + }, + { + uri: 'dc://naechste-schritte', + name: 'Als Naechstes dran', + description: + 'Die vorderen Eintraege der Warteschlange samt Bearbeiter. Beantwortet ' + + '"woran arbeite ich jetzt" und "sitzt da schon jemand dran".', + mimeType: 'application/json', + read: async () => { + const [naechste, aktiv] = await Promise.all([ + call('GET', '/api/bugtracker/v1/roadmap.php', { query: { action: 'next', limit: 8, project_slug: PROJECT } }), + call('GET', '/api/bugtracker/v1/roadmap.php', { query: { action: 'active', project_slug: PROJECT } }), + ]); + return { + mimeType: 'application/json', + text: JSON.stringify({ als_naechstes: naechste.items, in_arbeit: aktiv.items }, null, 2), + }; + }, + }, + { + uri: 'dc://projekte', + name: 'Projektlandschaft', + description: + 'Alle Projekte mit Slug, offenen Items und der Beziehung zum eigenen ' + + '(uses_sdk, depends_on, shares_code). Noetig, bevor ein Slug geraten wird.', + mimeType: 'application/json', + read: async () => ({ + mimeType: 'application/json', + text: JSON.stringify(await call('GET', '/api/bugtracker/v1/projects'), null, 2), + }), + }, + { + uri: 'dc://befunde', + name: 'Offene Befunde je Detektor', + description: + 'Die Zaehlung des Supervisors als Einstieg in die Sichtung: was ist ' + + 'ungewoehnlich, und wie oft.', + mimeType: 'application/json', + read: async () => ({ + mimeType: 'application/json', + text: JSON.stringify(await call('GET', '/api/supervisor/v1/summary', { query: { project_slug: PROJECT } }), null, 2), + }), + }, +]; + +const RESOURCE_BY_URI = new Map(RESOURCES.map((r) => [r.uri, r])); + +// --------------------------------------------------------------------------- +// Prompts +// --------------------------------------------------------------------------- +// In Claude Desktop erscheinen sie als Schraegstrich-Befehle im Eingabefeld. +// Ihr Wert liegt nicht darin, Tipparbeit zu sparen, sondern darin, die +// Hausregeln mitzuliefern: dass vor dem Anfangen nachgesehen wird, wer schon +// dransitzt; dass eine Fremdmeldung eine Begruendung braucht; dass ein +// verworfenes Vorhaben nicht ohne neues Argument wiederkommt. +// +// Ohne sie stehen diese Regeln nur in der Werkzeugbeschreibung - und die liest +// ein Modell erst, wenn es das Werkzeug schon gewaehlt hat. +const PROMPTS = [ + { + name: 'arbeit-aufnehmen', + description: 'Was ist als Naechstes dran? Planungsstand pruefen, Item uebernehmen, anfangen.', + arguments: [], + build: () => `Nimm Arbeit im Deploymentcenter auf. Halte dabei diese Reihenfolge ein: + +1. dc_roadmap aufrufen. Es beantwortet zwei Fragen, die man sich sonst falsch + beantwortet: was steht an, und sitzt dort schon jemand dran. +2. Erst dann dc_next_task, um ein Item exklusiv zu uebernehmen. Ohne diesen + Schritt arbeiten zwei Agenten am selben Problem. +3. Kommt ein Item mit Status "in_verification" oder "awaiting_verification" + zurueck, lautet die Aufgabe NICHT "behebe das", sondern "traegt der Fix?". + In resolved_in_build steht, gegen welche Fassung zu pruefen ist. +4. Fortschritt mit dc_comment festhalten, solange du arbeitest. +5. Kommst du nicht weiter: dc_release. Ein gehaltenes Item sperrt es fuer alle + anderen bis zum Lease-Ende. + +Sag mir zuerst, was du vorgefunden hast und was du dir vornimmst - bevor du +etwas aenderst.`, + }, + { + name: 'befunde-sichten', + description: 'Supervisor-Befunde durchgehen und einstufen: echt, Artefakt oder Rauschen?', + arguments: [ + { + name: 'detektor', + description: 'Nur eine Detektor-Familie, z. B. "deviation" oder "novel". Optional.', + required: false, + }, + ], + build: (args) => `Sichte die offenen Supervisor-Befunde${ + args.detektor ? ` der Familie "${args.detektor}"` : '' + }. + +Fang mit dc_finding_summary an - die Zaehlung je Detektor zeigt, wo ueberhaupt +etwas los ist. Dann dc_findings fuer die Einzelbefunde, dc_metrics wenn du den +Verlauf einer Kennzahl brauchst, um einen Befund zu beurteilen. + +Ein Befund behauptet nicht, dass etwas kaputt ist, sondern dass etwas +ungewoehnlich ist. Die Frage "ist das schlimm?" beantwortest du, nicht der +Detektor. + +Achte besonders auf die Umgebung: Was ausserhalb von "production" auffiel, ist +eine Stufe niedriger einzuordnen - dort entsteht systematisch mehr Rauschen. +Aber die Umkehrung gilt auch: Fehlende Zugangsdaten auf dem Pruefstand sind ein +Artefakt, WIE die Software darauf reagiert dagegen nicht. Endlose +Wiederholungen, verschluckte Fehler, halb geschriebener Zustand oder ein +Passwort im Protokoll sind echte Befunde, auch wenn der Ausloeser kuenstlich war. + +Schlag mir eine Einstufung je Befund vor. Setzen tue ich sie, nicht du.`, + }, + { + name: 'fehler-melden', + description: 'Einen Fehler oder eine Idee im richtigen Projekt melden - auch in einem fremden.', + arguments: [ + { name: 'beobachtung', description: 'Was ist passiert?', required: true }, + ], + build: (args) => `Melde das im Deploymentcenter: + +${args.beobachtung} + +Vorher zu klaeren, in dieser Reihenfolge: + +1. In WELCHEM Projekt liegt die Ursache? Nicht dort melden, wo du gerade + arbeitest, sondern wo der Fehler herkommt. Ein Fehler im SDK, im + update-agent, in der Lizenzpruefung oder im Bugtracker gehoert unter + "deploymentcenter". Bei Zweifeln dc_projects aufrufen - den Slug nicht raten. +2. Ist es ein FREMDES Projekt, ist "evidence" Pflicht: was wurde beobachtet, + und warum liegt die Ursache dort und nicht bei uns? Pruefe ehrlich, ob nicht + das eigene Projekt die Schnittstelle falsch benutzt - das ist der haeufigere + Fall. +3. Mit dc_search nachsehen, ob es das schon gibt. Ein zweites Item zur selben + Sache verteilt die Diskussion auf zwei Straenge. +4. client_ref setzen, damit ein wiederholter Aufruf kein Duplikat anlegt, und + build_version mitschicken - daran entscheidet der Server, ob es eine + Regression ist oder nur ein veralteter Pruefstand. + +Zeig mir den Entwurf, bevor du dc_report aufrufst.`, + }, + { + name: 'roadmap-pflegen', + description: 'Ein Vorhaben einplanen, umsortieren oder zuruecksetzen - mit den Regeln dazu.', + arguments: [ + { name: 'vorhaben', description: 'Worum geht es?', required: false }, + ], + build: (args) => `Pflege die Roadmap im Deploymentcenter${ + args.vorhaben ? `. Es geht um: ${args.vorhaben}` : '' + }. + +Der aktuelle Stand kommt aus dc_roadmap, geaendert wird mit dc_roadmap_set. +Vier Regeln, die dabei gelten: + +1. Ein Vorhaben steht auf der Roadmap, sobald es einen Themenbereich (epic) + hat. Das ist das einzige Kennzeichen. Erst dc_report anlegen, dann + einplanen - anlegen und einplanen sind zwei Entscheidungen. +2. Die Reihenfolge laeuft in Zehnerschritten. Die Luecken sind Absicht: So + laesst sich spaeter etwas dazwischenschieben, ohne alles neu zu nummerieren. +3. Zurueckstellen geht nur mit defer_trigger - woran erkennt man, dass + "spaeter" jetzt ist? Ohne benannten Anlass ist ein Vorhaben nicht + zurueckgestellt, sondern vergessen. Der Server lehnt es sonst ab. +4. Steht etwas auf "rejected" oder "deferred", lies erst die Begruendung, auf + die doc_anchor zeigt, bevor du es erneut vorschlaegst. Ein neuer Vorschlag + braucht ein Argument, das dort noch nicht steht. + +Der Zustand gehoert ins Deploymentcenter, die Begruendung in die Projektdatei. +Trag keine Statuszeilen von Hand in eine Markdown-Datei - der erzeugte Block +zwischen den dc:roadmap-Markern wird beim naechsten Lauf ueberschrieben.`, + }, +]; + +const PROMPT_BY_NAME = new Map(PROMPTS.map((p) => [p.name, p])); + // --------------------------------------------------------------------------- // JSON-RPC ueber stdio // --------------------------------------------------------------------------- @@ -628,19 +849,99 @@ async function handleToolCall(id, params) { } } +/** + * Liefert den Inhalt einer Ressource. + * + * Anders als bei Werkzeugen gibt es hier kein isError-Feld - ein Fehlschlag + * ist ein JSON-RPC-Fehler. Sonst haengte ein Client eine Fehlermeldung als + * Kontext an und arbeitete damit weiter, als waere es die Roadmap. + */ +async function handleResourceRead(id, params) { + const uri = params?.uri; + const resource = RESOURCE_BY_URI.get(uri); + + if (!resource) { + replyError(id, -32602, `Unbekannte Ressource "${uri}".`); + return; + } + + try { + const { mimeType, text } = await resource.read(); + + // Dieselbe Vertrauensgrenze wie bei den Werkzeugen. Eine Ressource wird + // sogar eher zum Einfallstor: Sie landet ungefragt im Kontext, weil ein + // Mensch sie angehaengt hat - und in Titeln und Fehlertexten steht, was + // auf fremden Maschinen entstanden ist. + const inhalt = mimeType === 'application/json' + ? JSON.stringify(guard(JSON.parse(text)), null, 2) + : text; + + const hinweis = inhalt.includes('<< ({ + uri, name, description, mimeType, + })), + }); + return; + + // Es gibt keine Vorlagen mit Platzhaltern - die Antwort ist leer und nicht + // "unbekannte Methode". Ein Client, der beim Aufbau der Anhangsliste einen + // -32601 bekommt, meldet das als Fehler, obwohl nichts falsch ist. + case 'resources/templates/list': + reply(id, { resourceTemplates: [] }); + return; + + case 'resources/read': + await handleResourceRead(id, params); + return; + + case 'prompts/list': + reply(id, { + prompts: PROMPTS.map(({ name, description, arguments: args }) => ({ + name, description, arguments: args, + })), + }); + return; + + case 'prompts/get': + handlePromptGet(id, params); + return; + default: if (id !== undefined) replyError(id, -32601, `Unbekannte Methode "${method}".`); } @@ -679,7 +1011,25 @@ let pending = 0; let closed = false; function drained() { - if (closed && pending === 0) process.exit(0); + if (!closed || pending > 0) return; + + // NICHT process.exit(). Der reisst den Prozess sofort ab, auch wenn libuv + // gerade dabei ist, Handles zu schliessen - unter Windows quittiert das die + // Laufzeit mit + // + // Assertion failed: !(handle->flags & UV_HANDLE_CLOSING), src\win\async.c + // + // und einem Rueckgabewert ungleich null. Fuer den Aufrufer sieht ein + // ordentlich beendeter Adapter dann aus wie ein abgestuerzter; Claude + // Desktop meldet den Server als fehlgeschlagen, obwohl jede Antwort + // rausgegangen ist. + // + // Stattdessen: Rueckgabewert vormerken und die letzte Referenz auf stdin + // loesen. Node beendet sich, sobald nichts mehr in der Schleife haengt - + // also nachdem geschrieben und geschlossen wurde. + process.exitCode = 0; + input.close(); + process.stdin.unref(); } input.on('line', (line) => { diff --git a/public/api/bugtracker/v1/roadmap.php b/public/api/bugtracker/v1/roadmap.php index 8dc9000..e80de80 100644 --- a/public/api/bugtracker/v1/roadmap.php +++ b/public/api/bugtracker/v1/roadmap.php @@ -88,7 +88,7 @@ switch ($action) { ]); case 'render': { - $project = requireProject($scope); + $project = requireProject($scope, $context); Http::ok([ 'project_slug' => $project, 'markdown' => $roadmap->renderBlock($project), @@ -100,7 +100,7 @@ switch ($action) { // dass der Server ihn liest: Die Datei liegt im Repository des // Projekts, oft auf einer anderen Maschine. Der Server kennt sie nicht // und soll auch nicht anfangen, in Verzeichnissen zu suchen. - $project = requireProject($scope); + $project = requireProject($scope, $context); $content = Http::str('content'); if ($content === null) { @@ -218,7 +218,7 @@ switch ($action) { } case 'reorder': { - $project = requireProject($writeProject ?? $requested); + $project = requireProject($writeProject ?? $requested, $context); $epic = Http::str('epic'); $ids = Http::input('ids'); @@ -235,7 +235,7 @@ switch ($action) { } case 'epic': { - $project = requireProject($writeProject ?? $requested); + $project = requireProject($writeProject ?? $requested, $context); $epic = Http::str('epic'); $title = Http::str('title'); @@ -260,14 +260,28 @@ switch ($action) { * * "all" ist beim Lesen sinnvoll und beim Schreiben oder Erzeugen nicht - eine * Datei gehoert zu genau einem Repository. + * + * Der Rueckfall auf das Heimatprojekt ist nicht Bequemlichkeit, sondern + * behebt einen Widerspruch: Beim Lesen laesst projectFilter() ein + * Werkbank-Token bewusst durch (es soll die Landschaft sehen), liefert also + * null - und damit wurde $scope zu "all". Ausgerechnet der Tokentyp, den der + * MCP-Adapter verlangt, konnte seine eigene Roadmap nicht rendern. + * + * Das Token ist trotzdem an ein Projekt gebunden; diese Bindung liefert + * homeProject(). "Meine Roadmap" ohne Angabe heisst genau das. */ -function requireProject(?string $slug): string +function requireProject(?string $slug, array $context = []): string { - if ($slug === null || $slug === '' || $slug === 'all') { - Http::fail(400, 'missing_project', 'Diese Aktion braucht ein konkretes "project_slug".'); + if ($slug !== null && $slug !== '' && $slug !== 'all') { + return $slug; } - return $slug; + $home = ApiAuth::homeProject($context); + if ($home !== null) { + return $home; + } + + Http::fail(400, 'missing_project', 'Diese Aktion braucht ein konkretes "project_slug".'); } /** diff --git a/public/docs/changelog.json b/public/docs/changelog.json index fa7af2e..5f014a2 100644 --- a/public/docs/changelog.json +++ b/public/docs/changelog.json @@ -64,6 +64,34 @@ "title": "Testinstanz dc-dev.mhdf.de und ein Testtor vor jedem Produktiv-Upload", "text": "Eine vollstaendige zweite Installation, die jederzeit geloescht und neu aufgebaut werden darf: eigene Datenbank, eigene Schluessel, Basic-Auth davor, Rocket.Chat aus. Dazu 124 Testfaelle in tests/e2e gegen API, Rechte, Migrationen und die Zugangssperre. scripts/deploy.py bringt vor jedem Upload erst den anstehenden Stand auf die Testinstanz und laesst dann den Testlauf darauf laufen; erst bei Rueckgabewert 0 geht etwas auf die Produktion (--ohne-test ist der Notausgang). Die Reihenfolge ist der Punkt: Liefe der Test gegen den vorhandenen Dev-Stand, prueft er aelteren Code als den, der gleich hochgeht. Gefundene, noch offene Fehler stehen mit Item-Nummer in tests/e2e/bekannte_befunde.json - sie laufen als BEK mit und blockieren nicht; besteht so ein Fall wieder, sagt der Lauf das ausdruecklich. Ein Lauf, in dem mehr uebersprungen als geprueft wurde, endet mit Rueckgabewert 2 statt 0: nichts ist kaputt, geprueft wurde es aber auch nicht, und der Unterschied darf nicht am Rueckgabewert verschwinden. Anleitung: docs/TESTINSTANZ.md.", "action": "Wer deployt, braucht scripts/deploy_dev_config.json (Vorlage: ...example.json). Ohne sie bricht scripts/deploy.py ab und verweist auf --ohne-test." + }, + { + "area": "mcp", + "breaking": false, + "title": "MCP-Adapter 1.1: Ressourcen, Prompts und eine echte Protokollaushandlung", + "text": "Werkzeuge ruft ein Modell von sich aus auf - Ressourcen und Prompts waehlt ein MENSCH. In Claude Desktop sind das das Anhang-Menue und die Schraegstrich-Befehle, und genau die fehlten. Neu: vier Ressourcen (dc://roadmap als fertiger Markdown-Block, dc://naechste-schritte, dc://projekte, dc://befunde) und vier Prompts (arbeit-aufnehmen, befunde-sichten, fehler-melden, roadmap-pflegen). Der Wert der Prompts liegt darin, dass die Hausregeln mitkommen: erst nachsehen wer dransitzt, Fremdmeldung nur mit Begruendung, verworfene Vorhaben nicht ohne neues Argument wiedervorschlagen. Bisher standen die nur in Werkzeugbeschreibungen - und die liest ein Modell erst, wenn es das Werkzeug schon gewaehlt hat. Ressourcen laufen durch dieselbe Fremddaten-Abgrenzung wie Werkzeugergebnisse; sie sind sogar eher ein Einfallstor, weil sie ungefragt im Kontext landen. BEHOBEN dabei: \"initialize\" spiegelte die vom Client verlangte Protokollfassung einfach zurueck. Ein Client, der 2025-06-18 anfragte, bekam sie bestaetigt, obwohl der Adapter fuer 2024-11-05 geschrieben war. Jetzt wird gegen eine Liste tatsaechlich bedienter Fassungen ausgehandelt. Ebenfalls behoben: Beim Beenden rief der Adapter process.exit() mitten in den schliessenden libuv-Handles - unter Windows eine Assertion und ein Rueckgabewert ungleich null, also ein sauber beendeter Server, der wie ein abgestuerzter aussah.", + "action": "Fuer Claude Desktop: Eintrag in claude_desktop_config.json (%APPDATA%\\Claude\\ bzw. ~/Library/Application Support/Claude/), absoluter Pfad, DC_PROJECT setzen, Desktop vollstaendig neu starten. Anleitung in mcp/README.md." + }, + { + "area": "api", + "breaking": false, + "title": "Behoben: Werkbank-Token konnte seine eigene Roadmap nicht rendern", + "text": "roadmap.php?action=render und ?action=check verlangen ein konkretes Projekt. Beim Lesen laesst projectFilter() ein Werkbank-Token aber bewusst durch - es soll die ganze Landschaft sehen - und liefert deshalb null. Damit wurde der Projektbezug zu \"all\" und der Aufruf mit 400 missing_project abgelehnt. Betroffen war ausgerechnet der Tokentyp, den der MCP-Adapter und dc-testkit verlangen. Das Token ist trotzdem an ein Projekt gebunden; dieser Bezug dient jetzt als Rueckfall. \"Meine Roadmap\" ohne Angabe heisst genau das.", + "action": null + }, + { + "area": "api", + "breaking": false, + "title": "Behoben: Projektliste warf 500 fuer Werkbank-Token (HY093)", + "text": "ProjectLinks::between() benutzte :a dreimal und :b zweimal im selben Statement, forProject() :slug zweimal. Bei ATTR_EMULATE_PREPARES = false ist das unzulaessig - PDO reicht die Namen an den Server durch, und der zaehlt Parameter, statt sie zu benennen. Ergebnis: SQLSTATE[HY093] und ein 500 auf /api/bugtracker/v1/projects. Die Abfrage laeuft nur, wenn ein Heimatprojekt gesetzt ist, also nur mit einem Werkbank-Token - fuer das Master-Token des Testlaufs wurde der Zweig uebersprungen. Derselbe Fehler steckte schon einmal in der Volltextsuche des Bugtrackers; ein Suchlauf ueber alle prepare()-Aufrufe der Codebasis fand jetzt keine weitere Stelle.", + "action": null + }, + { + "area": "betrieb", + "breaking": false, + "title": "Testlauf kennt jetzt den Werkbank-Token - der Tokentyp, den die Agenten benutzen", + "text": "Es gibt keine API, ein dev_agent-Token anzulegen; in der Produktion geschieht das von Hand in der Token-Verwaltung, und das bleibt auch so. Fuer den Testlauf fehlte damit ausgerechnet der Tokentyp, den MCP-Adapter und dc-testkit verwenden - mit der Folge, dass seine Sonderwege niemand prueft. Genau dort steckten die beiden Befunde oben. testctl.php kann jetzt action=workbench_token, und vier Testfaelle decken die Wege ab: Roadmap ohne Projektangabe rendern, Projektliste mit Beziehungen, und die Gegenprobe, dass der Rueckfall aufs Heimatprojekt das Lesen ueber alle Projekte nicht einschraenkt. Der Testlauf steht damit bei 128 Faellen.", + "action": null } ] }, diff --git a/src/Core/ProjectLinks.php b/src/Core/ProjectLinks.php index 908ccc8..ad77793 100644 --- a/src/Core/ProjectLinks.php +++ b/src/Core/ProjectLinks.php @@ -43,13 +43,16 @@ final class ProjectLinks */ public function forProject(string $slug): array { + // Zwei Platzhalter fuer denselben Wert - aus demselben Grund wie in + // between(): ein Name darf bei nativen Prepared Statements nur einmal + // vorkommen. Diese Abfrage haette beim ersten Aufruf HY093 geworfen. $stmt = $this->db->prepare(' SELECT from_slug, to_slug, kind, integrated_version, integrated_at, notes FROM dc_project_links - WHERE from_slug = :slug OR to_slug = :slug + WHERE from_slug = :slug_from OR to_slug = :slug_to ORDER BY kind ASC, to_slug ASC '); - $stmt->execute([':slug' => $slug]); + $stmt->execute([':slug_from' => $slug, ':slug_to' => $slug]); $uses = []; $usedBy = []; @@ -86,15 +89,31 @@ final class ProjectLinks return ['direction' => 'self', 'kind' => 'self', 'integrated_version' => null]; } + // Jedes Vorkommen bekommt einen eigenen Platzhalter. + // + // :a stand dreimal und :b zweimal im Statement. Bei + // ATTR_EMULATE_PREPARES = false ist das nicht zulaessig - PDO reicht + // die Namen an den Server durch, und der zaehlt Parameter, statt sie + // zu benennen. Ergebnis: HY093 "Invalid parameter number", also ein + // 500 statt einer Antwort. + // + // Derselbe Fehler steckte schon einmal in der Volltextsuche des + // Bugtrackers (siehe Kopfkommentar von BugRepo). Er faellt deshalb so + // spaet auf, weil er erst bei der Ausfuehrung zuschlaegt und diese + // Abfrage nur laeuft, wenn ein Heimatprojekt gesetzt ist - also nur + // mit einem Werkbank-Token. $stmt = $this->db->prepare(' SELECT from_slug, kind, integrated_version FROM dc_project_links - WHERE (from_slug = :a AND to_slug = :b) - OR (from_slug = :b AND to_slug = :a) - ORDER BY from_slug = :a DESC + WHERE (from_slug = :a1 AND to_slug = :b1) + OR (from_slug = :b2 AND to_slug = :a2) + ORDER BY from_slug = :a3 DESC LIMIT 1 '); - $stmt->execute([':a' => $from, ':b' => $to]); + $stmt->execute([ + ':a1' => $from, ':a2' => $from, ':a3' => $from, + ':b1' => $to, ':b2' => $to, + ]); $row = $stmt->fetch(); if (!is_array($row)) { diff --git a/tests/e2e/suite_bugtracker.py b/tests/e2e/suite_bugtracker.py index 0eb2612..f344400 100644 --- a/tests/e2e/suite_bugtracker.py +++ b/tests/e2e/suite_bugtracker.py @@ -437,6 +437,85 @@ def _(ctx): erwarte_erfolg(r) +@test('roadmap', 'Werkbank-Token laesst sich anlegen') +def _(ctx): + """ + Der Tokentyp, den MCP-Adapter und dc-testkit benutzen: dev_agent, an ein + Projekt gebunden. Bis hierher benutzte ihn kein Testfall - und genau + deshalb blieb der Fall darunter jahrelang ungeprueft. + """ + r = ctx.testctl('workbench_token', project_slug=PROJEKT, name='e2e-werkbank') + erwarte_erfolg(r) + erwarte(r.feld('raw_token'), 'Kein Token in der Antwort: %s' % r.kurz(300)) + ctx['werkbank_token'] = r.feld('raw_token') + + +@test('roadmap', 'Werkbank-Token rendert seine Roadmap ohne Projektangabe') +def _(ctx): + """ + BEFUND (29.08.2026): Beim Lesen laesst projectFilter() ein Werkbank-Token + bewusst durch - es soll die ganze Landschaft sehen und liefert deshalb + null. Damit wurde der Projektbezug zu "all", und action=render lehnte mit + "missing_project" ab. Ausgerechnet der Tokentyp, den der MCP-Adapter + verlangt, konnte seine eigene Roadmap nicht rendern. + + Das Token ist trotzdem an ein Projekt gebunden. "Meine Roadmap" ohne + Angabe heisst genau dieses Projekt. + """ + r = ctx.client.get('/api/bugtracker/v1/roadmap.php', + query={'action': 'render'}, + token=ctx['werkbank_token']) + erwarte_erfolg(r) + erwarte(r.feld('project_slug') == PROJEKT, + 'Gerendert wurde %r statt des Heimatprojekts %r' + % (r.feld('project_slug'), PROJEKT)) + erwarte('dc:roadmap' in (r.feld('markdown') or ''), + 'Im Ergebnis fehlt der Blockmarker: %s' % r.kurz(200)) + + +@test('roadmap', 'Werkbank-Token bekommt die Projektliste mit Beziehungen') +def _(ctx): + """ + BEFUND (29.08.2026): ProjectLinks::between() und forProject() benutzten + denselben Platzhalter mehrfach im Statement. Bei + ATTR_EMULATE_PREPARES = false ist das nicht zulaessig - PDO zaehlt + Parameter, statt sie zu benennen, und wirft HY093. + + Die Abfrage laeuft nur, wenn ein Heimatprojekt gesetzt ist, also + ausschliesslich mit einem Werkbank-Token. Fuer das Master-Token des + Testlaufs wurde der Zweig uebersprungen - deshalb blieb ein sicherer + 500er unbemerkt, bis der MCP-Adapter die Ressource dc://projekte abrief. + + Derselbe Fehler steckte schon in der Volltextsuche des Bugtrackers. Beim + dritten Mal ist es kein Zufall mehr, sondern eine Falle des Frameworks - + ein Suchlauf ueber alle prepare()-Aufrufe gehoert zur Durchsicht. + """ + r = ctx.client.get('/api/bugtracker/v1/projects', token=ctx['werkbank_token']) + erwarte_erfolg(r) + projekte = r.feld('projects', default=[]) or [] + erwarte(projekte, 'Leere Projektliste: %s' % r.kurz(300)) + + eigen = [p for p in projekte if p.get('slug') == PROJEKT] + erwarte(eigen, 'Das eigene Projekt %r fehlt in der Liste.' % PROJEKT) + erwarte((eigen[0].get('relation') or {}).get('kind') == 'self', + 'Das eigene Projekt sollte relation.kind="self" tragen, hat aber %r' + % eigen[0].get('relation')) + + +@test('roadmap', 'Werkbank-Token sieht weiterhin die ganze Landschaft') +def _(ctx): + """ + Die Gegenprobe zum Fall darueber. Der Rueckfall aufs Heimatprojekt darf + nur dort greifen, wo ein konkretes Projekt gebraucht wird - beim Lesen + ueber alle Projekte hinweg waere er eine Einschraenkung, die es vorher + nicht gab. + """ + r = ctx.client.get('/api/bugtracker/v1/roadmap.php', + query={'action': 'list', 'project_slug': 'all'}, + token=ctx['werkbank_token']) + erwarte_erfolg(r) + + @test('roadmap', 'Roadmap-Schreibzugriff verlangt Verwaltungsrecht') def _(ctx): r = ctx.client.post('/api/bugtracker/v1/roadmap.php', diff --git a/tests/server/testctl.php b/tests/server/testctl.php index 5394283..d93cb13 100644 --- a/tests/server/testctl.php +++ b/tests/server/testctl.php @@ -194,6 +194,47 @@ switch ($action) { ]); break; + // ------------------------------------------------------------------ + // Werkbank-Token (owner_type = dev_agent), an ein Projekt gebunden. + // + // Es gibt keine API, so eines anzulegen - in der Produktion geschieht das + // von Hand in der Token-Verwaltung, und das ist auch richtig so. Fuer den + // Testlauf fehlte damit aber ausgerechnet der Tokentyp, den der + // MCP-Adapter und dc-testkit benutzen. Die Folge war ein Fehler, den + // niemand fand: Beim Lesen laesst projectFilter() ein Werkbank-Token + // bewusst durch, also war der Projektbezug leer - und + // roadmap.php?action=render lehnte mit "missing_project" ab, obwohl das + // Token sehr wohl an ein Projekt gebunden ist. + // + // Ein Tokentyp, den kein Testfall benutzt, ist ein Tokentyp, dessen + // Sonderwege niemand prueft. + // ------------------------------------------------------------------ + case 'workbench_token': { + $slug = (string)(Http::str('project_slug') ?? ''); + if ($slug === '') { + Http::fail(400, 'missing_project', 'project_slug wird gebraucht.'); + } + + $manager = new TokenManager($db); + $token = $manager->createMasterToken( + (string)(Http::str('name') ?? 'Werkbank (Testlauf)'), + $slug, + null, + 'dev_agent', + 'testkit', + ['bugtracker:manage', 'supervisor:manage', 'watchdog:read', 'license:dev'], + 'all' + ); + + Http::ok([ + 'token_id' => $token['token_id'], + 'raw_token' => $token['raw_token'], + 'project_slug' => $slug, + 'owner_type' => 'dev_agent', + ]); + break; + } + // ------------------------------------------------------------------ // Signierschluessel fuer den Updateservice. Er wird auf der Testinstanz // erzeugt und nie von der Produktion kopiert - ein Testlauf darf mit dem @@ -260,6 +301,7 @@ switch ($action) { // ------------------------------------------------------------------ default: Http::fail(400, 'unknown_action', sprintf('Unbekannte Aktion "%s".', $action), null, [ - 'actions' => ['status', 'wipe', 'migrate', 'reset', 'genkey', 'log', 'clearlog'], + 'actions' => ['status', 'wipe', 'migrate', 'reset', 'workbench_token', + 'genkey', 'log', 'clearlog'], ]); }