diff --git a/.governance/manifest.json b/.governance/manifest.json index 96f7996..17de095 100644 --- a/.governance/manifest.json +++ b/.governance/manifest.json @@ -161,7 +161,12 @@ "generated/mcp/**", "packages/taskand-mcp-control/**", "tests/mcp_control_test.py", - "tests/fixtures/mcp_control_server.py" + "tests/fixtures/mcp_control_server.py", + "mcp/**", + "generated/registry/registry.json", + "tests/mcp_catalog_test.py", + "tests/mcp_registry.test.mjs", + "generated/registry/core/taskand.dev/v1/**" ] }, "interfaces": { diff --git a/Makefile b/Makefile index 1af57c6..7a2f5ea 100644 --- a/Makefile +++ b/Makefile @@ -14,7 +14,12 @@ status: docker compose ps @./bin/taskand status -test: conformance test-contracts test-negative test-twin test-web-twin test-cli +test: conformance test-contracts test-negative test-twin test-web-twin test-cli test-mcp + +.PHONY: test-mcp +test-mcp: + @uv run --frozen --project mcp python tests/mcp_catalog_test.py + @node --test tests/mcp_registry.test.mjs test-cli: @node --test tests/cli_routing.test.mjs diff --git a/docs/README.md b/docs/README.md index 297c85b..d55e3cb 100644 --- a/docs/README.md +++ b/docs/README.md @@ -47,3 +47,4 @@ standardu w CI i nie stanowi odbioru opisywanej funkcjonalności. - [Sekrety](SECRETS.md). - [Sterowanie MCP przez URI Taskand](information/mcp-process-control.md) — profile serwerów, dopuszczenia narzędzi, trwałe wyniki i izolowany pilot. +- [Lokalne MCP jako procesy URI](information/mcp-uri-catalog.md) — importer, katalog 45 serwerów, naprawione środowiska i testy pilota. diff --git a/docs/information/mcp-uri-catalog.md b/docs/information/mcp-uri-catalog.md new file mode 100644 index 0000000..eb38c37 --- /dev/null +++ b/docs/information/mcp-uri-catalog.md @@ -0,0 +1,291 @@ +--- +{ + "schema": "wellmanifest.docs/document/v1", + "id": "mcp-uri-catalog", + "kind": "information", + "version": 2, + "title": "Lokalne MCP jako natywne procesy URI", + "status": "implemented", + "owner": "semcod/taskand-glm53", + "created": "2026-09-19", + "updated": "2026-09-19", + "review_after": "2026-09-26", + "source_revision": "8fa9d47b389a9c0ab4b7a12601fad1b8ac23b95b", + "affected_repositories": [ + "semcod/taskand-glm53" + ], + "evidence": [ + "repo://semcod/taskand-glm53/project/ticket-030/intent.json", + "repo://semcod/taskand-glm53/mcp/catalog.py", + "repo://semcod/taskand-glm53/tests/mcp_catalog_test.py", + "repo://semcod/taskand-glm53/tests/mcp_registry.test.mjs" + ] +} +--- + +# Lokalne MCP jako natywne procesy URI + + +## Cel + +Udostępnić narzędzia lokalnych serwerów MCP przez istniejący rejestr i gateway Taskand. +Importer tworzy samodzielne pakiety w `mcp/`, zgodne z układem `generated/**`. +Wynik ticket-030: 45 dostępnych serwerów, 422 zarejestrowane narzędzia. +Każdy pakiet ma wersjonowany URI; import rejestruje kandydatów. + + +## Zakres obserwacji + +Pomiar z 2026-09-19 obejmuje konfiguracje Codex, Cursor, Claude Desktop, +Claude CLI i VS Code na tym hoście oraz cztery doinstalowane implementacje +z repozytorium [modelcontextprotocol/servers](https://github.com/modelcontextprotocol/servers). +To wszystkie unikalne nazwy w tych konfiguracjach, nie katalog wszystkich MCP w internecie. +Sprawdzono również konfiguracje projektowe Claude; nie dodawały serwerów. +46 nazw obejmuje 45 serwerów oraz wykluczony adapter `taskand`, którego ponowny +import tworzyłby rekurencyjne połączenie Taskand → MCP → Taskand. + +Powtarzające się nazwy mają jawny priorytet: naprawiony profil, Codex, Cursor, +Claude Desktop, Claude CLI, VS Code, dodatkowe serwery. `--prefer-first` zapisuje +pominięte lokalizacje w raporcie; bez tej opcji sprzeczne konfiguracje są błędem. Niedostępny serwer zachowuje +częściowy raport i powoduje niezerowy kod zakończenia importera. +Nie zmieniono konfiguracji klientów ani kodu repozytoriów serwerów. + +Ticket-029/PR #38 pozostaje osobną zmianą kontrolera MCP. Po autoryzowanym +przekazaniu jego rezerwacji pracy nowy zakres przydzielono ticket-030. +Gateway 8077 i pilot 8082 zachowują swój dotychczasowy stan. + + +## Implementacja i użycie + +`mcp/catalog.py` odczytuje zaufane konfiguracje operatora, wykonuje inicjalizację +MCP i `tools/list`, a następnie projektuje kontrakty na pakiety: + +```text +mcp/ + catalog.py, bridge.py, pyproject.toml, uv.lock + mcp--/ + registry.json + -/taskand.dev/v1/ + proc.yaml + bin.mjs + bridge.py + tool.json +``` + +Przykład: +`proc://taskand.dev/mcp-filesystem-cbf61858/read-text-file-e862d61b/v1`. +Hash w nazwie zapobiega kolizjom po normalizacji znaków. Przestrzeń `mcp-*` +ma jeden katalog główny; istniejące procesy nadal korzystają z `generated/`. +Rejestr eksportuje `inputSchema`, opcjonalne `outputSchema` oraz metadane MCP. +Pakiety i prywatne profile są niezmienne: zmiana wymaga nowej wersji oraz nowego +pliku profili. Ponowne wykonanie identycznego importu jest idempotentne. + +Przykład importu z pliku JSON `mcpServers` lub TOML `mcp_servers`: + +```bash +uv sync --frozen --project mcp +uv run --frozen --project mcp python mcp/catalog.py \ + --config /absolute/path/client-config.json \ + --output /absolute/path/taskand-release/mcp \ + --profiles /absolute/private/state/profiles-v1.json \ + --report /absolute/private/state/import-v1.json --register +``` + +`--config` można powtarzać; `--only NAZWA` ogranicza import. Przy zmianie +kontraktu należy użyć np. `--version 2`. Katalog nadrzędny `mcp/` musi zawierać +Taskand z tą wersją registry/core. Profile zawierają konfigurację uruchomienia +oraz wymagane wartości środowiska, pozostają poza repozytorium z trybem `0600`. +Pakiety `mcp/mcp-*/` są ignorowanymi artefaktami wdrożenia, odtwarzanymi importerem; +Git przechowuje kompilator, lock zależności, integrację rejestru i testy. + +Gateway potrzebuje `TASKAND_MCP_PYTHON` wskazującego absolutną ścieżkę interpretera +z zależnościami `mcp/uv.lock` oraz `TASKAND_MCP_PROFILES` wskazującego właściwy +prywatny snapshot. Sam import nie przyznaje praw do wykonywania narzędzi. +Operator dopuszcza wybrany URI istniejącą akcją rejestru `approve` i nadaje grant +`call` temu URI. Zwykłe wywołanie korzysta z `/api/proc/call`: + +```json +{"uri":"proc://taskand.dev/mcp-filesystem-cbf61858/read-text-file-e862d61b/v1", + "data":{"path":"/absolute/allowed/workspace/example.txt"},"timeout":35} +``` + +Most używa oficjalnego SDK: stdio oraz Streamable HTTP. Sprawdza schemat wejścia, +odcisk profilu i świeży kontrakt narzędzia przed wywołaniem; waliduje też wynik +strukturalny, jeśli serwer deklaruje jego schemat. Nie pobiera zewnętrznych +referencji JSON Schema. Limity: 500 narzędzi/serwer, katalog 1 MiB, +wejście/wynik 256 KiB, wywołanie MCP 25 s, wrapper 30 s. HTTPS jest dopuszczony; +zwykły HTTP wyłącznie na loopback. Brak obsługi nagłówków uwierzytelniających +HTTP jest zgłaszany jawnie. Wyjątek po wysłaniu wywołania daje +`OUTCOME_UNKNOWN`, `outcomeKnown:false`, bez automatycznego ponowienia. +`taskSuccessVerified:false` odróżnia odpowiedź narzędzia od niezależnego odbioru zadania. + +## Naprawione środowiska i źródła + +| Serwer | Potwierdzony problem i rozwiązanie | +| --- | --- | +| diff-dsl | Nieistniejący interpreter z konfiguracji Cursor. Osobna kopia `git archive` rewizji `c47973075671047a9d4be413d3e259bb9a5ebca5`, uruchamiana jako `python -m diff_dsl.mcp_server`. Ta przypięta rewizja udostępnia 2 narzędzia; roboczy katalog źródłowy udostępniał 4. | +| tillm | Brak lokalnego `.venv/bin/mcp2tillm`; drugi znaleziony launcher również importował brakujący moduł. Osobny runtime z rewizji `26c87bfcdd6f2c26f34035cc2a987ec9443d9e44`, z pakietami tillm, dsl2tillm, nlp2tillm i mcp2tillm. | +| microsoft/markitdown | `markitdown-mcp==0.0.1a4` używa MCP 1.8.x i nie obsługuje poprawnie sondy `server/discover`. Osobny runtime i jawne `taskand_protocol_mode: legacy` przywracają inicjalizację. | + +Dodatkowe implementacje mają przypięte wersje potwierdzone w +[wydaniach projektu](https://github.com/modelcontextprotocol/servers/releases): + +| Serwer | Zainstalowany pakiet | Ograniczenie lokalnego profilu | +| --- | --- | --- | +| filesystem | `@modelcontextprotocol/server-filesystem@2026.8.31` | Osobny katalog testowy. | +| memory | `@modelcontextprotocol/server-memory@2026.8.31` | Osobny plik grafu pamięci. | +| git | `mcp-server-git==2026.8.18` | Repozytorium ticket-030; test tylko odczytu statusu. | +| fetch | `mcp-server-fetch==2026.8.18` | Test pobiera publiczny README źródłowego projektu. | + +npm zainstalowano z `--save-exact --ignore-scripts`; Python w osobnych środowiskach uv. +Lock npm i środowiska serwerów pozostają lokalnymi artefaktami instalacji. +Import istniejącej konfiguracji przypina polecenie, argumenty, środowisko i schemat; +nie zamraża całego drzewa zależności ani zawartości każdej istniejącej instalacji. +W szczególności konfiguracje operatora zawierające ruchome wersje nadal wymagają +kontrolowanej aktualizacji. Pin profilu nie jest podpisem ani izolacją systemową. + +## Katalog lokalny + +| Serwer | Narzędzia | +| --- | ---: | +| algitex-aider | 3 | +| algitex-code2llm | 4 | +| algitex-planfile | 6 | +| algitex-proxym | 4 | +| algitex-vallm | 6 | +| chrome-devtools | 29 | +| code2logic | 4 | +| code2schema | 4 | +| curllm | 7 | +| data2dsl | 4 | +| diff-dsl | 2 | +| doql | 12 | +| env2llm | 7 | +| fetch | 1 | +| filesystem | 14 | +| gillm | 7 | +| git | 12 | +| hillm | 3 | +| ifuri-marketing | 14 | +| imgl | 3 | +| intract | 7 | +| iterun | 10 | +| koru | 36 | +| llx | 17 | +| memory | 9 | +| microsoft/markitdown | 1 | +| monag | 11 | +| nexu | 16 | +| nlp2cmd | 5 | +| nlp2dsl | 4 | +| nlp2env | 19 | +| nlp2uri | 15 | +| op3 | 4 | +| planfile | 9 | +| pycharm | 42 | +| redup | 8 | +| subactor-search | 3 | +| sumd | 13 | +| testql | 9 | +| text2dsl | 3 | +| tillm | 6 | +| todomat | 6 | +| toonic | 5 | +| vallm | 7 | +| vql | 11 | + + +## Walidacja i uruchomiony pilot + +- Dotychczasowy adapter Taskand MCP: 9/9 testów; kontroler ticket-029: 11/11. +- Końcowy `make test` z 422 zarejestrowanymi kandydatami: konformacja 4/4, + kontrakty 30/30, negatywne 17/17, twin 9, web twin 8, CLI 7, + MCP Python 11 i rejestr MCP Node 1; wszystkie przeszły. +- `node --test tests/doctor_diagnostics.test.mjs`: 14/14. Nowy test najpierw + odtworzył ucięcie JSON na potoku; po poprawce oba rzeczywiste programy + zachowują wszystkie 1024 ustalenia, ostatni rekord i tryb planowania. +- Integracja jest lokalnym commitem `9384699affb303d1ad31831eeea7181116e34680`. + Zależność ticket-031, commit `8fa9d47b389a9c0ab4b7a12601fad1b8ac23b95b`, + zastępuje asynchroniczny zapis stdout synchronicznym `writeFileSync(1, ...)` + przed istniejącym `process.exit`. Poprawka obejmuje doctor/prescribe i + doctor/heal oraz odświeża wyłącznie ich bindingHash. Dołączono ją lokalnie + do gałęzi ticket-030 przez fast-forward. +- `./project/governance-check.sh`: zero błędów i ostrzeżeń. +- Testy MCP uruchamiają prawdziwe serwery SDK stdio/HTTP i rzeczywisty rejestr; + sprawdzają schematy, zmianę profilu/kontraktu, kandydaturę/dopuszczenie, + niezmienność pakietów, kolizje nazw, symlinki oraz brak ponowienia niepewnego efektu. +- Pełna droga adapter Taskand MCP → gateway 8084 → natywny URI → filesystem MCP + przeszła test; `describe_process` udostępnia schemat, a `call_process` zwraca + oczekiwaną zawartość znanego pliku. +- Import 45 serwerów zakończył się bez niedostępnych integracji i zarejestrował + 422 kandydatów w roboczym `mcp/`. +- Pilot `http://127.0.0.1:8084`, jednostka użytkownika + `taskand-mcp030-release.service`, ma 7 dopuszczonych narzędzi i 415 kandydatów. + Profile, token i surowe odpowiedzi przechowuje prywatny magazyn hosta. + Po wdrożeniu powtórzono wszystkie siedem wywołań i test adaptera. + Health zgłasza `LOCAL_CODE_MATCH`; sprawdzono odciski 447 plików źródłowych + manifestu wydania. Doctor/prescribe zwraca pełny JSON 230708 bajtów, + a doctor/heal 217052 bajtów; oba zawierają 416 rekordów. Heal pozostaje + w trybie planowania bez wykonanych napraw. +- Rzeczywiste wywołania URI: odczyt pliku filesystem, odczyt memory, + status git, fetch publicznego README, konwersja lokalnego HTML MarkItDown, + tillm health i analiza dwóch manifestów diff-dsl. Odpowiedzi sprawdzono + względem znanych danych testowych; health tillm jest diagnostyką zależności. +- Pierwsza analiza diff-dsl dostała pusty katalog bez wymaganych dwóch manifestów. + Serwer zwrócił błąd JSON-RPC, zaklasyfikowany zachowawczo jako `OUTCOME_UNKNOWN`. + Po odczycie źródła potwierdzono odczytowy charakter operacji i wykonano nowy + test na dwóch odrębnych, poprawnych manifestach. Nie ponawiano go automatycznie. + +Powtarzalne dowody są w testach repozytorium. Lokalne obserwacje wdrożenia: +- `final-import.json`: SHA-256 `f75187b4f538fcff8e529513e0242f03274330286b6aea61fc31438cac3d4b51`. +- `gateway-canaries-verified.json`: SHA-256 `d4ab73a6998e2691636e36d72cb848a9434aae605c1218ffd3223e90ece28b93`. + +- `deployed-verification.json`: SHA-256 `75cd58a9263facf54284189e44fc9784ccfb388fe1b0272b7208e73f665ff915`. + +Magazyn operacyjny: `~/.local/state/taskand/mcp-local-import-20260919/`; +instalacje i pilot: `~/.local/share/taskand/mcp-import/`. +Te ścieżki pomagają operatorowi lokalnemu; nie zastępują dostępnego w repo kodu +ani niezależnych dowodów publikacji. Rewizja w metadanych wskazuje wdrożony commit implementacji. Późniejszy +commit odbioru aktualizuje wyłącznie ten dokument i kryteria ticket-030. + + +## Ograniczenia odbioru + +`tools/list` sprawdzono dla wszystkich 45 serwerów; logikę narzędzi sprawdzono +na siedmiu wybranych wywołaniach, nie na wszystkich 422. Nie wykonano działań +płatnych, publikacji ani arbitralnych operacji zapisu z importowanych katalogów. +Tillm health zgłasza brak opcjonalnych pakietów nlp2dsl, intract, redsl, proxym, +llx i env2llm w jego izolowanym środowisku. Nie dowodzi to gotowości jego +komend wykonujących zadania; odpowiednie samodzielne MCP są osobno w katalogu. + +Most uruchamia zaufane programy operatora pod jego kontem; nie jest sandboxem +systemowym. Schemat MCP ani adnotacja readOnly nie przyznają uprawnień. +Utrata odpowiedzi może pozostawić efekt i wymaga uzgodnienia stanu przed kolejnym +wywołaniem. Hostowe profile mogą zawierać sekrety i nie trafiają do Git. + +Integracja jest lokalnie wdrożona, lecz nie jest scalona z main ani wypchnięta. +Zachowany PR #38 ma HEAD `803771c0877ed7a2e21bdc049602efd4ad9a1fd8` i czeka na chronione +lokalne CI: brak `/run/onedev-docker-gate/taskand-browser.socket`, zależność +[onedev-agent #304](https://github.com/subactor/onedev-agent/issues/304). +Nowa implementacja nie obchodzi tej kontroli. Po ponownej decyzji użytkownika +zatrzymano dokładną sesję dashboardu i zwolniono jej lease przez CAS. Jej czysty +checkout, w tym ignorowane dane, zachowano w prywatnym archiwum przed zwolnieniem +katalogu; gałąź, PR38 i wdrożenie na 8082 pozostały zachowane. Audyt dokumentacji wykazuje +cztery istniejące wcześniej problemy metadanych/placeholderów w innych dokumentach; +zapisano je w kolejce PLF-010. Nowy dokument nie dodaje takiego problemu. + + +## Obsługa i dalsza dostawa + +Dodatkowe narzędzia dopuszczać indywidualnie po ocenie konkretnego działania; +import sam zachowuje stan candidate. Zmiany konfiguracji importować jako nowe +wersje z osobnym snapshotem profili. Wersja uruchomiona na 8084 pochodzi z `8fa9d47`; manifest plików wiąże ją +z commitem. Poprzedni dziennik żądań skopiowano po zatrzymaniu starej jednostki +pilota, zachowując również poprzedni katalog jako punkt odtworzenia. +Pełna publikacja wymaga chronionego +przeglądu i działającego lokalnego wykonawcy CI. + +Rollback pilota: `systemctl --user stop taskand-mcp030-release.service`. +Zachować katalog pilota, profile i receipts do uzgodnienia efektów; istniejące +instancje 8077/8082 pozostają niezależne. Jednostka jest lokalnym pilotem sesji, +nie deklaracją wdrożenia produkcyjnego ani konfiguracji startu po restarcie hosta. diff --git a/gateway/__init__.py b/gateway/__init__.py index d042e45..1d5e185 100644 --- a/gateway/__init__.py +++ b/gateway/__init__.py @@ -46,7 +46,11 @@ def do_OPTIONS(self) -> None: def do_GET(self) -> None: if urlsplit(self.path).path in ('/', '/index.html'): - page = (Path(__file__).resolve().parent.parent / 'index.html').read_bytes() + try: + page = (Path(__file__).resolve().parent.parent / 'index.html').read_bytes() + except OSError: + self._send(503, {'ok': False, 'error': 'PANEL_UNAVAILABLE'}) + return self.send_response(200) self.send_header('Content-Type', 'text/html; charset=utf-8') self.send_header('Cache-Control', 'no-store') diff --git a/gateway/handlers/chat.py b/gateway/handlers/chat.py index f358989..67d11d8 100644 --- a/gateway/handlers/chat.py +++ b/gateway/handlers/chat.py @@ -3,6 +3,31 @@ from gateway.utils import call_process, status_for DEV_CHAT = "proc://taskand.dev/dev/chat/v1" +LLM_URI = 'proc://taskand.dev/dev/llm/v1' + + +def handle_conversation(handler, body): + """A conversation uses a fixed LLM process; it never dispatches tools.""" + if not require_grant(handler, LLM_URI, 'call'): + return + messages = body.get('messages') + if (not isinstance(messages, list) or not 1 <= len(messages) <= 24 + or any(not isinstance(m, dict) or set(m) != {'role', 'content'} + or m['role'] not in {'user', 'assistant'} + or not isinstance(m['content'], str) or not m['content'].strip() + or len(m['content']) > 12000 for m in messages) + or messages[-1]['role'] != 'user' + or sum(len(m['content']) for m in messages) > 48000): + handler._send(400, {'ok': False, 'error': 'CONVERSATION_MESSAGES_INVALID'}) + return + result = call_process(LLM_URI, {'messages': [{'role': 'system', 'content': + 'Odpowiadaj po polsku. To rozmowa i przygotowanie zadań. Nie masz narzędzi ' + 'w tym trybie. Nie twierdź, że wykonałeś operacje ani odczytałeś aktualny stan usług.'}, + *messages], 'max_tokens': 2500, 'reasoning_effort': 'low'}, timeout=100) + if not isinstance(result, dict) or result.get('ok') is not True or not isinstance(result.get('content'), str) or not result['content'].strip(): + handler._send(503, {'ok': False, 'error': 'CONVERSATION_MODEL_UNAVAILABLE'}) + return + handler._send(200, {'ok': True, 'reply': result['content'], 'mode': 'conversation', 'toolsExecuted': False}) def handle_chat(request_handler, body: dict) -> None: diff --git a/gateway/handlers/context.py b/gateway/handlers/context.py index d06798b..56994b2 100644 --- a/gateway/handlers/context.py +++ b/gateway/handlers/context.py @@ -1,10 +1,55 @@ """Context commands and read-only owner-filtered projections. No impersonation.""" from urllib.parse import parse_qs, urlsplit -from gateway.auth import check_auth, check_grant +from gateway.auth import check_auth, check_grant, require_grant from gateway.context import ACTIVE, ContextError, default_store -from gateway.utils import call_process +from gateway.utils import call_process, registry, LLM_KEY PLANNER = 'proc://taskand.dev/planner/plan/v1' +MCP_CATALOG = 'proc://taskand.dev/mcp/catalog/v1' + + +def handle_mcp_catalog(handler, body): + """Authenticated metadata projection, with a read-only dashboard capability. + + This capability grants no registry/core call, admission or tool execution. + Host profiles, commands, environment and credential references stay private. + """ + user = require_grant(handler, MCP_CATALOG, 'read') + if not user: + return + source = registry('list', {}, timeout=30) + entries = source.get('processes') if isinstance(source, dict) else None + if not isinstance(source, dict) or not source.get('ok') or not isinstance(entries, list) or len(entries) > 5000: + handler._send(503, {'ok': False, 'error': 'MCP_CATALOG_UNAVAILABLE'}) + return + tools = [] + for entry in entries: + if not isinstance(entry, dict): + handler._send(503, {'ok': False, 'error': 'MCP_CATALOG_INVALID'}) + return + if entry.get('origin') != 'mcp': + continue + mcp = entry.get('mcp', {}) + uri, status = entry.get('uri'), entry.get('status') + if (not isinstance(mcp, dict) or not all(isinstance(mcp.get(k), str) and mcp[k] for k in ('server', 'tool')) + or not isinstance(uri, str) or not uri.startswith('proc://taskand.dev/mcp-') + or status not in {'candidate', 'active', 'deprecated'} + or not isinstance(entry.get('inputSchema'), dict)): + handler._send(503, {'ok': False, 'error': 'MCP_CATALOG_INVALID'}) + return + tools.append({'uri': uri, 'server': mcp['server'], 'name': mcp['tool'], + 'description': entry.get('desc', ''), 'status': status, + 'inputSchema': entry['inputSchema'], + **({'outputSchema': entry['outputSchema']} if isinstance(entry.get('outputSchema'), dict) else {}), + 'canCall': status == 'active' and check_grant(user, uri, 'call'), + 'canApprove': status != 'active' and check_grant(user, uri, 'admin')}) + tools.sort(key=lambda t: (t['server'], t['name'], t['uri'])) + handler._send(200, {'ok': True, 'tools': tools, + 'counts': {'servers': len({t['server'] for t in tools}), 'tools': len(tools), + 'active': sum(t['status'] == 'active' for t in tools), + 'candidate': sum(t['status'] == 'candidate' for t in tools)}, + 'conversation': {'allowed': check_grant(user, 'proc://taskand.dev/dev/llm/v1', 'call'), + 'configured': bool(LLM_KEY)}}) def identity(handler): diff --git a/gateway/router.py b/gateway/router.py index 75ce555..373643b 100644 --- a/gateway/router.py +++ b/gateway/router.py @@ -2,17 +2,19 @@ from gateway.handlers.health import handle_healthz from gateway.handlers.federation import handle_federation, handle_registry, handle_well_known_catalog from gateway.handlers.proc import handle_proc_call -from gateway.handlers.chat import handle_chat +from gateway.handlers.chat import handle_chat, handle_conversation from gateway.handlers.doctor import handle_doctor from gateway.handlers.planner import handle_planner from gateway.handlers.orchestrator import handle_orchestrator -from gateway.handlers.context import handle_context, handle_state +from gateway.handlers.context import handle_context, handle_state, handle_mcp_catalog from gateway.handlers.mesh import handle_mesh from gateway.handlers.observers import handle_pilot from gateway.handlers.monag_handler import handle_monag_status, handle_monag_resume, handle_notify from urllib.parse import urlsplit ROUTES: Dict[Tuple[str, str], Callable] = { + ('GET', '/api/mcp/catalog'): handle_mcp_catalog, + ('POST', '/api/conversation'): handle_conversation, ('GET', '/api/mesh/state'): handle_mesh, ('POST', '/api/observers/pilot'): handle_pilot, ('GET', '/api/context'): handle_context, diff --git a/generated/doctor/heal/taskand.dev/v1/bin.mjs b/generated/doctor/heal/taskand.dev/v1/bin.mjs index 05c7ca3..fd6c261 100644 --- a/generated/doctor/heal/taskand.dev/v1/bin.mjs +++ b/generated/doctor/heal/taskand.dev/v1/bin.mjs @@ -2,7 +2,7 @@ // proc://taskand.dev/doctor/heal/v1 — pętla samonaprawy: diagnose → prescribe → wykonanie recept "organism" → ponowna diagnoza. // in: { run?: true, max? }. Granice: policy.healing (auto|manual|off), max napraw na przebieg, odstęp między naprawami tego samego obiektu. // Recepty "human" (Docker, sekrety, kod builtin) są tylko raportowane. -import { readFileSync } from 'node:fs'; +import { readFileSync, writeFileSync } from 'node:fs'; import { fileURLToPath } from 'node:url'; import { registry, call } from './registry-client.mjs'; @@ -96,6 +96,7 @@ function report() { } function done(out) { - process.stdout.write(JSON.stringify(out) + '\n'); + // A forced exit must not discard the tail of a large JSON response on a pipe. + writeFileSync(1, JSON.stringify(out) + '\n'); process.exit(0); } diff --git a/generated/doctor/prescribe/taskand.dev/v1/bin.mjs b/generated/doctor/prescribe/taskand.dev/v1/bin.mjs index 4bf9eab..c8362b4 100755 --- a/generated/doctor/prescribe/taskand.dev/v1/bin.mjs +++ b/generated/doctor/prescribe/taskand.dev/v1/bin.mjs @@ -1,7 +1,7 @@ #!/usr/bin/env node // proc://taskand.dev/doctor/prescribe/v1 — recepty z ustaleń diagnozy (deterministyczne reguły). // Każda recepta: executor "organism" (remedies: kroki proc:// dla doctor/heal) albo "human" (polecenie + powód). -import { readFileSync } from 'node:fs'; +import { readFileSync, writeFileSync } from 'node:fs'; import { call } from './registry-client.mjs'; let input; @@ -58,13 +58,14 @@ const RULES = { const diag = input.diagnosis || call('proc://taskand.dev/doctor/diagnose/v1', {}, 90000); if (!Array.isArray(diag.findings)) { - process.stdout.write(JSON.stringify({ ok: false, error: `Brak diagnozy: ${diag.error}` }) + '\n'); + writeFileSync(1, JSON.stringify({ ok: false, error: `Brak diagnozy: ${diag.error}` }) + '\n'); process.exit(0); } const prescriptions = diag.findings.map(f => ({ finding: f, ...(RULES[f.code]?.(f) || human('brak reguły', `Nieznany kod ${f.code}`)) })); const byOrganism = prescriptions.filter(p => p.executor === 'organism').length; -process.stdout.write(JSON.stringify({ +// Complete pipe output before process.exit; large candidate catalogs exceed pipe capacity. +writeFileSync(1, JSON.stringify({ ok: true, healthy: diag.healthy, prescriptions, diff --git a/generated/doctor/registry.json b/generated/doctor/registry.json index 0bd5dda..b3217d4 100644 --- a/generated/doctor/registry.json +++ b/generated/doctor/registry.json @@ -37,11 +37,11 @@ "proc.yaml", "registry-client.mjs" ], - "hash": "sha256:4b743fc34f579ae7e347c517631456884e93cf30802c6079fcbf2069132c6151", + "hash": "sha256:107c79c5d68d62c99b99e533d24de3db5a9a1299682b6b3ecae40ca57581148f", "status": "active", "registered": "2026-09-12T19:09:04.970Z", "capability": "Recepty naprawcze wyliczane z bieżącej diagnozy", - "refreshedAt": "2026-09-13T06:38:40.925Z" + "refreshedAt": "2026-09-19T19:13:31.640Z" }, "proc://taskand.dev/doctor/heal/v1": { "uri": "proc://taskand.dev/doctor/heal/v1", @@ -58,10 +58,11 @@ "proc.yaml", "registry-client.mjs" ], - "hash": "sha256:07a34a366b1fa736157149ec339cf6f88f800bf4d170f1b167e69bbc6837e8e4", + "hash": "sha256:97afd41978e9581c3e269802d38e4cbd91a477f04f959abe21102825d2b86f1d", "status": "active", - "registered": "2026-09-13T05:42:54.873Z" + "registered": "2026-09-13T05:42:54.873Z", + "refreshedAt": "2026-09-19T19:13:31.687Z" } }, - "updated": "2026-09-14T09:48:53.622Z" + "updated": "2026-09-19T19:13:31.687Z" } diff --git a/generated/registry/core/taskand.dev/v1/federation.mjs b/generated/registry/core/taskand.dev/v1/federation.mjs index 97354bd..6d02079 100644 --- a/generated/registry/core/taskand.dev/v1/federation.mjs +++ b/generated/registry/core/taskand.dev/v1/federation.mjs @@ -8,7 +8,11 @@ import { register } from './lifecycle.mjs'; export function exportCatalog() { const processes = allEntries() .filter(e => e.status === 'active') - .map(({ uri, organism, desc, kind, hash, origin, files }) => ({ uri, organism, desc, kind, hash, origin, files })); + .map(({ uri, organism, desc, kind, hash, origin, files, inputSchema, outputSchema, mcp }) => ({ + uri, organism, desc, kind, hash, origin, files, + ...(inputSchema ? { inputSchema } : {}), ...(outputSchema ? { outputSchema } : {}), + ...(mcp ? { mcp } : {}) + })); return { ok: true, standard: 'taskand-registry/1', node: NODE_ID, generated: new Date().toISOString(), processes }; } diff --git a/generated/registry/core/taskand.dev/v1/lifecycle.mjs b/generated/registry/core/taskand.dev/v1/lifecycle.mjs index 1a74db3..0d2897b 100644 --- a/generated/registry/core/taskand.dev/v1/lifecycle.mjs +++ b/generated/registry/core/taskand.dev/v1/lifecycle.mjs @@ -1,11 +1,12 @@ // Cykl życia wpisu: register (candidate|active wg polityki) → approve → deprecate; verify i scan -import { readdirSync, existsSync } from 'node:fs'; +import { readdirSync, existsSync, readFileSync } from 'node:fs'; import { join, relative } from 'node:path'; -import { ROOT, GEN, parseUri, organisms, allEntries, findEntry, updateRegistry, policy, addToGenome, audit } from './store.mjs'; +import { ROOT, organismDir, parseUri, organisms, allEntries, findEntry, updateRegistry, policy, addToGenome, audit } from './store.mjs'; import { packageFiles, packageHash, readManifest, checkPackage } from './package.mjs'; // builtin → active; evolved → wg policy.evolution (auto|manual); peer:* → wg policy.peers (domyślnie manual) function initialStatus(origin) { + if (origin === 'mcp') return 'candidate'; if (origin.startsWith('peer:')) return policy('peers', 'manual') === 'auto' ? 'active' : 'candidate'; if (origin === 'evolved') return policy('evolution', 'manual') === 'auto' ? 'active' : 'candidate'; return 'active'; @@ -24,6 +25,10 @@ export function register({ uri, origin, hold = false }) { if (existing) return { ok: true, entry: existing, unchanged: true }; const m = readManifest(loc.dir); const src = origin || m.origin || 'builtin'; + const contract = src === 'mcp' ? JSON.parse(readFileSync(join(loc.dir, 'tool.json'), 'utf8')) : null; + if (contract && (contract.uri !== uri || !contract.tool?.inputSchema || typeof contract.tool.inputSchema !== 'object')) { + return { ok: false, error: 'Invalid MCP tool contract' }; + } const entry = { uri, organism: loc.organism, @@ -37,7 +42,10 @@ export function register({ uri, origin, hold = false }) { files: packageFiles(loc.dir), hash, status: hold ? 'candidate' : initialStatus(src), - registered: new Date().toISOString() + registered: new Date().toISOString(), + ...(contract ? { inputSchema: contract.tool.inputSchema, + ...(contract.tool.outputSchema ? { outputSchema: contract.tool.outputSchema } : {}), + mcp: { server: contract.server, tool: contract.tool.name, profilePin: contract.profilePin } } : {}) }; updateRegistry(loc.organism, reg => { reg.processes[uri] = entry; @@ -110,8 +118,8 @@ export function verify() { // Pakiety na dysku bez wpisu w rejestrze; {adopt:true} rejestruje je (origin z proc.yaml) export function scan({ adopt = false } = {}) { const found = organisms().flatMap(org => - readdirSync(join(GEN, org), { withFileTypes: true }).filter(d => d.isDirectory()).flatMap(cap => { - const base = join(GEN, org, cap.name, 'taskand.dev'); + readdirSync(organismDir(org), { withFileTypes: true }).filter(d => d.isDirectory()).flatMap(cap => { + const base = join(organismDir(org), cap.name, 'taskand.dev'); return existsSync(base) ? readdirSync(base).map(v => `proc://taskand.dev/${org}/${cap.name}/${v}`) : []; }) ); diff --git a/generated/registry/core/taskand.dev/v1/store.mjs b/generated/registry/core/taskand.dev/v1/store.mjs index fa10de5..2105a48 100644 --- a/generated/registry/core/taskand.dev/v1/store.mjs +++ b/generated/registry/core/taskand.dev/v1/store.mjs @@ -1,11 +1,12 @@ // Rejestry organizmów: generated//registry.json (jedyny zapisujący: registry/core, z blokadą) -import { readFileSync, writeFileSync, readdirSync, existsSync, openSync, closeSync, unlinkSync, statSync, renameSync, appendFileSync, chownSync } from 'node:fs'; +import { readFileSync, writeFileSync, readdirSync, existsSync, openSync, closeSync, unlinkSync, statSync, lstatSync, renameSync, appendFileSync, chownSync } from 'node:fs'; import { fileURLToPath } from 'node:url'; import { join } from 'node:path'; import { hostname } from 'node:os'; export const ROOT = fileURLToPath(new URL('../../../../..', import.meta.url)).replace(/\/$/, ''); export const GEN = join(ROOT, 'generated'); +export const MCP = join(ROOT, 'mcp'); export const NODE_ID = process.env.TASKAND_NODE || hostname(); const GENOME = join(ROOT, 'genome.yaml'); const URI_RE = /^proc:\/\/taskand\.dev\/([a-z0-9][a-z0-9-]*)\/([a-z0-9][a-z0-9-]*)\/(v[0-9]+)$/; @@ -17,10 +18,31 @@ export function parseUri(uri) { const m = URI_RE.exec(String(uri || '')); if (!m) return null; const [, organism, capability, version] = m; - return { uri, organism, capability, version, dir: join(GEN, organism, capability, 'taskand.dev', version) }; + const root = organismDir(organism); + const dir = join(root, capability, 'taskand.dev', version); + for (const path of [join(root, capability), join(root, capability, 'taskand.dev'), dir]) rejectSymlink(path); + return { uri, organism, capability, version, dir }; } -const registryFile = organism => join(GEN, organism, 'registry.json'); +function rejectSymlink(path) { + try { + if (lstatSync(path).isSymbolicLink()) throw new Error('REGISTRY_SYMLINK'); + } catch (error) { + if (error.code !== 'ENOENT') throw error; + } +} + +// The reserved mcp-* namespace has one canonical root, never a fallback path. +export function organismDir(organism) { + if (!/^[a-z0-9][a-z0-9-]*$/.test(organism)) throw new Error('INVALID_ORGANISM'); + const root = organism.startsWith('mcp-') ? MCP : GEN; + const dir = join(root, organism); + for (const path of [root, dir]) rejectSymlink(path); + if (organism.startsWith('mcp-') && existsSync(join(GEN, organism))) throw new Error('MCP_NAMESPACE_COLLISION'); + return dir; +} + +const registryFile = organism => join(organismDir(organism), 'registry.json'); export function readRegistry(organism) { try { @@ -31,10 +53,16 @@ export function readRegistry(organism) { } export function organisms() { - return readdirSync(GEN, { withFileTypes: true }) - .filter(d => d.isDirectory() && /^[a-z0-9]/.test(d.name)) - .map(d => d.name) - .sort(); + const names = [GEN, MCP].flatMap(root => { + if (!existsSync(root)) return []; + if (lstatSync(root).isSymbolicLink()) throw new Error('REGISTRY_SYMLINK'); + return readdirSync(root, { withFileTypes: true }) + .filter(d => d.isDirectory() && /^[a-z0-9][a-z0-9-]*$/.test(d.name) + && (root === MCP ? d.name.startsWith('mcp-') : true)) + .map(d => d.name); + }); + for (const name of names) organismDir(name); + return names.sort(); } export function allEntries() { @@ -61,7 +89,7 @@ const sleep = ms => Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, // Zapis rejestru organizmu pod blokadą O_EXCL (równoległe ewolucje nie nadpisują się nawzajem) export function updateRegistry(organism, mutate) { - const lock = join(GEN, organism, '.registry.lock'); + const lock = join(organismDir(organism), '.registry.lock'); const deadline = Date.now() + LOCK_WAIT_MS; let fd; while (fd === undefined) { @@ -84,7 +112,7 @@ export function updateRegistry(organism, mutate) { const tmp = `${registryFile(organism)}.${process.pid}.tmp`; writeFileSync(tmp, JSON.stringify(reg, null, 2) + '\n'); renameSync(tmp, registryFile(organism)); - adoptOwnership(join(GEN, organism)); + adoptOwnership(organismDir(organism)); return result; } finally { closeSync(fd); diff --git a/generated/registry/registry.json b/generated/registry/registry.json index f846cf3..7b33aba 100644 --- a/generated/registry/registry.json +++ b/generated/registry/registry.json @@ -22,12 +22,12 @@ "store.mjs", "vault.mjs" ], - "hash": "sha256:b62cc9bd58d213ddac6e2f8e4376e39fb57b3b75acfb244476d37c9c333a942d", + "hash": "sha256:257418b5bd5995e2fdbb4a6f41771a5a5af0d85d8f62a68601213ec8da71b93c", "status": "active", "registered": "2026-09-12T19:09:43.334Z", - "refreshedAt": "2026-09-13T07:02:41.464Z", + "refreshedAt": "2026-09-19T18:35:44.561Z", "capability": "Rejestr węzła: wywołania przez URI (broker env/sekretów), cykl życia pakietów, wymiana rejestrów z peerami" } }, - "updated": "2026-09-13T07:02:41.464Z" + "updated": "2026-09-19T18:35:44.561Z" } diff --git a/index.html b/index.html index f3c4c2c..d070070 100644 --- a/index.html +++ b/index.html @@ -18,6 +18,8 @@ @media(max-width:760px){.visuals{grid-template-columns:1fr}} [hidden]{display:none!important}.topbar{position:sticky;top:0;z-index:10;background:#101820f5;padding:10px 0;border-bottom:1px solid #385064}.topbar nav{display:flex;flex-wrap:wrap;gap:8px}.topbar a{padding:10px 14px;text-decoration:none;border-radius:6px}.topbar a[aria-current=page]{background:#245b57;color:white}.access{margin:12px 0}.access input{max-width:420px}.chat-message{padding:12px 16px;margin:12px 0;border-radius:10px;background:#182633;white-space:pre-wrap;overflow-wrap:anywhere}.chat-message[data-role=user]{border-left:3px solid #91d6ce}.chat-message[data-role=assistant]{border-left:3px solid #a1cce9}#conversationLog{max-height:55vh;overflow:auto;min-height:140px}.chat-actions{display:flex;gap:8px;flex-wrap:wrap} + +.mcp-summary{display:flex;align-items:center;justify-content:space-between;gap:16px;padding:12px 16px;border:1px solid #385064;border-radius:8px}.mcp-filters{display:grid;grid-template-columns:1fr 2fr 1fr;gap:16px}.mcp-workspace{display:grid;grid-template-columns:minmax(0,1fr) minmax(0,1.4fr);gap:24px}#mcpTool{min-height:300px;max-width:100%}#mcpFields small{display:block}#mcpSchema{max-height:300px}#mcpDescription{overflow-wrap:anywhere}h3{font-size:1rem}#mcpHistory button{max-width:100%;overflow-wrap:anywhere;font-size:.85rem}@media(max-width:760px){.mcp-filters,.mcp-workspace{grid-template-columns:1fr}.mcp-summary{align-items:flex-start;flex-direction:column}}

taskand. kontekst · bliźniaki · live