diff --git a/.github/workflows/docker-hub.yml b/.github/workflows/docker-hub.yml index 5971fcfa7..707465514 100644 --- a/.github/workflows/docker-hub.yml +++ b/.github/workflows/docker-hub.yml @@ -5,13 +5,7 @@ on: workflow_dispatch: push: branches: - - 'main' - tags: - - 'v*' - pull_request: - branches: - - 'main' - - 'ci/trivy-fails' + - 'do-not-merge/hackathon-2025' env: DOCKER_USER: 1001:127 @@ -31,7 +25,6 @@ jobs: images: lasuite/impress-backend - name: Login to DockerHub - if: github.event_name != 'pull_request' run: echo "${{ secrets.DOCKER_HUB_PASSWORD }}" | docker login -u "${{ secrets.DOCKER_HUB_USER }}" --password-stdin - name: Run trivy scan @@ -43,10 +36,10 @@ jobs: name: Build and push uses: docker/build-push-action@v6 with: + push: true context: . target: backend-production build-args: DOCKER_USER=${{ env.DOCKER_USER }}:-1000 - push: ${{ github.event_name != 'pull_request' }} tags: ${{ steps.meta.outputs.tags }} labels: ${{ steps.meta.outputs.labels }} @@ -64,7 +57,6 @@ jobs: images: lasuite/impress-frontend - name: Login to DockerHub - if: github.event_name != 'pull_request' run: echo "${{ secrets.DOCKER_HUB_PASSWORD }}" | docker login -u "${{ secrets.DOCKER_HUB_USER }}" --password-stdin - name: Run trivy scan @@ -76,13 +68,13 @@ jobs: name: Build and push uses: docker/build-push-action@v6 with: + push: true context: . file: ./src/frontend/Dockerfile target: frontend-production build-args: | DOCKER_USER=${{ env.DOCKER_USER }}:-1000 PUBLISH_AS_MIT=false - push: ${{ github.event_name != 'pull_request' }} tags: ${{ steps.meta.outputs.tags }} labels: ${{ steps.meta.outputs.labels }} @@ -100,7 +92,6 @@ jobs: images: lasuite/impress-y-provider - name: Login to DockerHub - if: github.event_name != 'pull_request' run: echo "${{ secrets.DOCKER_HUB_PASSWORD }}" | docker login -u "${{ secrets.DOCKER_HUB_USER }}" --password-stdin - name: Run trivy scan @@ -112,11 +103,34 @@ jobs: name: Build and push uses: docker/build-push-action@v6 with: + push: true context: . file: ./src/frontend/servers/y-provider/Dockerfile target: y-provider build-args: DOCKER_USER=${{ env.DOCKER_USER }}:-1000 - push: ${{ github.event_name != 'pull_request' }} + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} + + build-and-push-mcp-server: + runs-on: ubuntu-latest + steps: + - name: Checkout repository + uses: actions/checkout@v4 + - name: Docker meta + id: meta + uses: docker/metadata-action@v5 + with: + images: lasuite/impress-mcp-server + - name: Login to DockerHub + run: echo "${{ secrets.DOCKER_HUB_PASSWORD }}" | docker login -u "${{ secrets.DOCKER_HUB_USER }}" --password-stdin + - name: Build and push + uses: docker/build-push-action@v6 + with: + push: true + context: ./src/mcp_server + file: ./src/mcp_server/Dockerfile + build-args: | + DOCKER_USER=${{ env.DOCKER_USER }}:-1000 tags: ${{ steps.meta.outputs.tags }} labels: ${{ steps.meta.outputs.labels }} @@ -125,7 +139,6 @@ jobs: - build-and-push-frontend - build-and-push-backend runs-on: ubuntu-latest - if: github.event_name != 'pull_request' steps: - uses: numerique-gouv/action-argocd-webhook-notification@main id: notify diff --git a/bin/Tiltfile b/bin/Tiltfile index 5b3e72a71..41c2789e4 100644 --- a/bin/Tiltfile +++ b/bin/Tiltfile @@ -39,10 +39,19 @@ docker_build( ] ) +docker_build( + 'localhost:5001/impress-mcp-server:latest', + context='../src/mcp_server', + dockerfile='../src/mcp_server/Dockerfile', +) + k8s_resource('impress-docs-backend-migrate', resource_deps=['postgres-postgresql']) k8s_resource('impress-docs-backend-createsuperuser', resource_deps=['impress-docs-backend-migrate']) k8s_resource('impress-docs-backend', resource_deps=['impress-docs-backend-migrate']) -k8s_yaml(local('cd ../src/helm && helmfile -n impress -e dev template .')) + +# helmfile in docker mount the current working directory and the helmfile.yaml +# requires the keycloak config in another directory +k8s_yaml(local('cd .. && helmfile -n impress -e ${DEV_ENV:-dev} template --file ./src/helm/helmfile.yaml')) migration = ''' set -eu diff --git a/src/backend/core/api/viewsets.py b/src/backend/core/api/viewsets.py index cf9930703..9582ecf24 100644 --- a/src/backend/core/api/viewsets.py +++ b/src/backend/core/api/viewsets.py @@ -25,13 +25,15 @@ import requests import rest_framework as drf from botocore.exceptions import ClientError +from knox.auth import TokenAuthentication from lasuite.malware_detection import malware_detection +from lasuite.oidc_resource_server.authentication import ResourceServerAuthentication from rest_framework import filters, status, viewsets from rest_framework import response as drf_response from rest_framework.permissions import AllowAny from rest_framework.throttling import UserRateThrottle -from core import authentication, enums, models +from core import authentication, enums, models, utils as core_utils from core.services.ai_services import AIService from core.services.collaboration_services import CollaborationService from core.utils import extract_attachments, filter_descendants @@ -430,9 +432,7 @@ class DocumentViewSet( ordering = ["-updated_at"] ordering_fields = ["created_at", "updated_at", "title"] pagination_class = Pagination - permission_classes = [ - permissions.DocumentAccessPermission, - ] + permission_classes = [permissions.DocumentAccessPermission] queryset = models.Document.objects.all() serializer_class = serializers.DocumentSerializer ai_translate_serializer_class = serializers.AITranslateSerializer @@ -669,10 +669,14 @@ def trashbin(self, request, *args, **kwargs): return self.get_response_for_queryset(queryset) @drf.decorators.action( - authentication_classes=[authentication.ServerToServerAuthentication], + authentication_classes=[ + authentication.ServerToServerAuthentication, + ResourceServerAuthentication, + TokenAuthentication, + ], detail=False, methods=["post"], - permission_classes=[], + permission_classes=[permissions.IsAuthenticated], url_path="create-for-owner", ) @transaction.atomic @@ -1349,6 +1353,25 @@ def media_check(self, request, *args, **kwargs): } return drf.response.Response(body, status=drf.status.HTTP_200_OK) + + @drf.decorators.action(detail=True, methods=["get"], url_path="content") + def content(self, request, *args, **kwargs): + """ + Get the content of a document + """ + + document = self.get_object() + + # content_type = response.headers.get("Content-Type", "") + + base64_yjs_content = document.content + content = core_utils.base64_yjs_to_markdown(base64_yjs_content) + + body = { + "content": content, + } + + return drf.response.Response(body, status=drf.status.HTTP_200_OK) @drf.decorators.action( detail=True, diff --git a/src/backend/core/authentication/__init__.py b/src/backend/core/authentication/__init__.py index c5fa0c711..d5c6c4e31 100644 --- a/src/backend/core/authentication/__init__.py +++ b/src/backend/core/authentication/__init__.py @@ -6,6 +6,15 @@ from rest_framework.exceptions import AuthenticationFailed +class AuthenticatedServer: + """ + Simple class to represent an authenticated server to be used along the + IsAuthenticated permission. + """ + + is_authenticated = True + + class ServerToServerAuthentication(BaseAuthentication): """ Custom authentication class for server-to-server requests. @@ -39,13 +48,16 @@ def authenticate(self, request): # Validate token format and existence auth_parts = auth_header.split(" ") if len(auth_parts) != 2 or auth_parts[0] != self.TOKEN_TYPE: - raise AuthenticationFailed("Invalid authorization header.") + # Do not raise here to leave the door open for other authentication methods + return None token = auth_parts[1] if token not in settings.SERVER_TO_SERVER_API_TOKENS: - raise AuthenticationFailed("Invalid server-to-server token.") + # Do not raise here to leave the door open for other authentication methods + return None - # Authentication is successful, but no user is authenticated + # Authentication is successful + return AuthenticatedServer(), token def authenticate_header(self, request): """Return the WWW-Authenticate header value.""" diff --git a/src/backend/core/models.py b/src/backend/core/models.py index e9880f527..3e2a26326 100644 --- a/src/backend/core/models.py +++ b/src/backend/core/models.py @@ -839,6 +839,7 @@ def get_abilities(self, user, ancestors_links=None): "children_list": can_get, "children_create": can_update and user.is_authenticated, "collaboration_auth": can_get, + "content": can_get, "cors_proxy": can_get, "descendants": can_get, "destroy": is_owner, diff --git a/src/backend/core/tests/test_user_token_api.py b/src/backend/core/tests/test_user_token_api.py new file mode 100644 index 000000000..ab0672959 --- /dev/null +++ b/src/backend/core/tests/test_user_token_api.py @@ -0,0 +1,131 @@ +""" +Test user_token API endpoints in the impress core app. +""" + +import pytest +from knox.models import get_token_model +from rest_framework.test import APIClient + +from core import factories, models + +pytestmark = pytest.mark.django_db +AuthToken = get_token_model() + +def test_api_user_token_list_anonymous(client): + """Anonymous users should not be allowed to list user tokens.""" + response = client.get("/api/v1.0/user-tokens/") + assert response.status_code == 403 + assert response.json() == { + "detail": "Authentication credentials were not provided." + } + + +def test_api_user_token_list_authenticated(client): + """ + Authenticated users should be able to list their own tokens. + Tokens are identified by digest, and include created/expiry. + """ + user = factories.UserFactory() + # Knox creates a token instance and a character string token key. + # The create method returns a tuple: (instance, token_key_string) + token_instance_1, _ = AuthToken.objects.create(user=user) + AuthToken.objects.create(user=user) # Another token for the same user + AuthToken.objects.create(user=factories.UserFactory()) # Token for a different user + + client.force_login(user) + + response = client.get("/api/v1.0/user-tokens/") + assert response.status_code == 200 + content = response.json() + assert len(content) == 2 + + # Check that the response contains the digests of the tokens created for the user + response_token_digests = {item["digest"] for item in content} + assert token_instance_1.digest in response_token_digests + + # Ensure the token_key is not listed + for item in content: + assert "token_key" not in item + assert "digest" in item + assert "created" in item + assert "expiry" in item + + +def test_api_user_token_create_anonymous(client): + """Anonymous users should not be allowed to create user tokens.""" + # The create endpoint does not take any parameters as per TokenCreateSerializer + # (user is implicit, other fields are read_only) + response = client.post("/api/v1.0/user-tokens/", data={}) + assert response.status_code == 403 + assert response.json() == { + "detail": "Authentication credentials were not provided." + } + + +def test_api_user_token_create_authenticated(client): + """ + Authenticated users should be able to create a new token. + The token key should be returned in the response upon creation. + """ + user = factories.UserFactory() + + client.force_login(user) + + # The create endpoint does not take any parameters as per TokenCreateSerializer + response = client.post("/api/v1.0/user-tokens/", data={}) + assert response.status_code == 201 + content = response.json() + + # Based on TokenCreateSerializer, these fields should be in the response + assert "token_key" in content + assert "digest" in content + assert "created" in content + assert "expiry" in content + assert len(content["token_key"]) > 0 # Knox token key should be non-empty + + # Verify the token was actually created in the database for the user + assert AuthToken.objects.filter(user=user, digest=content["digest"]).exists() + +def test_api_user_token_destroy_anonymous(client): + """Anonymous users should not be allowed to delete user tokens.""" + user = factories.UserFactory() + token_instance, _ = AuthToken.objects.create(user=user) + response = client.delete(f"/api/v1.0/user-tokens/{token_instance.digest}/") + assert response.status_code == 403 + assert AuthToken.objects.filter(digest=token_instance.digest).exists() + + +def test_api_user_token_destroy_authenticated_own_token(client): + """Authenticated users should be able to delete their own tokens.""" + user = factories.UserFactory() + token_instance, _ = AuthToken.objects.create(user=user) + + client.force_login(user) + + response = client.delete(f"/api/v1.0/user-tokens/{token_instance.digest}/") + assert response.status_code == 204 + assert not AuthToken.objects.filter(digest=token_instance.digest).exists() + + +def test_api_user_token_destroy_authenticated_other_user_token(client): + """Authenticated users should not be able to delete other users' tokens.""" + user = factories.UserFactory() + other_user = factories.UserFactory() + other_user_token_instance, _ = AuthToken.objects.create(user=other_user) + + client.force_login(user) # Log in as 'user' + + response = client.delete(f"/api/v1.0/user-tokens/{other_user_token_instance.digest}/") + # The default behavior for a non-found or non-permissioned item in DestroyModelMixin + # when the queryset is filtered (as in get_queryset) is often a 404. + assert response.status_code == 404 + assert AuthToken.objects.filter(digest=other_user_token_instance.digest).exists() + + +def test_api_user_token_destroy_non_existent_token(client): + """Attempting to delete a non-existent token should result in a 404.""" + user = factories.UserFactory() + client.force_login(user) + + response = client.delete("/api/v1.0/user-tokens/nonexistentdigest/") + assert response.status_code == 404 \ No newline at end of file diff --git a/src/backend/core/urls.py b/src/backend/core/urls.py index 054418954..73e73c062 100644 --- a/src/backend/core/urls.py +++ b/src/backend/core/urls.py @@ -4,15 +4,22 @@ from django.urls import include, path, re_path from lasuite.oidc_login.urls import urlpatterns as oidc_urls +from lasuite.oidc_resource_server.urls import urlpatterns as resource_server_urls from rest_framework.routers import DefaultRouter from core.api import viewsets +from core.user_token import viewsets as user_token_viewsets # - Main endpoints router = DefaultRouter() router.register("templates", viewsets.TemplateViewSet, basename="templates") router.register("documents", viewsets.DocumentViewSet, basename="documents") router.register("users", viewsets.UserViewSet, basename="users") +router.register( + "user-tokens", + user_token_viewsets.UserTokenViewset, + basename="user_tokens", +) # - Routes nested under a document document_related_router = DefaultRouter() @@ -44,6 +51,7 @@ [ *router.urls, *oidc_urls, + *resource_server_urls, re_path( r"^documents/(?P[0-9a-z-]*)/", include(document_related_router.urls), diff --git a/src/backend/core/user_token/__init__.py b/src/backend/core/user_token/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/src/backend/core/user_token/serializers.py b/src/backend/core/user_token/serializers.py new file mode 100644 index 000000000..0c7a61cb7 --- /dev/null +++ b/src/backend/core/user_token/serializers.py @@ -0,0 +1,27 @@ +from knox.models import get_token_model +from rest_framework import serializers + + +class TokenReadSerializer(serializers.ModelSerializer): + """Serialize token for list purpose.""" + + class Meta: + model = get_token_model() + fields = ["digest", "created", "expiry"] + read_only_fields = ["digest", "created", "expiry"] + + +class TokenCreateSerializer(serializers.ModelSerializer): + """Serialize token for creation purpose.""" + + class Meta: + model = get_token_model() + fields = ["user", "digest", "token_key", "created", "expiry"] + read_only_fields = ["digest", "token_key", "created", "expiry"] + extra_kwargs = {"user": {"write_only": True}} + + def create(self, validated_data): + """The default knox token create manager returns a tuple.""" + instance, token = super().create(validated_data) + instance.token_key = token # warning do not save this + return instance diff --git a/src/backend/core/user_token/viewsets.py b/src/backend/core/user_token/viewsets.py new file mode 100644 index 000000000..fb462092a --- /dev/null +++ b/src/backend/core/user_token/viewsets.py @@ -0,0 +1,50 @@ +"""API endpoints for user token management""" + +from knox.models import get_token_model +from rest_framework import permissions, viewsets, mixins +from rest_framework.authentication import SessionAuthentication + +from . import serializers + + +class UserTokenViewset( + mixins.CreateModelMixin, + mixins.ListModelMixin, + mixins.DestroyModelMixin, + viewsets.GenericViewSet, +): + """API ViewSet for user invitations to document. + + This view access is restricted to the session ie from frontend. + + GET /api/v1.0/user-token/ + Return list of existing tokens. + + POST /api/v1.0/user-token/ + Return newly created token. + + DELETE /api/v1.0/user-token// + Delete targeted token. + """ + + authentication_classes = [SessionAuthentication] + pagination_class = None + permission_classes = [permissions.IsAuthenticated] + queryset = get_token_model().objects.all() + serializer_class = serializers.TokenReadSerializer + + def get_queryset(self): + """Return the queryset restricted to the logged-in user.""" + queryset = super().get_queryset() + queryset = queryset.filter(user_id=self.request.user.pk) + return queryset + + def get_serializer_class(self): + if self.action == "create": + return serializers.TokenCreateSerializer + return super().get_serializer_class() + + def create(self, request, *args, **kwargs): + """Enforce request data to use current user.""" + request.data["user"] = self.request.user.pk + return super().create(request, *args, **kwargs) diff --git a/src/backend/core/utils.py b/src/backend/core/utils.py index 780431f49..4bc6de41b 100644 --- a/src/backend/core/utils.py +++ b/src/backend/core/utils.py @@ -66,6 +66,116 @@ def base64_yjs_to_text(base64_string): soup = BeautifulSoup(blocknote_structure, "lxml-xml") return soup.get_text(separator=" ", strip=True) +def base64_yjs_to_markdown(base64_string: str) -> str: + xml_content = base64_yjs_to_xml(base64_string) + soup = BeautifulSoup(xml_content, "lxml-xml") + + md_lines: list[str] = [] + + def walk(node) -> None: + if not getattr(node, "name", None): + return + + # Treat the synthetic “[document]” tag exactly like a wrapper + if node.name in {"[document]", "blockGroup", "blockContainer"}: + for child in node.find_all(recursive=False): + walk(child) + if node.name == "blockContainer": + md_lines.append("") # paragraph break + return + + # ----------- content nodes ------------- + if node.name == "heading": + level = int(node.get("level", 1)) + md_lines.extend([("#" * level) + " " + process_inline_formatting(node), ""]) + + elif node.name == "paragraph": + md_lines.extend([process_inline_formatting(node), ""]) + + elif node.name == "bulletListItem": + md_lines.append("- " + process_inline_formatting(node)) + + elif node.name == "numberedListItem": + idx = node.get("index", "1") + md_lines.append(f"{idx}. " + process_inline_formatting(node)) + + elif node.name == "checkListItem": + checked = "x" if node.get("checked") == "true" else " " + md_lines.append(f"- [{checked}] " + process_inline_formatting(node)) + + elif node.name == "codeBlock": + lang = node.get("language", "") + code = node.get_text("", strip=False) + md_lines.extend([f"```{lang}", code, "```", ""]) + + elif node.name in {"quote", "blockquote"}: + quote = process_inline_formatting(node) + for line in quote.splitlines() or [""]: + md_lines.append("> " + line) + md_lines.append("") + + elif node.name == "divider": + md_lines.extend(["---", ""]) + + elif node.name == "callout": + emoji = node.get("emoji", "💡") + md_lines.extend([f"> {emoji} {process_inline_formatting(node)}", ""]) + + elif node.name == "img": + src = node.get("src", "") + alt = node.get("alt", "") + md_lines.extend([f"![{alt}]({src})", ""]) + + # unknown tags are ignored + + # kick-off: start at the synthetic root + walk(soup) + + # collapse accidental multiple blank lines + cleaned: list[str] = [] + for line in md_lines: + if line == "" and (not cleaned or cleaned[-1] == ""): + continue + cleaned.append(line) + + return "\n".join(cleaned).rstrip() + "\n" + +def process_inline_formatting(element): + """ + Process inline formatting elements like bold, italic, underline, etc. + and convert them to markdown syntax. + """ + result = "" + + # If it's just a text node, return the text + if isinstance(element, str): + return element + + # Process children elements + for child in element.contents: + if isinstance(child, str): + result += child + elif hasattr(child, 'name'): + if child.name == "bold": + result += "**" + process_inline_formatting(child) + "**" + elif child.name == "italic": + result += "*" + process_inline_formatting(child) + "*" + elif child.name == "underline": + result += "__" + process_inline_formatting(child) + "__" + elif child.name == "strike": + result += "~~" + process_inline_formatting(child) + "~~" + elif child.name == "code": + result += "`" + process_inline_formatting(child) + "`" + elif child.name == "link": + href = child.get("href", "") + text = process_inline_formatting(child) + result += f"[{text}]({href})" + else: + # For other elements, just process their contents + result += process_inline_formatting(child) + + return result + def extract_attachments(content): """Helper method to extract media paths from a document's content.""" diff --git a/src/backend/impress/settings.py b/src/backend/impress/settings.py index 737bb3382..83a487091 100755 --- a/src/backend/impress/settings.py +++ b/src/backend/impress/settings.py @@ -10,6 +10,9 @@ https://docs.djangoproject.com/en/3.1/ref/settings/ """ +# pylint: disable=too-many-lines + +import datetime import os import tomllib from socket import gethostbyname, gethostname @@ -59,7 +62,7 @@ class Base(Configuration): """ DEBUG = False - USE_SWAGGER = False + USE_SWAGGER = values.BooleanValue(False, environ_name="USE_SWAGGER", environ_prefix=None) API_VERSION = "v1.0" @@ -303,6 +306,7 @@ class Base(Configuration): "django_filters", "dockerflow.django", "rest_framework", + "knox", "parler", "treebeard", "easy_thumbnails", @@ -318,6 +322,7 @@ class Base(Configuration): # OIDC third party "mozilla_django_oidc", "lasuite.malware_detection", + "drf_spectacular_sidecar" ] # Cache @@ -327,8 +332,9 @@ class Base(Configuration): REST_FRAMEWORK = { "DEFAULT_AUTHENTICATION_CLASSES": ( - "mozilla_django_oidc.contrib.drf.OIDCAuthentication", "rest_framework.authentication.SessionAuthentication", + "knox.auth.TokenAuthentication", + "lasuite.oidc_resource_server.authentication.ResourceServerAuthentication", ), "DEFAULT_PARSER_CLASSES": [ "rest_framework.parsers.JSONParser", @@ -593,6 +599,72 @@ class Base(Configuration): default=True, environ_name="ALLOW_LOGOUT_GET_METHOD", environ_prefix=None ) + # OIDC - Docs as a resource server + OIDC_OP_URL = values.Value( + default=None, environ_name="OIDC_OP_URL", environ_prefix=None + ) + OIDC_OP_INTROSPECTION_ENDPOINT = values.Value( + environ_name="OIDC_OP_INTROSPECTION_ENDPOINT", environ_prefix=None + ) + OIDC_VERIFY_SSL = values.BooleanValue( + default=True, environ_name="OIDC_VERIFY_SSL", environ_prefix=None + ) + OIDC_TIMEOUT = values.IntegerValue( + default=3, environ_name="OIDC_TIMEOUT", environ_prefix=None + ) + OIDC_PROXY = values.Value(None, environ_name="OIDC_PROXY", environ_prefix=None) + + OIDC_RS_BACKEND_CLASS = "lasuite.oidc_resource_server.backend.ResourceServerBackend" + OIDC_RS_AUDIENCE_CLAIM = values.Value( # The claim used to identify the audience + default="client_id", environ_name="OIDC_RS_AUDIENCE_CLAIM", environ_prefix=None + ) + OIDC_RS_PRIVATE_KEY_STR = values.Value( + default=None, + environ_name="OIDC_RS_PRIVATE_KEY_STR", + environ_prefix=None, + ) + OIDC_RS_ENCRYPTION_KEY_TYPE = values.Value( + default="RSA", + environ_name="OIDC_RS_ENCRYPTION_KEY_TYPE", + environ_prefix=None, + ) + OIDC_RS_ENCRYPTION_ALGO = values.Value( + default="RSA-OAEP", + environ_name="OIDC_RS_ENCRYPTION_ALGO", + environ_prefix=None, + ) + OIDC_RS_ENCRYPTION_ENCODING = values.Value( + default="A256GCM", + environ_name="OIDC_RS_ENCRYPTION_ENCODING", + environ_prefix=None, + ) + OIDC_RS_CLIENT_ID = values.Value( + None, environ_name="OIDC_RS_CLIENT_ID", environ_prefix=None + ) + OIDC_RS_CLIENT_SECRET = values.Value( + None, + environ_name="OIDC_RS_CLIENT_SECRET", + environ_prefix=None, + ) + OIDC_RS_SIGNING_ALGO = values.Value( + default="ES256", environ_name="OIDC_RS_SIGNING_ALGO", environ_prefix=None + ) + OIDC_RS_SCOPES = values.ListValue( + [], environ_name="OIDC_RS_SCOPES", environ_prefix=None + ) + + # User token (knox) + REST_KNOX = { + "SECURE_HASH_ALGORITHM": "hashlib.sha512", + "AUTH_TOKEN_CHARACTER_LENGTH": 64, + "TOKEN_TTL": datetime.timedelta(hours=24 * 7), + "TOKEN_LIMIT_PER_USER": None, + "AUTO_REFRESH": False, + "AUTO_REFRESH_MAX_TTL": None, + "MIN_REFRESH_INTERVAL": 60, + "AUTH_HEADER_PREFIX": "Token", + } + # AI service AI_FEATURE_ENABLED = values.BooleanValue( default=False, environ_name="AI_FEATURE_ENABLED", environ_prefix=None @@ -784,7 +856,7 @@ class Build(Base): This environment should not be used to run the application. Just to build it with non-blocking settings. """ - + USE_SWAGGER = True SECRET_KEY = values.Value("DummyKey") STORAGES = { "default": { @@ -839,7 +911,7 @@ class Development(Base): def __init__(self): # pylint: disable=invalid-name - self.INSTALLED_APPS += ["django_extensions", "drf_spectacular_sidecar"] + self.INSTALLED_APPS += ["django_extensions"] class Test(Base): @@ -852,10 +924,6 @@ class Test(Base): CELERY_TASK_ALWAYS_EAGER = values.BooleanValue(True) - def __init__(self): - # pylint: disable=invalid-name - self.INSTALLED_APPS += ["drf_spectacular_sidecar"] - class ContinuousIntegration(Test): """ diff --git a/src/backend/pyproject.toml b/src/backend/pyproject.toml index 1e4e9cc62..c55b145d2 100644 --- a/src/backend/pyproject.toml +++ b/src/backend/pyproject.toml @@ -36,12 +36,14 @@ dependencies = [ "django-lasuite[all]==0.0.9", "django-parler==2.3", "django-redis==5.4.0", + "django-rest-knox==5.0.2", "django-storages[s3]==1.14.6", "django-timezone-field>=5.1", "django==5.1.9", "django-treebeard==4.7.1", "djangorestframework==3.16.0", "drf_spectacular==0.28.0", + "drf-spectacular-sidecar==2025.5.1", "dockerflow==2024.4.2", "easy_thumbnails==2.10", "factory_boy==3.3.3", @@ -72,7 +74,6 @@ dependencies = [ dev = [ "django-extensions==4.1", "django-test-migrations==1.5.0", - "drf-spectacular-sidecar==2025.5.1", "freezegun==1.5.2", "ipdb==0.13.13", "ipython==9.2.0", diff --git a/src/frontend/apps/impress/src/features/header/components/Header.tsx b/src/frontend/apps/impress/src/features/header/components/Header.tsx index 39f3ce63b..62754c5bf 100644 --- a/src/frontend/apps/impress/src/features/header/components/Header.tsx +++ b/src/frontend/apps/impress/src/features/header/components/Header.tsx @@ -1,3 +1,5 @@ +import { Button } from '@openfun/cunningham-react'; +import { useRouter } from 'next/navigation'; import { useTranslation } from 'react-i18next'; import { css } from 'styled-components'; @@ -16,6 +18,7 @@ import { Title } from './Title'; export const Header = () => { const { t } = useTranslation(); + const router = useRouter(); const { spacingsTokens, colorsTokens } = useCunninghamTheme(); const { isDesktop } = useResponsiveStore(); @@ -63,6 +66,13 @@ export const Header = () => { ) : ( + diff --git a/src/frontend/apps/impress/src/features/user-tokens/api/index.ts b/src/frontend/apps/impress/src/features/user-tokens/api/index.ts new file mode 100644 index 000000000..2cf8ba169 --- /dev/null +++ b/src/frontend/apps/impress/src/features/user-tokens/api/index.ts @@ -0,0 +1,3 @@ +export * from './useListUserTokens'; +export * from './useCreateUserToken'; +export * from './useDeleteUserToken'; diff --git a/src/frontend/apps/impress/src/features/user-tokens/api/useCreateUserToken.ts b/src/frontend/apps/impress/src/features/user-tokens/api/useCreateUserToken.ts new file mode 100644 index 000000000..49959bdd4 --- /dev/null +++ b/src/frontend/apps/impress/src/features/user-tokens/api/useCreateUserToken.ts @@ -0,0 +1,28 @@ +import { useMutation } from '@tanstack/react-query'; + +import { APIError, errorCauses, fetchAPI } from '@/api'; + +import { NewUserToken } from '../types'; + +export const createUserToken = async (): Promise => { + const response = await fetchAPI(`user-tokens/`, { + method: 'POST', + // The backend test indicates no data is sent for creation, so body is an empty object + body: JSON.stringify({}), + }); + + if (!response.ok) { + throw new APIError( + 'Failed to create user token', + await errorCauses(response), + ); + } + + return response.json() as Promise; +}; + +export function useCreateUserToken() { + return useMutation({ + mutationFn: createUserToken, + }); +} diff --git a/src/frontend/apps/impress/src/features/user-tokens/api/useDeleteUserToken.ts b/src/frontend/apps/impress/src/features/user-tokens/api/useDeleteUserToken.ts new file mode 100644 index 000000000..b30bb0fde --- /dev/null +++ b/src/frontend/apps/impress/src/features/user-tokens/api/useDeleteUserToken.ts @@ -0,0 +1,30 @@ +import { useMutation } from '@tanstack/react-query'; + +import { APIError, errorCauses, fetchAPI } from '@/api'; + +export const deleteUserToken = async (digest: string): Promise => { + const response = await fetchAPI(`user-tokens/${digest}/`, { + method: 'DELETE', + }); + + if (!response.ok && response.status !== 204) { + // 204 is a valid response for delete + throw new APIError( + 'Failed to delete user token', + await errorCauses(response), + ); + } + // For 204, there's no content, and for other successful deletions, we don't expect content. + // So, we don't try to parse JSON. + return Promise.resolve(); +}; + +export type DeleteUserTokenParams = { + digest: string; +}; + +export function useDeleteUserToken() { + return useMutation({ + mutationFn: ({ digest }) => deleteUserToken(digest), + }); +} diff --git a/src/frontend/apps/impress/src/features/user-tokens/api/useListUserTokens.ts b/src/frontend/apps/impress/src/features/user-tokens/api/useListUserTokens.ts new file mode 100644 index 000000000..63c439177 --- /dev/null +++ b/src/frontend/apps/impress/src/features/user-tokens/api/useListUserTokens.ts @@ -0,0 +1,25 @@ +import { useQuery } from '@tanstack/react-query'; + +import { APIError, errorCauses, fetchAPI } from '@/api'; + +import { UserToken } from '../types'; + +export const listUserTokens = async (): Promise => { + const response = await fetchAPI(`user-tokens/`); + + if (!response.ok) { + throw new APIError( + 'Failed to list user tokens', + await errorCauses(response), + ); + } + + return response.json() as Promise; +}; + +export function useListUserTokens() { + return useQuery({ + queryKey: ['userTokens'], + queryFn: listUserTokens, + }); +} diff --git a/src/frontend/apps/impress/src/features/user-tokens/components/UserTokenManager.tsx b/src/frontend/apps/impress/src/features/user-tokens/components/UserTokenManager.tsx new file mode 100644 index 000000000..070ac978f --- /dev/null +++ b/src/frontend/apps/impress/src/features/user-tokens/components/UserTokenManager.tsx @@ -0,0 +1,314 @@ +import { + Button as CunninghamButton, + DataGrid, + Modal, + ModalSize, +} from '@openfun/cunningham-react'; +import React, { useCallback, useEffect, useState } from 'react'; + +import { Box, Card } from '@/components'; + +import { createUserToken, deleteUserToken, listUserTokens } from '../api/index'; +import { NewUserToken, UserToken } from '../types'; + +const formatTimeAgo = (dateString: string) => { + const now = new Date(); + const date = new Date(dateString); + const seconds = Math.floor((now.getTime() - date.getTime()) / 1000); + if (seconds < 60) { + return `${seconds} seconds ago`; + } + const minutes = Math.floor(seconds / 60); + if (minutes < 60) { + return `${minutes} minutes ago`; + } + const hours = Math.floor(minutes / 60); + if (hours < 24) { + return `${hours} hours ago`; + } + const days = Math.floor(hours / 24); + return `${days} days ago`; +}; + +// Add id to UserToken type for DataGrid compatibility +interface UserTokenWithId extends UserToken { + id: string; +} + +// Define proper type for DataGrid columns +interface ColumnDef { + field: string; + headerName: string; + width?: number; + renderCell: (params: { row: UserTokenWithId }) => React.ReactNode; +} + +export const UserTokenManager: React.FC = () => { + const [tokens, setTokens] = useState([]); + const [isLoading, setIsLoading] = useState(false); + const [error, setError] = useState(null); + const [newToken, setNewToken] = useState(null); + const [modalState, setModalState] = useState<{ + isOpen: boolean; + title: string; + message: React.ReactNode; + onConfirm?: () => void; + confirmText?: string; + isConfirmation: boolean; + type?: 'success' | 'error' | 'warning' | 'info'; + size: ModalSize; + }>({ + isOpen: false, + title: '', + message: '', + isConfirmation: false, + size: ModalSize.MEDIUM, // Default size using ModalSize enum + }); + + const showNotification = ( + message: string, + type: 'success' | 'error' = 'success', + size: ModalSize = ModalSize.SMALL, + ) => { + setModalState({ + isOpen: true, + title: type === 'success' ? 'Success' : 'Error', + message, + isConfirmation: false, + type: type, + size, + }); + }; + + const showConfirmation = ( + title: string, + message: React.ReactNode, + onConfirm: () => void, + confirmText: string = 'Confirm', + size: ModalSize = ModalSize.MEDIUM, + ) => { + setModalState({ + isOpen: true, + title, + message, + onConfirm, + confirmText, + isConfirmation: true, + size, + }); + }; + + const fetchTokens = useCallback(async () => { + setIsLoading(true); + setError(null); + try { + const fetchedTokens = await listUserTokens(); + // Add id to each token + setTokens(fetchedTokens.map((token) => ({ ...token, id: token.digest }))); + } catch (err) { + setError( + 'Failed to fetch tokens. Please ensure you are logged in and have permissions.', + ); + showNotification('Failed to fetch tokens', 'error'); + console.error(err); + } + setIsLoading(false); + }, []); + + useEffect(() => { + void fetchTokens(); + }, [fetchTokens]); + + const handleCreateToken = async () => { + setIsLoading(true); + setError(null); + setNewToken(null); + try { + const generatedToken = await createUserToken(); + setNewToken(generatedToken); + showNotification( + 'Token created successfully! Store the token key safely, it will not be shown again.', + 'success', + ModalSize.LARGE, + ); + void fetchTokens(); + } catch (err) { + setError('Failed to create token.'); + showNotification('Failed to create token', 'error'); + console.error(err); + } + setIsLoading(false); + }; + + const handleDeleteToken = (digest: string) => { + showConfirmation( + 'Confirm Deletion', + 'Are you sure you want to delete this token?', + () => { + void (async () => { + setIsLoading(true); + setError(null); + try { + await deleteUserToken(digest); + showNotification('Token deleted successfully!'); + setNewToken(null); + await fetchTokens(); + } catch (err) { + setError('Failed to delete token.'); + showNotification('Failed to delete token', 'error'); + console.error(err); + } + setIsLoading(false); + })(); + }, + ); + }; + + const columns: ColumnDef[] = [ + { + field: 'digest', + headerName: 'Name', + renderCell: ({ row }: { row: UserTokenWithId }) => <>{row.digest}, + }, + { + field: 'created', + headerName: 'Updated at', + renderCell: ({ row }: { row: UserTokenWithId }) => ( + <>{formatTimeAgo(row.created)} + ), + }, + { + field: 'expires', + headerName: 'Expires at', + renderCell: ({ row }: { row: UserTokenWithId }) => <>{row.expiry}, + }, + { + field: 'actions', + headerName: '', + width: 50, + renderCell: ({ row }: { row: UserTokenWithId }) => ( + { + handleDeleteToken(row.digest); + }} + color="danger" + size="small" + icon={delete} + aria-label="Delete token" + > + Delete + + ), + }, + ]; + + return ( + + +

+ User token management +

+ void handleCreateToken()} + disabled={isLoading} + > + {isLoading ? 'Generating...' : 'Generate New Token'} + +
+ + {newToken && ( + + + New Token: {newToken.token_key} + + + Digest: {newToken.digest} + + + Expires:{' '} + {new Date(newToken.expiry).toLocaleString()} + + + )} + + {isLoading && !tokens.length && ( + + Loading... + + )} + {error && ( + + {error} + + )} + + + rows={tokens} + columns={columns} + isLoading={isLoading} + emptyCta={
No tokens found.
} + /> + {modalState.isOpen && ( + setModalState((prev) => ({ ...prev, isOpen: false }))} + title={modalState.title} + size={modalState.size} // Use ModalSize enum directly + actions={ + modalState.isConfirmation ? ( + + + setModalState((prev) => ({ ...prev, isOpen: false })) + } + color="secondary" + > + Cancel + + { + if (modalState.onConfirm) { + modalState.onConfirm(); + } + setModalState((prev) => ({ ...prev, isOpen: false })); + }} + color="danger" + > + {modalState.confirmText || 'Confirm'} + + + ) : ( + + setModalState((prev) => ({ ...prev, isOpen: false })) + } + color="primary" + > + Close + + ) + } + > + {modalState.message} + + )} +
+ ); +}; diff --git a/src/frontend/apps/impress/src/features/user-tokens/index.ts b/src/frontend/apps/impress/src/features/user-tokens/index.ts new file mode 100644 index 000000000..047138638 --- /dev/null +++ b/src/frontend/apps/impress/src/features/user-tokens/index.ts @@ -0,0 +1 @@ +export { UserTokenManager } from './components/UserTokenManager'; diff --git a/src/frontend/apps/impress/src/features/user-tokens/types.ts b/src/frontend/apps/impress/src/features/user-tokens/types.ts new file mode 100644 index 000000000..ce15e091b --- /dev/null +++ b/src/frontend/apps/impress/src/features/user-tokens/types.ts @@ -0,0 +1,9 @@ +export interface UserToken { + digest: string; + created: string; // Assuming ISO date string + expiry: string; // Assuming ISO date string +} + +export interface NewUserToken extends UserToken { + token_key: string; +} diff --git a/src/frontend/apps/impress/src/pages/user-tokens/index.tsx b/src/frontend/apps/impress/src/pages/user-tokens/index.tsx new file mode 100644 index 000000000..a958705d0 --- /dev/null +++ b/src/frontend/apps/impress/src/pages/user-tokens/index.tsx @@ -0,0 +1,13 @@ +import { UserTokenManager } from '@/features/user-tokens'; +import { MainLayout } from '@/layouts'; +import { NextPageWithLayout } from '@/types/next'; + +const UserTokensPage: NextPageWithLayout = () => { + return ; +}; + +UserTokensPage.getLayout = function getLayout(page: React.ReactElement) { + return {page}; +}; + +export default UserTokensPage; diff --git a/src/helm/env.d/dev/values.impress.yaml.gotmpl b/src/helm/env.d/dev/values.impress.yaml.gotmpl index 57e92cd56..002101e8b 100644 --- a/src/helm/env.d/dev/values.impress.yaml.gotmpl +++ b/src/helm/env.d/dev/values.impress.yaml.gotmpl @@ -29,19 +29,25 @@ backend: DJANGO_EMAIL_PORT: 1025 DJANGO_EMAIL_USE_SSL: False LOGGING_LEVEL_HANDLERS_CONSOLE: ERROR - LOGGING_LEVEL_LOGGERS_ROOT: INFO - LOGGING_LEVEL_LOGGERS_APP: INFO + LOGGING_LEVEL_LOGGERS_ROOT: DEBUG + LOGGING_LEVEL_LOGGERS_APP: DEBUG OIDC_USERINFO_SHORTNAME_FIELD: "given_name" OIDC_USERINFO_FULLNAME_FIELDS: "given_name,usual_name" + OIDC_OP_URL: https://keycloak.127.0.0.1.nip.io/realms/people OIDC_OP_JWKS_ENDPOINT: https://docs-keycloak.127.0.0.1.nip.io/realms/impress/protocol/openid-connect/certs OIDC_OP_AUTHORIZATION_ENDPOINT: https://docs-keycloak.127.0.0.1.nip.io/realms/impress/protocol/openid-connect/auth OIDC_OP_TOKEN_ENDPOINT: https://docs-keycloak.127.0.0.1.nip.io/realms/impress/protocol/openid-connect/token OIDC_OP_USER_ENDPOINT: https://docs-keycloak.127.0.0.1.nip.io/realms/impress/protocol/openid-connect/userinfo OIDC_OP_LOGOUT_ENDPOINT: https://docs-keycloak.127.0.0.1.nip.io/realms/impress/protocol/openid-connect/logout + OIDC_OP_INTROSPECTION_ENDPOINT: https://docs-keycloak.127.0.0.1.nip.io/realms/impress/protocol/openid-connect/token/introspect OIDC_RP_CLIENT_ID: impress OIDC_RP_CLIENT_SECRET: ThisIsAnExampleKeyForDevPurposeOnly OIDC_RP_SIGN_ALGO: RS256 OIDC_RP_SCOPES: "openid email" + OIDC_RS_CLIENT_ID: impress + OIDC_RS_CLIENT_SECRET: ThisIsAnExampleKeyForDevPurposeOnly + OIDC_RS_SIGNING_ALGO: RS256 + OIDC_RS_SCOPES: "openid,profile,email" LOGIN_REDIRECT_URL: https://impress.127.0.0.1.nip.io LOGIN_REDIRECT_URL_FAILURE: https://impress.127.0.0.1.nip.io LOGOUT_REDIRECT_URL: https://impress.127.0.0.1.nip.io @@ -171,3 +177,20 @@ ingressMedia: serviceMedia: host: minio.impress.svc.cluster.local port: 9000 + + +mcpServer: + replicas: 1 + + image: + repository: localhost:5001/impress-mcp-server + pullPolicy: Always + tag: "latest" + + envVars: + DOCS_API_URL: https://impress.127.0.0.1.nip.io/ + SERVER_TRANSPORT: STREAMABLE_HTTP + +ingressMcpServer: + enabled: true + host: impress.127.0.0.1.nip.io diff --git a/src/helm/impress/templates/_helpers.tpl b/src/helm/impress/templates/_helpers.tpl index 63a9f4b3e..5f66ded0f 100644 --- a/src/helm/impress/templates/_helpers.tpl +++ b/src/helm/impress/templates/_helpers.tpl @@ -178,6 +178,15 @@ Requires top level scope {{ include "impress.fullname" . }}-celery-worker {{- end }} +{{/* +Full name for the MCP server + +Requires top level scope +*/}} +{{- define "impress.mcpServer.fullname" -}} +{{ include "impress.fullname" . }}-mcp-server +{{- end }} + {{/* Usage : {{ include "impress.secret.dockerconfigjson.name" (dict "fullname" (include "impress.fullname" .) "imageCredentials" .Values.path.to.the.image1) }} */}} diff --git a/src/helm/impress/templates/ingress_mcp_server.yaml b/src/helm/impress/templates/ingress_mcp_server.yaml new file mode 100644 index 000000000..52edb6186 --- /dev/null +++ b/src/helm/impress/templates/ingress_mcp_server.yaml @@ -0,0 +1,89 @@ +{{- if .Values.ingressMcpServer.enabled -}} +{{- $fullName := include "impress.fullname" . -}} +{{- if and .Values.ingressMcpServer.className (not (semverCompare ">=1.18-0" .Capabilities.KubeVersion.GitVersion)) }} + {{- if not (hasKey .Values.ingressMcpServer.annotations "kubernetes.io/ingress.class") }} + {{- $_ := set .Values.ingressMcpServer.annotations "kubernetes.io/ingress.class" .Values.ingressMcpServer.className}} + {{- end }} +{{- end }} +{{- if semverCompare ">=1.19-0" .Capabilities.KubeVersion.GitVersion -}} +apiVersion: networking.k8s.io/v1 +{{- else if semverCompare ">=1.14-0" .Capabilities.KubeVersion.GitVersion -}} +apiVersion: networking.k8s.io/v1beta1 +{{- else -}} +apiVersion: extensions/v1beta1 +{{- end }} +kind: Ingress +metadata: + name: {{ $fullName }}-mcp-server + namespace: {{ .Release.Namespace | quote }} + labels: + {{- include "impress.labels" . | nindent 4 }} + {{- with .Values.ingressMcpServer.annotations }} + annotations: + {{- toYaml . | nindent 4 }} + {{- end }} +spec: + {{- if and .Values.ingressMcpServer.className (semverCompare ">=1.18-0" .Capabilities.KubeVersion.GitVersion) }} + ingressClassName: {{ .Values.ingressMcpServer.className }} + {{- end }} + {{- if .Values.ingressMcpServer.tls.enabled }} + tls: + {{- if .Values.ingressMcpServer.host }} + - secretName: {{ .Values.ingressMcpServer.tls.secretName | default (printf "%s-tls" $fullName) | quote }} + hosts: + - {{ .Values.ingressMcpServer.host | quote }} + {{- end }} + {{- range .Values.ingressMcpServer.tls.additional }} + - hosts: + {{- range .hosts }} + - {{ . | quote }} + {{- end }} + secretName: {{ .secretName }} + {{- end }} + {{- end }} + rules: + {{- if .Values.ingressMcpServer.host }} + - host: {{ .Values.ingressMcpServer.host | quote }} + http: + paths: + - path: {{ .Values.ingressMcpServer.path | quote }} + {{- if semverCompare ">=1.18-0" $.Capabilities.KubeVersion.GitVersion }} + pathType: Prefix + {{- end }} + backend: + {{- if semverCompare ">=1.19-0" $.Capabilities.KubeVersion.GitVersion }} + service: + name: {{ include "impress.mcpServer.fullname" . }} + port: + number: {{ .Values.mcpServer.service.port }} + {{- else }} + serviceName: {{ include "impress.mcpServer.fullname" . }} + servicePort: {{ .Values.mcpServer.service.port }} + {{- end }} + {{- with .Values.ingressMcpServer.customBackends }} + {{- toYaml . | nindent 10 }} + {{- end }} + {{- end }} + {{- range .Values.ingressMcpServer.hosts }} + - host: {{ . | quote }} + http: + paths: + - path: {{ $.Values.ingressMcpServer.path | quote }} + {{- if semverCompare ">=1.18-0" $.Capabilities.KubeVersion.GitVersion }} + pathType: Prefix + {{- end }} + backend: + {{- if semverCompare ">=1.19-0" $.Capabilities.KubeVersion.GitVersion }} + service: + name: {{ include "impress.mcpServer.fullname" $ }} + port: + number: {{ $.Values.mcpServer.service.port }} + {{- else }} + serviceName: {{ include "impress.mcpServer.fullname" $ }} + servicePort: {{ $.Values.mcpServer.service.port }} + {{- end }} + {{- with $.Values.ingressMcpServer.customBackends }} + {{- toYaml . | nindent 10 }} + {{- end }} + {{- end }} +{{- end }} diff --git a/src/helm/impress/templates/mcp_server_deployment.yaml b/src/helm/impress/templates/mcp_server_deployment.yaml new file mode 100644 index 000000000..20a464912 --- /dev/null +++ b/src/helm/impress/templates/mcp_server_deployment.yaml @@ -0,0 +1,153 @@ +{{- $envVars := include "impress.common.env" (list . .Values.mcpServer) -}} +{{- $fullName := include "impress.mcpServer.fullname" . -}} +{{- $component := "mcp-server" -}} +apiVersion: apps/v1 +kind: Deployment +metadata: + name: {{ $fullName }} + namespace: {{ .Release.Namespace | quote }} + annotations: + {{- with .Values.mcpServer.dpAnnotations }} + {{- toYaml . | nindent 4 }} + {{- end }} + labels: + {{- include "impress.common.labels" (list . $component) | nindent 4 }} +spec: + replicas: {{ .Values.backend.replicas }} + selector: + matchLabels: + {{- include "impress.common.selectorLabels" (list . $component) | nindent 6 }} + template: + metadata: + annotations: + {{- with .Values.backend.podAnnotations }} + {{- toYaml . | nindent 8 }} + {{- end }} + labels: + {{- include "impress.common.selectorLabels" (list . $component) | nindent 8 }} + spec: + {{- if $.Values.image.credentials }} + imagePullSecrets: + - name: {{ include "impress.secret.dockerconfigjson.name" (dict "fullname" (include "impress.fullname" .) "imageCredentials" $.Values.image.credentials) }} + {{- end}} + shareProcessNamespace: {{ .Values.backend.shareProcessNamespace }} + containers: + {{- with .Values.mcpServer.sidecars }} + {{- toYaml . | nindent 8 }} + {{- end }} + - name: {{ .Chart.Name }} + image: "{{ (.Values.mcpServer.image | default dict).repository }}:{{ (.Values.mcpServer.image | default dict).tag }}" + imagePullPolicy: {{ (.Values.mcpServer.image | default dict).pullPolicy }} + {{- with .Values.mcpServer.command }} + command: + {{- toYaml . | nindent 12 }} + {{- end }} + {{- with .Values.mcpServer.args }} + args: + {{- toYaml . | nindent 12 }} + {{- end }} + env: + {{- if $envVars}} + {{- $envVars | indent 12 }} + {{- end }} + {{- with .Values.mcpServer.securityContext }} + securityContext: + {{- toYaml . | nindent 12 }} + {{- end }} + ports: + - name: http + containerPort: {{ .Values.mcpServer.service.targetPort }} + protocol: TCP + {{- if .Values.mcpServer.probes.liveness }} + livenessProbe: + {{- include "impress.probes.abstract" (merge .Values.mcpServer.probes.liveness (dict "targetPort" .Values.mcpServer.service.targetPort )) | nindent 12 }} + {{- end }} + {{- if .Values.mcpServer.probes.readiness }} + readinessProbe: + {{- include "impress.probes.abstract" (merge .Values.mcpServer.probes.readiness (dict "targetPort" .Values.mcpServer.service.targetPort )) | nindent 12 }} + {{- end }} + {{- if .Values.mcpServer.probes.startup }} + startupProbe: + {{- include "impress.probes.abstract" (merge .Values.mcpServer.probes.startup (dict "targetPort" .Values.mcpServer.service.targetPort )) | nindent 12 }} + {{- end }} + {{- with .Values.mcpServer.resources }} + resources: + {{- toYaml . | nindent 12 }} + {{- end }} + volumeMounts: + {{- range $index, $value := .Values.mountFiles }} + - name: "files-{{ $index }}" + mountPath: {{ $value.path }} + subPath: content + {{- end }} + {{- range $name, $volume := .Values.mcpServer.persistence }} + - name: "{{ $name }}" + mountPath: "{{ $volume.mountPath }}" + {{- end }} + {{- range .Values.mcpServer.extraVolumeMounts }} + - name: {{ .name }} + mountPath: {{ .mountPath }} + subPath: {{ .subPath | default "" }} + readOnly: {{ .readOnly }} + {{- end }} + {{- with .Values.mcpServer.nodeSelector }} + nodeSelector: + {{- toYaml . | nindent 8 }} + {{- end }} + {{- with .Values.mcpServer.affinity }} + affinity: + {{- toYaml . | nindent 8 }} + {{- end }} + {{- with .Values.mcpServer.tolerations }} + tolerations: + {{- toYaml . | nindent 8 }} + {{- end }} + volumes: + {{- range $index, $value := .Values.mountFiles }} + - name: "files-{{ $index }}" + configMap: + name: "{{ include "impress.fullname" $ }}-files-{{ $index }}" + {{- end }} + {{- range $name, $volume := .Values.mcpServer.persistence }} + - name: "{{ $name }}" + {{- if eq $volume.type "emptyDir" }} + emptyDir: {} + {{- else }} + persistentVolumeClaim: + claimName: "{{ $fullName }}-{{ $name }}" + {{- end }} + {{- end }} + {{- range .Values.mcpServer.extraVolumes }} + - name: {{ .name }} + {{- if .existingClaim }} + persistentVolumeClaim: + claimName: {{ .existingClaim }} + {{- else if .hostPath }} + hostPath: + {{ toYaml .hostPath | nindent 12 }} + {{- else if .csi }} + csi: + {{- toYaml .csi | nindent 12 }} + {{- else if .configMap }} + configMap: + {{- toYaml .configMap | nindent 12 }} + {{- else if .emptyDir }} + emptyDir: + {{- toYaml .emptyDir | nindent 12 }} + {{- else }} + emptyDir: {} + {{- end }} + {{- end }} +--- +{{ if .Values.mcpServer.pdb.enabled }} +apiVersion: policy/v1 +kind: PodDisruptionBudget +metadata: + name: {{ $fullName }} + namespace: {{ .Release.Namespace | quote }} +spec: + maxUnavailable: 1 + selector: + matchLabels: + {{- include "impress.common.selectorLabels" (list . $component) | nindent 6 }} +{{ end }} diff --git a/src/helm/impress/templates/mcp_server_svc.yaml b/src/helm/impress/templates/mcp_server_svc.yaml new file mode 100644 index 000000000..640cb3e2d --- /dev/null +++ b/src/helm/impress/templates/mcp_server_svc.yaml @@ -0,0 +1,21 @@ +{{- $envVars := include "impress.common.env" (list . .Values.mcpServer) -}} +{{- $fullName := include "impress.mcpServer.fullname" . -}} +{{- $component := "mcp-server" -}} +apiVersion: v1 +kind: Service +metadata: + name: {{ $fullName }} + namespace: {{ .Release.Namespace | quote }} + labels: + {{- include "impress.common.labels" (list . $component) | nindent 4 }} + annotations: + {{- toYaml $.Values.mcpServer.service.annotations | nindent 4 }} +spec: + type: {{ .Values.mcpServer.service.type }} + ports: + - port: {{ .Values.mcpServer.service.port }} + targetPort: {{ .Values.mcpServer.service.targetPort }} + protocol: TCP + name: http + selector: + {{- include "impress.common.selectorLabels" (list . $component) | nindent 4 }} diff --git a/src/helm/impress/values.yaml b/src/helm/impress/values.yaml index f743f02df..5b2361366 100644 --- a/src/helm/impress/values.yaml +++ b/src/helm/impress/values.yaml @@ -50,6 +50,31 @@ ingress: ## @param ingress.customBackends Add custom backends to ingress customBackends: [] +## @param ingressMcpServer.enabled whether to enable the Ingress or not +## @param ingressMcpServer.className IngressClass to use for the Ingress +## @param ingressMcpServer.host Host for the Ingress +## @param ingressMcpServer.path Path to use for the Ingress +ingressMcpServer: + enabled: false + className: null + host: impress.example.com + path: /mcp/docs/ + ## @param ingressMcpServer.hosts Additional host to configure for the Ingress + hosts: [] + # - chart-example.local + ## @param ingressMcpServer.tls.enabled Weather to enable TLS for the Ingress + ## @param ingressMcpServer.tls.secretName Secret name for TLS config + ## @skip ingressMcpServer.tls.additional + ## @extra ingressMcpServer.tls.additional[].secretName Secret name for additional TLS config + ## @extra ingressMcpServer.tls.additional[].hosts[] Hosts for additional TLS config + tls: + enabled: true + secretName: null + additional: [] + + ## @param ingressMcpServer.customBackends Add custom backends to ingress + customBackends: [] + ## @param ingressCollaborationWS.enabled whether to enable the Ingress or not ## @param ingressCollaborationWS.className IngressClass to use for the Ingress ## @param ingressCollaborationWS.host Host for the Ingress @@ -348,6 +373,93 @@ backend: timeoutSeconds: 5 +## @section mcpServer + +mcpServer: + ## @param mcpServer.image.repository Repository to use to pull impress's MCP server container image + ## @param mcpServer.image.tag impress's MCP server container tag + ## @param mcpServer.image.pullPolicy MCP server container image pull policy + image: + repository: lasuite/impress-mcp-server + pullPolicy: IfNotPresent + tag: "latest" + + ## @param mcpServer.command Override the MCP server container command + command: [] + + ## @param mcpServer.args Override the backend container args + args: [] + + ## @param mcpServer.envVars Configure MCP server container environment variables + envVars: [] + + ## @param mcpServer.replicas Amount of backend replicas + replicas: 3 + + ## @param mcpServer.podAnnotations Annotations to add to the MCP server Pod + podAnnotations: {} + + ## @param mcpServer.dpAnnotations Annotations to add to the MCP server Deployment + dpAnnotations: {} + + ## @param mcpServer.sidecars Add sidecars containers to MCP server deployment + sidecars: [] + + ## @param mcpServer.securityContext Configure MCP server Pod security context + securityContext: null + + ## @param mcpServer.service.type frontend Service type + ## @param mcpServer.service.port frontend Service listening port + ## @param mcpServer.service.targetPort frontend container listening port + ## @param mcpServer.service.annotations Annotations to add to the frontend Service + service: + type: ClusterIP + port: 80 + targetPort: 4200 + annotations: {} + + ## @param mcpServer.probes Configure probe for frontend + ## @extra mcpServer.probes.liveness.path Configure path for frontend HTTP liveness probe + ## @extra mcpServer.probes.liveness.targetPort Configure port for frontend HTTP liveness probe + ## @extra mcpServer.probes.liveness.initialDelaySeconds Configure initial delay for frontend liveness probe + ## @extra mcpServer.probes.liveness.initialDelaySeconds Configure timeout for frontend liveness probe + ## @extra mcpServer.probes.startup.path Configure path for frontend HTTP startup probe + ## @extra mcpServer.probes.startup.targetPort Configure port for frontend HTTP startup probe + ## @extra mcpServer.probes.startup.initialDelaySeconds Configure initial delay for frontend startup probe + ## @extra mcpServer.probes.startup.initialDelaySeconds Configure timeout for frontend startup probe + ## @extra mcpServer.probes.readiness.path Configure path for frontend HTTP readiness probe + ## @extra mcpServer.probes.readiness.targetPort Configure port for frontend HTTP readiness probe + ## @extra mcpServer.probes.readiness.initialDelaySeconds Configure initial delay for frontend readiness probe + ## @extra mcpServer.probes.readiness.initialDelaySeconds Configure timeout for frontend readiness probe + probes: {} + + ## @param mcpServer.resources Resource requirements for the frontend container + resources: {} + + ## @param mcpServer.persistence Additional volumes to create and mount on the backend. Used for debugging purposes + ## @extra mcpServer.persistence.volume-name.size Size of the additional volume + ## @extra mcpServer.persistence.volume-name.type Type of the additional volume, persistentVolumeClaim or emptyDir + ## @extra mcpServer.persistence.volume-name.mountPath Path where the volume should be mounted to + persistence: {} + + ## @param mcpServer.extraVolumeMounts Additional volumes to mount on the backend. + extraVolumeMounts: [ ] + + ## @param mcpServer.extraVolumes Additional volumes to mount on the backend. + extraVolumes: [] + + ## @param mcpServer.nodeSelector Node selector for the backend Pod + nodeSelector: {} + + ## @param mcpServer.affinity Affinity for the backend Pod + affinity: {} + + ## @param mcpServer.tolerations Tolerations for the backend Pod + tolerations: [] + + ## @param mcpServer.pdb.enabled Enable pdb on backend + pdb: + enabled: true ## @section frontend diff --git a/src/mcp_server/.dockerignore b/src/mcp_server/.dockerignore new file mode 100644 index 000000000..10da1cd2b --- /dev/null +++ b/src/mcp_server/.dockerignore @@ -0,0 +1,4 @@ +.venv +.env +docker-compose.yaml +Dockerfile diff --git a/src/mcp_server/.env.example b/src/mcp_server/.env.example new file mode 100644 index 000000000..6e4e3ba85 --- /dev/null +++ b/src/mcp_server/.env.example @@ -0,0 +1,10 @@ +SERVER_TRANSPORT=SSE + +# Example Docker: DOCS_API_URL=http://app-dev:8000/ or http://host.docker.internal:8071/ +# Example localhost: DOCS_API_URL=http://localhost:8071/ +# Example Hackathon: DOCS_API_URL=https://docs-ia.beta.numerique.gouv.fr/ +DOCS_API_URL= + +# Token generated from the frontend application +# see http://localhost:3000/user-tokens/ +DOCS_API_TOKEN= \ No newline at end of file diff --git a/src/mcp_server/.python-version b/src/mcp_server/.python-version new file mode 100644 index 000000000..e4fba2183 --- /dev/null +++ b/src/mcp_server/.python-version @@ -0,0 +1 @@ +3.12 diff --git a/src/mcp_server/Dockerfile b/src/mcp_server/Dockerfile new file mode 100644 index 000000000..d2ed72a18 --- /dev/null +++ b/src/mcp_server/Dockerfile @@ -0,0 +1,35 @@ +FROM python:3.12-slim + +USER root + +# Install uv. +COPY --from=ghcr.io/astral-sh/uv:latest /uv /uvx /bin/ + +# Change the working directory to the `app` directory +WORKDIR /app + +# Install dependencies +RUN --mount=type=cache,target=/root/.cache/uv \ + --mount=type=bind,source=uv.lock,target=uv.lock \ + --mount=type=bind,source=pyproject.toml,target=pyproject.toml \ + uv sync --locked --no-install-project + +# Copy the application into the image +COPY docs_mcp_server/ /app/docs_mcp_server/ + +# Sync the project +RUN --mount=type=cache,target=/root/.cache/uv \ + --mount=type=bind,source=uv.lock,target=uv.lock \ + --mount=type=bind,source=pyproject.toml,target=pyproject.toml \ + uv sync --locked + +# Attach ports +EXPOSE 4200 +ENV SERVER_HOST=0.0.0.0 + +# Un-privileged user running the application +ARG DOCKER_USER +USER ${DOCKER_USER} + +# Run the MCP server. +CMD ["uv", "--no-cache", "run", "python", "-m" ,"docs_mcp_server.mcp_server"] diff --git a/src/mcp_server/Makefile b/src/mcp_server/Makefile new file mode 100644 index 000000000..eb18fade8 --- /dev/null +++ b/src/mcp_server/Makefile @@ -0,0 +1,84 @@ +# /!\ /!\ /!\ /!\ /!\ /!\ /!\ DISCLAIMER /!\ /!\ /!\ /!\ /!\ /!\ /!\ /!\ +# +# This Makefile is only meant to be used for DEVELOPMENT purpose as we are +# changing the user id that will run in the container. +# +# PLEASE DO NOT USE IT FOR YOUR CI/PRODUCTION/WHATEVER... +# +# /!\ /!\ /!\ /!\ /!\ /!\ /!\ /!\ /!\ /!\ /!\ /!\ /!\ /!\ /!\ /!\ /!\ /!\ +# +# Note to developers: +# +# While editing this file, please respect the following statements: +# +# 1. Every variable should be defined in the ad hoc VARIABLES section with a +# relevant subsection +# 2. Every new rule should be defined in the ad hoc RULES section with a +# relevant subsection depending on the targeted service +# 3. Rules should be sorted alphabetically within their section +# 4. When a rule has multiple dependencies, you should: +# - duplicate the rule name to add the help string (if required) +# - write one dependency per line to increase readability and diffs +# 5. .PHONY rule statement should be written after the corresponding rule +# ============================================================================== +# VARIABLES + + + +BOLD := \033[1m +RESET := \033[0m +GREEN := \033[1;32m + +# Use uv for package management +UV = uv + +# ============================================================================== +# RULES + +default: help + +help: ## Display this help message + @echo "$(BOLD)Docs MCP server Makefile" + @echo "Please use 'make $(BOLD)target$(RESET)' where $(BOLD)target$(RESET) is one of:" + @grep -E '^[a-zA-Z0-9_-]+:.*?## .*$$' $(firstword $(MAKEFILE_LIST)) | sort | awk 'BEGIN {FS = ":.*?## "}; {printf "$(GREEN)%-30s$(RESET) %s\n", $$1, $$2}' +.PHONY: help + +install: ## Install the project + @$(UV) sync +.PHONY: install + +install-dev: ## Install the project with dev dependencies + @$(UV) sync --extra dev +.PHONY: install-dev + +clean: ## Clean the project folder + @rm -rf build/ + @rm -rf dist/ + @rm -rf *.egg-info + @find . -type d -name __pycache__ -exec rm -rf {} + + @find . -type f -name "*.pyc" -delete +.PHONY: clean + +format: ## Run the formatter + @$(UV) run ruff format +.PHONY: format + +lint: format ## Run the linter + @$(UV) run ruff check --fix . +.PHONY: lint + +test: ## Run the tests + @cd tests && PYTHON_PATH=.:$(PYTHON_PATH) $(UV) run python -m pytest . -vvv +.PHONY: test + +runserver: ## Run the project server + @$(UV) run python -m docs_mcp_server.mcp_server +.PHONY: runserver + +runserver-docker: ## Run the project server in a docker container + @touch .env + @docker compose up --watch +.PHONY: runserver-docker + +run_llm: ## Run the LLM server + @mcphost -m ollama:qwen2.5:3b --config "$(CWD)/mcphost.json" diff --git a/src/mcp_server/README.md b/src/mcp_server/README.md new file mode 100644 index 000000000..d41ab1df0 --- /dev/null +++ b/src/mcp_server/README.md @@ -0,0 +1,154 @@ +# mcp_server + +`mcp_server` is a backend server application designed to manage and process MCP requests. + +## Features + +- Create a new Docs with a title and markdown content from a request to an agentic LLM. + + +## Configuration + +Configuration options can be set via environment variables or a configuration file (`.env`). + +Common options include: + +- `SERVER_TRANSPORT`: `STDIO`, `SSE` or `STREAMABLE_HTTP` (default: `STDIO` locally or `STREAMABLE_HTTP` in the docker image) +- `SERVER_PATH`: The base path of the server tools and resources (default: `/mcp/docs/`) + +You will need to set the following options to allow the MCP server to query Docs API: + +- `DOCS_API_URL`: The Docs base URL without the "/api/v1.0/" (default: `http://localhost:8071`) +- `DOCS_API_TOKEN`: The API user token you generate from the Docs frontend if you want + to use token based authentication (default: `None`). If not provided, the server will + use the authentication forwarder (pass the incoming authentication header to the Docs API call). + +You may customize the following options for local development, while it's not recommended and may break the Docker image: + +- `SERVER_HOST`: (default: `localhost` locally and `0.0.0.0` in the docker Image) +- `SERVER_PORT`: (default: `4200`) + +Example when using the server from a Docker instance + +```dotenv +SERVER_TRANSPORT=SSE + +DOCS_API_URL=http://host.docker.internal:8071/ +DOCS_API_TOKEN= +``` + +## Run the MCP server + +### Local +You may work on the MCP server project using local configuration with `uv`: + +```shell +cd src/mcp_server + +make install +make runserver +``` + +### Docker +If you don't have local installation of Python or `uv` you can work using the Docker image: + +```shell +cd src/mcp_server + +make runserver-docker +``` + +## Usage + +1. Create a local configuration file `.env` + + ```dotenv + SERVER_TRANSPORT=SSE + + DOCS_API_URL=http://host.docker.internal:8071/ + DOCS_API_TOKEN=your-token-here + ``` + +2. Run the server + + ```shell + make runserver-docker + ``` + +### In Cursor IDE + +In Cursor settings, in the MCP section, you can add a new MCP server with the following configuration: + +```json +{ + "mcpServers": { + "docs": { + "url": "http://127.0.0.1:4200/mcp/docs/" + } + } +} +``` + +### In VSCode IDE + +In VSCode settings, you can add a new MCP server with the following configuration: + +```json +// .vscode/settings.json +{ + "chat.mcp.discovery.enabled": true, + "chat.mcp.enabled": true +} +``` + +```json +// .vscode/mcp.json +{ + "servers": { + "docs": { + "url": "http://localhost:4200/mcp/docs" + } + } +} +``` + +### Locally with `mcphost` and `ollama` + +1. Install [mcphost](https://github.com/mark3labs/mcphost) +2. Install [ollama](https://ollama.ai) +3. Start ollama: `ollama serve` +4. Pull an agentic model like Qwen2.5 `ollama pull qwen2.5:3b` +5. Create an MCP configuration file (e.g. `mcphost.json`) + + ```json + { + "mcpServers": { + "docs": { + "url": "http://127.0.0.1:4200/mcp/docs/" + } + } + } + ``` + +6. Start mcphost + + ```shell + mcphost -m ollama:qwen2.5:3b --config "$PWD/mcphost.json" + ``` + + +## About the authentication forwarder + +The authentication forwarder is a simple proxy that forwards the authentication header from +the incoming request to the Docs API call. This allows to use "resource server" authentication. + +For instance: + +- Docs authentication is based on OIDC with Keycloak. +- The AI chat is using the same Keycloak instance for authentication. +- You can store the access token in the chat session and use it when calling the MCP server. +- The MCP server will forward the access token to the Docs API call + (actually, it forwards the whole authentication header). +- Docs will introspect the access token and authenticate the user. +- Conclusion: the user will be able to create a new Doc with the same access token + used in the chat session. diff --git a/src/mcp_server/docker-compose.yaml b/src/mcp_server/docker-compose.yaml new file mode 100644 index 000000000..e3a70d476 --- /dev/null +++ b/src/mcp_server/docker-compose.yaml @@ -0,0 +1,28 @@ +services: + docs_mcp-server: + build: + context: . + args: + DOCKER_USER: ${DOCKER_USER:-1000} + user: ${DOCKER_USER:-1000} + extra_hosts: + - "host.docker.internal:host-gateway" + env_file: + - .env + ports: + - "4200:4200" + + develop: + # Create a `watch` configuration to update the app + watch: + # Sync the working directory with the `/app` directory in the container + - action: sync+restart + path: ./docs_mcp_server + target: /app/docs_mcp_server/ + # Exclude the project virtual environment + ignore: + - .venv/ + + # Rebuild the image on changes to the `pyproject.toml` + - action: rebuild + path: ./pyproject.toml diff --git a/src/mcp_server/docs_mcp_server/__init__.py b/src/mcp_server/docs_mcp_server/__init__.py new file mode 100644 index 000000000..ae58a2ad3 --- /dev/null +++ b/src/mcp_server/docs_mcp_server/__init__.py @@ -0,0 +1,3 @@ +"""MCP Server package.""" + +__version__ = "0.1.0" diff --git a/src/mcp_server/docs_mcp_server/auth/__init__.py b/src/mcp_server/docs_mcp_server/auth/__init__.py new file mode 100644 index 000000000..039ae8bd4 --- /dev/null +++ b/src/mcp_server/docs_mcp_server/auth/__init__.py @@ -0,0 +1 @@ +"""Authentication module for the MCP server.""" diff --git a/src/mcp_server/docs_mcp_server/auth/forwarder.py b/src/mcp_server/docs_mcp_server/auth/forwarder.py new file mode 100644 index 000000000..d1fdd1de8 --- /dev/null +++ b/src/mcp_server/docs_mcp_server/auth/forwarder.py @@ -0,0 +1,18 @@ +"""Authentication against Docs API via user token.""" + +import httpx +from fastmcp.server.dependencies import get_http_request + + +class HeaderForwarderAuthentication(httpx.Auth): + """Authentication class for request made to Docs, work as boilerplate.""" + + def auth_flow(self, request): + """Get Authorization header from request and pass it to the client.""" + _incoming_request = get_http_request() + + # Get authorization header + auth_header = _incoming_request.headers.get("authorization", "") + + request.headers["Authorization"] = auth_header + yield request diff --git a/src/mcp_server/docs_mcp_server/auth/token.py b/src/mcp_server/docs_mcp_server/auth/token.py new file mode 100644 index 000000000..c80b4d617 --- /dev/null +++ b/src/mcp_server/docs_mcp_server/auth/token.py @@ -0,0 +1,16 @@ +"""Authentication against Docs API via user token.""" + +import httpx + + +class UserTokenAuthentication(httpx.Auth): + """Authentication class for request made to Docs, using the user token.""" + + def __init__(self, token): + """Initialize the authentication class with the user token.""" + self.token = token + + def auth_flow(self, request): + """Add the Authorization header to the request with the user token.""" + request.headers["Authorization"] = f"Token {self.token}" + yield request diff --git a/src/mcp_server/docs_mcp_server/constants.py b/src/mcp_server/docs_mcp_server/constants.py new file mode 100644 index 000000000..a97ca42e7 --- /dev/null +++ b/src/mcp_server/docs_mcp_server/constants.py @@ -0,0 +1,11 @@ +"""Project constants.""" + +import enum + + +class TransportLayerEnum(enum.Enum): + """Enum for the MCP server transport layer types.""" + + STDIO = "stdio" + SSE = "sse" + STREAMABLE_HTTP = "streamable-http" diff --git a/src/mcp_server/docs_mcp_server/mcp_server.py b/src/mcp_server/docs_mcp_server/mcp_server.py new file mode 100644 index 000000000..0259bcd7d --- /dev/null +++ b/src/mcp_server/docs_mcp_server/mcp_server.py @@ -0,0 +1,109 @@ +"""The core of the MCP server for the Docs API.""" + +import asyncio +import logging +import logging.config + +import httpx +from fastmcp import FastMCP + +from . import settings, utils +from .auth.forwarder import HeaderForwarderAuthentication +from .auth.token import UserTokenAuthentication + +logging_config = { + "version": 1, + "disable_existing_loggers": False, + "formatters": { + "default": { + "format": "[%(asctime)s] %(levelname)s - %(name)s - %(message)s", + "datefmt": "%Y-%m-%d %H:%M:%S", + }, + }, + "handlers": { + "default": { + "formatter": "default", + "class": "logging.StreamHandler", + "stream": "ext://sys.stdout", + }, + }, + "root": {"handlers": ["default"], "level": "DEBUG"}, + "loggers": { + "uvicorn": {"handlers": ["default"], "level": "INFO", "propagate": False}, + "uvicorn.error": {"handlers": ["default"], "level": "INFO", "propagate": False}, + "uvicorn.access": {"handlers": ["default"], "level": "INFO", "propagate": False}, + "FastMCP": {"handlers": ["default"], "level": "INFO", "propagate": False}, + }, +} +logging.config.dictConfig(logging_config) +logger = logging.getLogger("docs_mcp_server") + + +class ToolsProvider: + """Provides tools for the MCP server to interact with the Docs API.""" + + def __init__(self, mcp_instance): + """Register all the available tools here.""" + mcp_instance.add_tool(self.create_document_tool) + + @property + def api_client(self): + """Create and return an HTTP client for the Docs API.""" + if settings.DOCS_API_TOKEN: + auth_backend = UserTokenAuthentication(token=settings.DOCS_API_TOKEN) + else: + auth_backend = HeaderForwarderAuthentication() + + return httpx.AsyncClient( + base_url=settings.DOCS_API_URL, + auth=auth_backend, + ) + + async def create_document_tool(self, document_title: str, document_content: str) -> None: + """ + Create a new document with the provided title and content. + + Args: + document_title: The title of the document (required) + document_content: The content of the document (required) + + """ + _api_client = self.api_client + + # Get current user information + user_response = await _api_client.get("/api/v1.0/users/me/") + user_response.raise_for_status() + user_data = user_response.json() + + # Prepare document data + data = { + "title": document_title, + "content": document_content, + "sub": user_data["id"], + "email": user_data["email"], + } + + # Create the document + create_response = await _api_client.post( + "/api/v1.0/documents/create-for-owner/", + json=data, + ) + create_response.raise_for_status() + + await _api_client.aclose() + + +# Create a server instance from the OpenAPI spec +mcp_server = FastMCP(name="Docs MCP Server") +ToolsProvider(mcp_server) + + +if __name__ == "__main__": + asyncio.run(utils.check_mcp(mcp_server)) + logger.info("Starting Docs MCP Server...") + mcp_server.run( + transport=settings.SERVER_TRANSPORT.value, + host=settings.SERVER_HOST, + port=settings.SERVER_PORT, + path=settings.SERVER_PATH, + ) diff --git a/src/mcp_server/docs_mcp_server/settings.py b/src/mcp_server/docs_mcp_server/settings.py new file mode 100644 index 000000000..212d2dba1 --- /dev/null +++ b/src/mcp_server/docs_mcp_server/settings.py @@ -0,0 +1,21 @@ +"""Settings for the MCP server.""" + +import os + +from dotenv import load_dotenv + +from .constants import TransportLayerEnum + +load_dotenv() + + +# Server settings +SERVER_TRANSPORT = TransportLayerEnum[os.getenv("SERVER_TRANSPORT", "STDIO")] +SERVER_HOST = str(os.getenv("SERVER_HOST", "localhost")) +SERVER_PORT = int(os.getenv("SERVER_PORT", "4200")) +SERVER_PATH = str(os.getenv("SERVER_PATH", "/mcp/docs")) + + +# Docs related settings +DOCS_API_URL = str(os.getenv("DOCS_API_URL", "http://localhost:8071")) +DOCS_API_TOKEN = str(os.getenv("DOCS_API_TOKEN", "")) or None diff --git a/src/mcp_server/docs_mcp_server/utils.py b/src/mcp_server/docs_mcp_server/utils.py new file mode 100644 index 000000000..d994e79e9 --- /dev/null +++ b/src/mcp_server/docs_mcp_server/utils.py @@ -0,0 +1,18 @@ +"""Helpers for the project, not named `tools` to avoid confusion.""" + +import logging + +from fastmcp import FastMCP + +logger = logging.getLogger("docs_mcp_server") + + +async def check_mcp(mcp: FastMCP): + """List the MCP server components (tools, resources and templates).""" + tools = await mcp.get_tools() + resources = await mcp.get_resources() + templates = await mcp.get_resource_templates() + + logger.info("%d Tool(s): %s", len(tools), ", ".join([t.name for t in tools.values()])) + logger.info("%d Resource(s): %s", len(resources), ", ".join([r.name for r in resources.values()])) + logger.info("%d Resource Template(s): %s", len(templates), ", ".join([t.name for t in templates.values()])) diff --git a/src/mcp_server/pyproject.toml b/src/mcp_server/pyproject.toml new file mode 100644 index 000000000..2cd2621c4 --- /dev/null +++ b/src/mcp_server/pyproject.toml @@ -0,0 +1,98 @@ +[project] +name = "mcp-server" +dynamic = ["version"] +description = "MCP server implementation for Docs" +readme = "README.md" +requires-python = ">=3.12" +license = {file = "LICENSE"} +authors = [ + {name = "DINUM", email = "dev@mail.numerique.gouv.fr"}, +] +classifiers = [ + "Development Status :: 3 - Alpha", + "Intended Audience :: Developers", + "License :: OSI Approved :: MIT License", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.12", +] + +dependencies = [ + "dotenv>=0.9.9", + "fastmcp>=2.3.4", + "httpx>=0.28.1", +] + +[project.optional-dependencies] +dev = [ + "ruff", +] + +[tool.setuptools.dynamic] +version = {attr = "docs_mcp_server.__version__"} + +[tool.ruff] +line-length = 120 +target-version = "py310" +lint.select = [ + # pycodestyle + "E", "W", + # Pyflakes + "F", + # pyupgrade + "UP", + # flake8-bugbear + "B", + # flake8-simplify + "SIM", + # isort + "I", + # flake8-logging-format + "G", + # flake8-pie + "PIE", + # flake8-comprehensions + "C4", + # flake8-django + "DJ", + # flake8-bandit + "S", + # flake8-builtins + "A", + # flake8-datetimez + "DTZ", + # flake8-gettext + "INT", + # Pylint + "PL", + # flake8-fixme + "FIX", + # flake8-self + "SLF", + # flake8-return + "RET", + # pep8-naming (N) + "N", + # pydocstyle + "D", + # flake8-pytest-style (PT) + "PT", +] +lint.ignore = [ + # incorrect-blank-line-before-class + "D203", + # missing-blank-line-after-summary + "D205", + # multi-line-summary-first-line + "D212", +] +lint.per-file-ignores = {"**/tests/*"= [ + # flake8-bandit + "S", + # flake8-self + "SLF", + # magic-value-comparison + "PLR2004", +]} + +[tool.ruff.lint.isort] +known-first-party = ["mcp_server"] diff --git a/src/mcp_server/uv.lock b/src/mcp_server/uv.lock new file mode 100644 index 000000000..2c9b5fb69 --- /dev/null +++ b/src/mcp_server/uv.lock @@ -0,0 +1,488 @@ +version = 1 +revision = 1 +requires-python = ">=3.12" + +[[package]] +name = "annotated-types" +version = "0.7.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ee/67/531ea369ba64dcff5ec9c3402f9f51bf748cec26dde048a2f973a4eea7f5/annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89", size = 16081 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/78/b6/6307fbef88d9b5ee7421e68d78a9f162e0da4900bc5f5793f6d3d0e34fb8/annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53", size = 13643 }, +] + +[[package]] +name = "anyio" +version = "4.9.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "idna" }, + { name = "sniffio" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/95/7d/4c1bd541d4dffa1b52bd83fb8527089e097a106fc90b467a7313b105f840/anyio-4.9.0.tar.gz", hash = "sha256:673c0c244e15788651a4ff38710fea9675823028a6f08a5eda409e0c9840a028", size = 190949 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a1/ee/48ca1a7c89ffec8b6a0c5d02b89c305671d5ffd8d3c94acf8b8c408575bb/anyio-4.9.0-py3-none-any.whl", hash = "sha256:9f76d541cad6e36af7beb62e978876f3b41e3e04f2c1fbf0884604c0a9c4d93c", size = 100916 }, +] + +[[package]] +name = "certifi" +version = "2025.4.26" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e8/9e/c05b3920a3b7d20d3d3310465f50348e5b3694f4f88c6daf736eef3024c4/certifi-2025.4.26.tar.gz", hash = "sha256:0a816057ea3cdefcef70270d2c515e4506bbc954f417fa5ade2021213bb8f0c6", size = 160705 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/4a/7e/3db2bd1b1f9e95f7cddca6d6e75e2f2bd9f51b1246e546d88addca0106bd/certifi-2025.4.26-py3-none-any.whl", hash = "sha256:30350364dfe371162649852c63336a15c70c6510c2ad5015b21c2345311805f3", size = 159618 }, +] + +[[package]] +name = "click" +version = "8.1.8" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b9/2e/0090cbf739cee7d23781ad4b89a9894a41538e4fcf4c31dcdd705b78eb8b/click-8.1.8.tar.gz", hash = "sha256:ed53c9d8990d83c2a27deae68e4ee337473f6330c040a31d4225c9574d16096a", size = 226593 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7e/d4/7ebdbd03970677812aac39c869717059dbb71a4cfc033ca6e5221787892c/click-8.1.8-py3-none-any.whl", hash = "sha256:63c132bbbed01578a06712a2d1f497bb62d9c1c0d329b7903a866228027263b2", size = 98188 }, +] + +[[package]] +name = "colorama" +version = "0.4.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335 }, +] + +[[package]] +name = "dotenv" +version = "0.9.9" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "python-dotenv" }, +] +wheels = [ + { url = "https://files.pythonhosted.org/packages/b2/b7/545d2c10c1fc15e48653c91efde329a790f2eecfbbf2bd16003b5db2bab0/dotenv-0.9.9-py2.py3-none-any.whl", hash = "sha256:29cf74a087b31dafdb5a446b6d7e11cbce8ed2741540e2339c69fbef92c94ce9", size = 1892 }, +] + +[[package]] +name = "exceptiongroup" +version = "1.3.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/0b/9f/a65090624ecf468cdca03533906e7c69ed7588582240cfe7cc9e770b50eb/exceptiongroup-1.3.0.tar.gz", hash = "sha256:b241f5885f560bc56a59ee63ca4c6a8bfa46ae4ad651af316d4e81817bb9fd88", size = 29749 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/36/f4/c6e662dade71f56cd2f3735141b265c3c79293c109549c1e6933b0651ffc/exceptiongroup-1.3.0-py3-none-any.whl", hash = "sha256:4d111e6e0c13d0644cad6ddaa7ed0261a0b36971f6d23e7ec9b4b9097da78a10", size = 16674 }, +] + +[[package]] +name = "fastmcp" +version = "2.4.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "exceptiongroup" }, + { name = "httpx" }, + { name = "mcp" }, + { name = "openapi-pydantic" }, + { name = "python-dotenv" }, + { name = "rich" }, + { name = "typer" }, + { name = "websockets" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/50/11/2ccd6219eb65692a298e764fa84a15fd756e03c811c7ea217129d6ca545f/fastmcp-2.4.0.tar.gz", hash = "sha256:a08d812939d16c0d4490bdbdaf17ab136f1bdaa8ddcc14a37e33335727343c05", size = 1020290 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ab/a4/26396706c1b48ebd051da4f7d321594207364077a5c308827722536b927c/fastmcp-2.4.0-py3-none-any.whl", hash = "sha256:fccd0768028a31eec488707fb6bbe4f8659f84ca0c206c4c32dd33947c0faae9", size = 101108 }, +] + +[[package]] +name = "h11" +version = "0.16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/01/ee/02a2c011bdab74c6fb3c75474d40b3052059d95df7e73351460c8588d963/h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1", size = 101250 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515 }, +] + +[[package]] +name = "httpcore" +version = "1.0.9" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "h11" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/06/94/82699a10bca87a5556c9c59b5963f2d039dbd239f25bc2a63907a05a14cb/httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8", size = 85484 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784 }, +] + +[[package]] +name = "httpx" +version = "0.28.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "certifi" }, + { name = "httpcore" }, + { name = "idna" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b1/df/48c586a5fe32a0f01324ee087459e112ebb7224f646c0b5023f5e79e9956/httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc", size = 141406 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517 }, +] + +[[package]] +name = "httpx-sse" +version = "0.4.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/4c/60/8f4281fa9bbf3c8034fd54c0e7412e66edbab6bc74c4996bd616f8d0406e/httpx-sse-0.4.0.tar.gz", hash = "sha256:1e81a3a3070ce322add1d3529ed42eb5f70817f45ed6ec915ab753f961139721", size = 12624 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e1/9b/a181f281f65d776426002f330c31849b86b31fc9d848db62e16f03ff739f/httpx_sse-0.4.0-py3-none-any.whl", hash = "sha256:f329af6eae57eaa2bdfd962b42524764af68075ea87370a2de920af5341e318f", size = 7819 }, +] + +[[package]] +name = "idna" +version = "3.10" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f1/70/7703c29685631f5a7590aa73f1f1d3fa9a380e654b86af429e0934a32f7d/idna-3.10.tar.gz", hash = "sha256:12f65c9b470abda6dc35cf8e63cc574b1c52b11df2c86030af0ac09b01b13ea9", size = 190490 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/76/c6/c88e154df9c4e1a2a66ccf0005a88dfb2650c1dffb6f5ce603dfbd452ce3/idna-3.10-py3-none-any.whl", hash = "sha256:946d195a0d259cbba61165e88e65941f16e9b36ea6ddb97f00452bae8b1287d3", size = 70442 }, +] + +[[package]] +name = "markdown-it-py" +version = "3.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "mdurl" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/38/71/3b932df36c1a044d397a1f92d1cf91ee0a503d91e470cbd670aa66b07ed0/markdown-it-py-3.0.0.tar.gz", hash = "sha256:e3f60a94fa066dc52ec76661e37c851cb232d92f9886b15cb560aaada2df8feb", size = 74596 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/42/d7/1ec15b46af6af88f19b8e5ffea08fa375d433c998b8a7639e76935c14f1f/markdown_it_py-3.0.0-py3-none-any.whl", hash = "sha256:355216845c60bd96232cd8d8c40e8f9765cc86f46880e43a8fd22dc1a1a8cab1", size = 87528 }, +] + +[[package]] +name = "mcp" +version = "1.9.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "httpx" }, + { name = "httpx-sse" }, + { name = "pydantic" }, + { name = "pydantic-settings" }, + { name = "python-multipart" }, + { name = "sse-starlette" }, + { name = "starlette" }, + { name = "uvicorn", marker = "sys_platform != 'emscripten'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/bc/8d/0f4468582e9e97b0a24604b585c651dfd2144300ecffd1c06a680f5c8861/mcp-1.9.0.tar.gz", hash = "sha256:905d8d208baf7e3e71d70c82803b89112e321581bcd2530f9de0fe4103d28749", size = 281432 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a5/d5/22e36c95c83c80eb47c83f231095419cf57cf5cca5416f1c960032074c78/mcp-1.9.0-py3-none-any.whl", hash = "sha256:9dfb89c8c56f742da10a5910a1f64b0d2ac2c3ed2bd572ddb1cfab7f35957178", size = 125082 }, +] + +[[package]] +name = "mcp-server" +source = { virtual = "." } +dependencies = [ + { name = "dotenv" }, + { name = "fastmcp" }, + { name = "httpx" }, +] + +[package.optional-dependencies] +dev = [ + { name = "ruff" }, +] + +[package.metadata] +requires-dist = [ + { name = "dotenv", specifier = ">=0.9.9" }, + { name = "fastmcp", specifier = ">=2.3.4" }, + { name = "httpx", specifier = ">=0.28.1" }, + { name = "ruff", marker = "extra == 'dev'" }, +] +provides-extras = ["dev"] + +[[package]] +name = "mdurl" +version = "0.1.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d6/54/cfe61301667036ec958cb99bd3efefba235e65cdeb9c84d24a8293ba1d90/mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba", size = 8729 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", size = 9979 }, +] + +[[package]] +name = "openapi-pydantic" +version = "0.5.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pydantic" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/02/2e/58d83848dd1a79cb92ed8e63f6ba901ca282c5f09d04af9423ec26c56fd7/openapi_pydantic-0.5.1.tar.gz", hash = "sha256:ff6835af6bde7a459fb93eb93bb92b8749b754fc6e51b2f1590a19dc3005ee0d", size = 60892 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/12/cf/03675d8bd8ecbf4445504d8071adab19f5f993676795708e36402ab38263/openapi_pydantic-0.5.1-py3-none-any.whl", hash = "sha256:a3a09ef4586f5bd760a8df7f43028b60cafb6d9f61de2acba9574766255ab146", size = 96381 }, +] + +[[package]] +name = "pydantic" +version = "2.11.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "annotated-types" }, + { name = "pydantic-core" }, + { name = "typing-extensions" }, + { name = "typing-inspection" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/77/ab/5250d56ad03884ab5efd07f734203943c8a8ab40d551e208af81d0257bf2/pydantic-2.11.4.tar.gz", hash = "sha256:32738d19d63a226a52eed76645a98ee07c1f410ee41d93b4afbfa85ed8111c2d", size = 786540 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e7/12/46b65f3534d099349e38ef6ec98b1a5a81f42536d17e0ba382c28c67ba67/pydantic-2.11.4-py3-none-any.whl", hash = "sha256:d9615eaa9ac5a063471da949c8fc16376a84afb5024688b3ff885693506764eb", size = 443900 }, +] + +[[package]] +name = "pydantic-core" +version = "2.33.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ad/88/5f2260bdfae97aabf98f1778d43f69574390ad787afb646292a638c923d4/pydantic_core-2.33.2.tar.gz", hash = "sha256:7cb8bc3605c29176e1b105350d2e6474142d7c1bd1d9327c4a9bdb46bf827acc", size = 435195 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/18/8a/2b41c97f554ec8c71f2a8a5f85cb56a8b0956addfe8b0efb5b3d77e8bdc3/pydantic_core-2.33.2-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:a7ec89dc587667f22b6a0b6579c249fca9026ce7c333fc142ba42411fa243cdc", size = 2009000 }, + { url = "https://files.pythonhosted.org/packages/a1/02/6224312aacb3c8ecbaa959897af57181fb6cf3a3d7917fd44d0f2917e6f2/pydantic_core-2.33.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:3c6db6e52c6d70aa0d00d45cdb9b40f0433b96380071ea80b09277dba021ddf7", size = 1847996 }, + { url = "https://files.pythonhosted.org/packages/d6/46/6dcdf084a523dbe0a0be59d054734b86a981726f221f4562aed313dbcb49/pydantic_core-2.33.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4e61206137cbc65e6d5256e1166f88331d3b6238e082d9f74613b9b765fb9025", size = 1880957 }, + { url = "https://files.pythonhosted.org/packages/ec/6b/1ec2c03837ac00886ba8160ce041ce4e325b41d06a034adbef11339ae422/pydantic_core-2.33.2-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:eb8c529b2819c37140eb51b914153063d27ed88e3bdc31b71198a198e921e011", size = 1964199 }, + { url = "https://files.pythonhosted.org/packages/2d/1d/6bf34d6adb9debd9136bd197ca72642203ce9aaaa85cfcbfcf20f9696e83/pydantic_core-2.33.2-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:c52b02ad8b4e2cf14ca7b3d918f3eb0ee91e63b3167c32591e57c4317e134f8f", size = 2120296 }, + { url = "https://files.pythonhosted.org/packages/e0/94/2bd0aaf5a591e974b32a9f7123f16637776c304471a0ab33cf263cf5591a/pydantic_core-2.33.2-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:96081f1605125ba0855dfda83f6f3df5ec90c61195421ba72223de35ccfb2f88", size = 2676109 }, + { url = "https://files.pythonhosted.org/packages/f9/41/4b043778cf9c4285d59742281a769eac371b9e47e35f98ad321349cc5d61/pydantic_core-2.33.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8f57a69461af2a5fa6e6bbd7a5f60d3b7e6cebb687f55106933188e79ad155c1", size = 2002028 }, + { url = "https://files.pythonhosted.org/packages/cb/d5/7bb781bf2748ce3d03af04d5c969fa1308880e1dca35a9bd94e1a96a922e/pydantic_core-2.33.2-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:572c7e6c8bb4774d2ac88929e3d1f12bc45714ae5ee6d9a788a9fb35e60bb04b", size = 2100044 }, + { url = "https://files.pythonhosted.org/packages/fe/36/def5e53e1eb0ad896785702a5bbfd25eed546cdcf4087ad285021a90ed53/pydantic_core-2.33.2-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:db4b41f9bd95fbe5acd76d89920336ba96f03e149097365afe1cb092fceb89a1", size = 2058881 }, + { url = "https://files.pythonhosted.org/packages/01/6c/57f8d70b2ee57fc3dc8b9610315949837fa8c11d86927b9bb044f8705419/pydantic_core-2.33.2-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:fa854f5cf7e33842a892e5c73f45327760bc7bc516339fda888c75ae60edaeb6", size = 2227034 }, + { url = "https://files.pythonhosted.org/packages/27/b9/9c17f0396a82b3d5cbea4c24d742083422639e7bb1d5bf600e12cb176a13/pydantic_core-2.33.2-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:5f483cfb75ff703095c59e365360cb73e00185e01aaea067cd19acffd2ab20ea", size = 2234187 }, + { url = "https://files.pythonhosted.org/packages/b0/6a/adf5734ffd52bf86d865093ad70b2ce543415e0e356f6cacabbc0d9ad910/pydantic_core-2.33.2-cp312-cp312-win32.whl", hash = "sha256:9cb1da0f5a471435a7bc7e439b8a728e8b61e59784b2af70d7c169f8dd8ae290", size = 1892628 }, + { url = "https://files.pythonhosted.org/packages/43/e4/5479fecb3606c1368d496a825d8411e126133c41224c1e7238be58b87d7e/pydantic_core-2.33.2-cp312-cp312-win_amd64.whl", hash = "sha256:f941635f2a3d96b2973e867144fde513665c87f13fe0e193c158ac51bfaaa7b2", size = 1955866 }, + { url = "https://files.pythonhosted.org/packages/0d/24/8b11e8b3e2be9dd82df4b11408a67c61bb4dc4f8e11b5b0fc888b38118b5/pydantic_core-2.33.2-cp312-cp312-win_arm64.whl", hash = "sha256:cca3868ddfaccfbc4bfb1d608e2ccaaebe0ae628e1416aeb9c4d88c001bb45ab", size = 1888894 }, + { url = "https://files.pythonhosted.org/packages/46/8c/99040727b41f56616573a28771b1bfa08a3d3fe74d3d513f01251f79f172/pydantic_core-2.33.2-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:1082dd3e2d7109ad8b7da48e1d4710c8d06c253cbc4a27c1cff4fbcaa97a9e3f", size = 2015688 }, + { url = "https://files.pythonhosted.org/packages/3a/cc/5999d1eb705a6cefc31f0b4a90e9f7fc400539b1a1030529700cc1b51838/pydantic_core-2.33.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f517ca031dfc037a9c07e748cefd8d96235088b83b4f4ba8939105d20fa1dcd6", size = 1844808 }, + { url = "https://files.pythonhosted.org/packages/6f/5e/a0a7b8885c98889a18b6e376f344da1ef323d270b44edf8174d6bce4d622/pydantic_core-2.33.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0a9f2c9dd19656823cb8250b0724ee9c60a82f3cdf68a080979d13092a3b0fef", size = 1885580 }, + { url = "https://files.pythonhosted.org/packages/3b/2a/953581f343c7d11a304581156618c3f592435523dd9d79865903272c256a/pydantic_core-2.33.2-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:2b0a451c263b01acebe51895bfb0e1cc842a5c666efe06cdf13846c7418caa9a", size = 1973859 }, + { url = "https://files.pythonhosted.org/packages/e6/55/f1a813904771c03a3f97f676c62cca0c0a4138654107c1b61f19c644868b/pydantic_core-2.33.2-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1ea40a64d23faa25e62a70ad163571c0b342b8bf66d5fa612ac0dec4f069d916", size = 2120810 }, + { url = "https://files.pythonhosted.org/packages/aa/c3/053389835a996e18853ba107a63caae0b9deb4a276c6b472931ea9ae6e48/pydantic_core-2.33.2-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:0fb2d542b4d66f9470e8065c5469ec676978d625a8b7a363f07d9a501a9cb36a", size = 2676498 }, + { url = "https://files.pythonhosted.org/packages/eb/3c/f4abd740877a35abade05e437245b192f9d0ffb48bbbbd708df33d3cda37/pydantic_core-2.33.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9fdac5d6ffa1b5a83bca06ffe7583f5576555e6c8b3a91fbd25ea7780f825f7d", size = 2000611 }, + { url = "https://files.pythonhosted.org/packages/59/a7/63ef2fed1837d1121a894d0ce88439fe3e3b3e48c7543b2a4479eb99c2bd/pydantic_core-2.33.2-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:04a1a413977ab517154eebb2d326da71638271477d6ad87a769102f7c2488c56", size = 2107924 }, + { url = "https://files.pythonhosted.org/packages/04/8f/2551964ef045669801675f1cfc3b0d74147f4901c3ffa42be2ddb1f0efc4/pydantic_core-2.33.2-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:c8e7af2f4e0194c22b5b37205bfb293d166a7344a5b0d0eaccebc376546d77d5", size = 2063196 }, + { url = "https://files.pythonhosted.org/packages/26/bd/d9602777e77fc6dbb0c7db9ad356e9a985825547dce5ad1d30ee04903918/pydantic_core-2.33.2-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:5c92edd15cd58b3c2d34873597a1e20f13094f59cf88068adb18947df5455b4e", size = 2236389 }, + { url = "https://files.pythonhosted.org/packages/42/db/0e950daa7e2230423ab342ae918a794964b053bec24ba8af013fc7c94846/pydantic_core-2.33.2-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:65132b7b4a1c0beded5e057324b7e16e10910c106d43675d9bd87d4f38dde162", size = 2239223 }, + { url = "https://files.pythonhosted.org/packages/58/4d/4f937099c545a8a17eb52cb67fe0447fd9a373b348ccfa9a87f141eeb00f/pydantic_core-2.33.2-cp313-cp313-win32.whl", hash = "sha256:52fb90784e0a242bb96ec53f42196a17278855b0f31ac7c3cc6f5c1ec4811849", size = 1900473 }, + { url = "https://files.pythonhosted.org/packages/a0/75/4a0a9bac998d78d889def5e4ef2b065acba8cae8c93696906c3a91f310ca/pydantic_core-2.33.2-cp313-cp313-win_amd64.whl", hash = "sha256:c083a3bdd5a93dfe480f1125926afcdbf2917ae714bdb80b36d34318b2bec5d9", size = 1955269 }, + { url = "https://files.pythonhosted.org/packages/f9/86/1beda0576969592f1497b4ce8e7bc8cbdf614c352426271b1b10d5f0aa64/pydantic_core-2.33.2-cp313-cp313-win_arm64.whl", hash = "sha256:e80b087132752f6b3d714f041ccf74403799d3b23a72722ea2e6ba2e892555b9", size = 1893921 }, + { url = "https://files.pythonhosted.org/packages/a4/7d/e09391c2eebeab681df2b74bfe6c43422fffede8dc74187b2b0bf6fd7571/pydantic_core-2.33.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:61c18fba8e5e9db3ab908620af374db0ac1baa69f0f32df4f61ae23f15e586ac", size = 1806162 }, + { url = "https://files.pythonhosted.org/packages/f1/3d/847b6b1fed9f8ed3bb95a9ad04fbd0b212e832d4f0f50ff4d9ee5a9f15cf/pydantic_core-2.33.2-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:95237e53bb015f67b63c91af7518a62a8660376a6a0db19b89acc77a4d6199f5", size = 1981560 }, + { url = "https://files.pythonhosted.org/packages/6f/9a/e73262f6c6656262b5fdd723ad90f518f579b7bc8622e43a942eec53c938/pydantic_core-2.33.2-cp313-cp313t-win_amd64.whl", hash = "sha256:c2fc0a768ef76c15ab9238afa6da7f69895bb5d1ee83aeea2e3509af4472d0b9", size = 1935777 }, +] + +[[package]] +name = "pydantic-settings" +version = "2.9.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pydantic" }, + { name = "python-dotenv" }, + { name = "typing-inspection" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/67/1d/42628a2c33e93f8e9acbde0d5d735fa0850f3e6a2f8cb1eb6c40b9a732ac/pydantic_settings-2.9.1.tar.gz", hash = "sha256:c509bf79d27563add44e8446233359004ed85066cd096d8b510f715e6ef5d268", size = 163234 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b6/5f/d6d641b490fd3ec2c4c13b4244d68deea3a1b970a97be64f34fb5504ff72/pydantic_settings-2.9.1-py3-none-any.whl", hash = "sha256:59b4f431b1defb26fe620c71a7d3968a710d719f5f4cdbbdb7926edeb770f6ef", size = 44356 }, +] + +[[package]] +name = "pygments" +version = "2.19.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7c/2d/c3338d48ea6cc0feb8446d8e6937e1408088a72a39937982cc6111d17f84/pygments-2.19.1.tar.gz", hash = "sha256:61c16d2a8576dc0649d9f39e089b5f02bcd27fba10d8fb4dcc28173f7a45151f", size = 4968581 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8a/0b/9fcc47d19c48b59121088dd6da2488a49d5f72dacf8262e2790a1d2c7d15/pygments-2.19.1-py3-none-any.whl", hash = "sha256:9ea1544ad55cecf4b8242fab6dd35a93bbce657034b0611ee383099054ab6d8c", size = 1225293 }, +] + +[[package]] +name = "python-dotenv" +version = "1.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/88/2c/7bb1416c5620485aa793f2de31d3df393d3686aa8a8506d11e10e13c5baf/python_dotenv-1.1.0.tar.gz", hash = "sha256:41f90bc6f5f177fb41f53e87666db362025010eb28f60a01c9143bfa33a2b2d5", size = 39920 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1e/18/98a99ad95133c6a6e2005fe89faedf294a748bd5dc803008059409ac9b1e/python_dotenv-1.1.0-py3-none-any.whl", hash = "sha256:d7c01d9e2293916c18baf562d95698754b0dbbb5e74d457c45d4f6561fb9d55d", size = 20256 }, +] + +[[package]] +name = "python-multipart" +version = "0.0.20" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f3/87/f44d7c9f274c7ee665a29b885ec97089ec5dc034c7f3fafa03da9e39a09e/python_multipart-0.0.20.tar.gz", hash = "sha256:8dd0cab45b8e23064ae09147625994d090fa46f5b0d1e13af944c331a7fa9d13", size = 37158 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/45/58/38b5afbc1a800eeea951b9285d3912613f2603bdf897a4ab0f4bd7f405fc/python_multipart-0.0.20-py3-none-any.whl", hash = "sha256:8a62d3a8335e06589fe01f2a3e178cdcc632f3fbe0d492ad9ee0ec35aab1f104", size = 24546 }, +] + +[[package]] +name = "rich" +version = "14.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markdown-it-py" }, + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a1/53/830aa4c3066a8ab0ae9a9955976fb770fe9c6102117c8ec4ab3ea62d89e8/rich-14.0.0.tar.gz", hash = "sha256:82f1bc23a6a21ebca4ae0c45af9bdbc492ed20231dcb63f297d6d1021a9d5725", size = 224078 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0d/9b/63f4c7ebc259242c89b3acafdb37b41d1185c07ff0011164674e9076b491/rich-14.0.0-py3-none-any.whl", hash = "sha256:1c9491e1951aac09caffd42f448ee3d04e58923ffe14993f6e83068dc395d7e0", size = 243229 }, +] + +[[package]] +name = "ruff" +version = "0.11.10" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e8/4c/4a3c5a97faaae6b428b336dcca81d03ad04779f8072c267ad2bd860126bf/ruff-0.11.10.tar.gz", hash = "sha256:d522fb204b4959909ecac47da02830daec102eeb100fb50ea9554818d47a5fa6", size = 4165632 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2f/9f/596c628f8824a2ce4cd12b0f0b4c0629a62dfffc5d0f742c19a1d71be108/ruff-0.11.10-py3-none-linux_armv6l.whl", hash = "sha256:859a7bfa7bc8888abbea31ef8a2b411714e6a80f0d173c2a82f9041ed6b50f58", size = 10316243 }, + { url = "https://files.pythonhosted.org/packages/3c/38/c1e0b77ab58b426f8c332c1d1d3432d9fc9a9ea622806e208220cb133c9e/ruff-0.11.10-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:968220a57e09ea5e4fd48ed1c646419961a0570727c7e069842edd018ee8afed", size = 11083636 }, + { url = "https://files.pythonhosted.org/packages/23/41/b75e15961d6047d7fe1b13886e56e8413be8467a4e1be0a07f3b303cd65a/ruff-0.11.10-py3-none-macosx_11_0_arm64.whl", hash = "sha256:1067245bad978e7aa7b22f67113ecc6eb241dca0d9b696144256c3a879663bca", size = 10441624 }, + { url = "https://files.pythonhosted.org/packages/b6/2c/e396b6703f131406db1811ea3d746f29d91b41bbd43ad572fea30da1435d/ruff-0.11.10-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f4854fd09c7aed5b1590e996a81aeff0c9ff51378b084eb5a0b9cd9518e6cff2", size = 10624358 }, + { url = "https://files.pythonhosted.org/packages/bd/8c/ee6cca8bdaf0f9a3704796022851a33cd37d1340bceaf4f6e991eb164e2e/ruff-0.11.10-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:8b4564e9f99168c0f9195a0fd5fa5928004b33b377137f978055e40008a082c5", size = 10176850 }, + { url = "https://files.pythonhosted.org/packages/e9/ce/4e27e131a434321b3b7c66512c3ee7505b446eb1c8a80777c023f7e876e6/ruff-0.11.10-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:5b6a9cc5b62c03cc1fea0044ed8576379dbaf751d5503d718c973d5418483641", size = 11759787 }, + { url = "https://files.pythonhosted.org/packages/58/de/1e2e77fc72adc7cf5b5123fd04a59ed329651d3eab9825674a9e640b100b/ruff-0.11.10-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:607ecbb6f03e44c9e0a93aedacb17b4eb4f3563d00e8b474298a201622677947", size = 12430479 }, + { url = "https://files.pythonhosted.org/packages/07/ed/af0f2340f33b70d50121628ef175523cc4c37619e98d98748c85764c8d88/ruff-0.11.10-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:7b3a522fa389402cd2137df9ddefe848f727250535c70dafa840badffb56b7a4", size = 11919760 }, + { url = "https://files.pythonhosted.org/packages/24/09/d7b3d3226d535cb89234390f418d10e00a157b6c4a06dfbe723e9322cb7d/ruff-0.11.10-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2f071b0deed7e9245d5820dac235cbdd4ef99d7b12ff04c330a241ad3534319f", size = 14041747 }, + { url = "https://files.pythonhosted.org/packages/62/b3/a63b4e91850e3f47f78795e6630ee9266cb6963de8f0191600289c2bb8f4/ruff-0.11.10-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4a60e3a0a617eafba1f2e4186d827759d65348fa53708ca547e384db28406a0b", size = 11550657 }, + { url = "https://files.pythonhosted.org/packages/46/63/a4f95c241d79402ccdbdb1d823d156c89fbb36ebfc4289dce092e6c0aa8f/ruff-0.11.10-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:da8ec977eaa4b7bf75470fb575bea2cb41a0e07c7ea9d5a0a97d13dbca697bf2", size = 10489671 }, + { url = "https://files.pythonhosted.org/packages/6a/9b/c2238bfebf1e473495659c523d50b1685258b6345d5ab0b418ca3f010cd7/ruff-0.11.10-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:ddf8967e08227d1bd95cc0851ef80d2ad9c7c0c5aab1eba31db49cf0a7b99523", size = 10160135 }, + { url = "https://files.pythonhosted.org/packages/ba/ef/ba7251dd15206688dbfba7d413c0312e94df3b31b08f5d695580b755a899/ruff-0.11.10-py3-none-musllinux_1_2_i686.whl", hash = "sha256:5a94acf798a82db188f6f36575d80609072b032105d114b0f98661e1679c9125", size = 11170179 }, + { url = "https://files.pythonhosted.org/packages/73/9f/5c336717293203ba275dbfa2ea16e49b29a9fd9a0ea8b6febfc17e133577/ruff-0.11.10-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:3afead355f1d16d95630df28d4ba17fb2cb9c8dfac8d21ced14984121f639bad", size = 11626021 }, + { url = "https://files.pythonhosted.org/packages/d9/2b/162fa86d2639076667c9aa59196c020dc6d7023ac8f342416c2f5ec4bda0/ruff-0.11.10-py3-none-win32.whl", hash = "sha256:dc061a98d32a97211af7e7f3fa1d4ca2fcf919fb96c28f39551f35fc55bdbc19", size = 10494958 }, + { url = "https://files.pythonhosted.org/packages/24/f3/66643d8f32f50a4b0d09a4832b7d919145ee2b944d43e604fbd7c144d175/ruff-0.11.10-py3-none-win_amd64.whl", hash = "sha256:5cc725fbb4d25b0f185cb42df07ab6b76c4489b4bfb740a175f3a59c70e8a224", size = 11650285 }, + { url = "https://files.pythonhosted.org/packages/95/3a/2e8704d19f376c799748ff9cb041225c1d59f3e7711bc5596c8cfdc24925/ruff-0.11.10-py3-none-win_arm64.whl", hash = "sha256:ef69637b35fb8b210743926778d0e45e1bffa850a7c61e428c6b971549b5f5d1", size = 10765278 }, +] + +[[package]] +name = "shellingham" +version = "1.5.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/58/15/8b3609fd3830ef7b27b655beb4b4e9c62313a4e8da8c676e142cc210d58e/shellingham-1.5.4.tar.gz", hash = "sha256:8dbca0739d487e5bd35ab3ca4b36e11c4078f3a234bfce294b0a0291363404de", size = 10310 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e0/f9/0595336914c5619e5f28a1fb793285925a8cd4b432c9da0a987836c7f822/shellingham-1.5.4-py2.py3-none-any.whl", hash = "sha256:7ecfff8f2fd72616f7481040475a65b2bf8af90a56c89140852d1120324e8686", size = 9755 }, +] + +[[package]] +name = "sniffio" +version = "1.3.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a2/87/a6771e1546d97e7e041b6ae58d80074f81b7d5121207425c964ddf5cfdbd/sniffio-1.3.1.tar.gz", hash = "sha256:f4324edc670a0f49750a81b895f35c3adb843cca46f0530f79fc1babb23789dc", size = 20372 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e9/44/75a9c9421471a6c4805dbf2356f7c181a29c1879239abab1ea2cc8f38b40/sniffio-1.3.1-py3-none-any.whl", hash = "sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2", size = 10235 }, +] + +[[package]] +name = "sse-starlette" +version = "2.3.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "starlette" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/10/5f/28f45b1ff14bee871bacafd0a97213f7ec70e389939a80c60c0fb72a9fc9/sse_starlette-2.3.5.tar.gz", hash = "sha256:228357b6e42dcc73a427990e2b4a03c023e2495ecee82e14f07ba15077e334b2", size = 17511 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c8/48/3e49cf0f64961656402c0023edbc51844fe17afe53ab50e958a6dbbbd499/sse_starlette-2.3.5-py3-none-any.whl", hash = "sha256:251708539a335570f10eaaa21d1848a10c42ee6dc3a9cf37ef42266cdb1c52a8", size = 10233 }, +] + +[[package]] +name = "starlette" +version = "0.46.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ce/20/08dfcd9c983f6a6f4a1000d934b9e6d626cff8d2eeb77a89a68eef20a2b7/starlette-0.46.2.tar.gz", hash = "sha256:7f7361f34eed179294600af672f565727419830b54b7b084efe44bb82d2fccd5", size = 2580846 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8b/0c/9d30a4ebeb6db2b25a841afbb80f6ef9a854fc3b41be131d249a977b4959/starlette-0.46.2-py3-none-any.whl", hash = "sha256:595633ce89f8ffa71a015caed34a5b2dc1c0cdb3f0f1fbd1e69339cf2abeec35", size = 72037 }, +] + +[[package]] +name = "typer" +version = "0.15.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "click" }, + { name = "rich" }, + { name = "shellingham" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/6c/89/c527e6c848739be8ceb5c44eb8208c52ea3515c6cf6406aa61932887bf58/typer-0.15.4.tar.gz", hash = "sha256:89507b104f9b6a0730354f27c39fae5b63ccd0c95b1ce1f1a6ba0cfd329997c3", size = 101559 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c9/62/d4ba7afe2096d5659ec3db8b15d8665bdcb92a3c6ff0b95e99895b335a9c/typer-0.15.4-py3-none-any.whl", hash = "sha256:eb0651654dcdea706780c466cf06d8f174405a659ffff8f163cfbfee98c0e173", size = 45258 }, +] + +[[package]] +name = "typing-extensions" +version = "4.13.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f6/37/23083fcd6e35492953e8d2aaaa68b860eb422b34627b13f2ce3eb6106061/typing_extensions-4.13.2.tar.gz", hash = "sha256:e6c81219bd689f51865d9e372991c540bda33a0379d5573cddb9a3a23f7caaef", size = 106967 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8b/54/b1ae86c0973cc6f0210b53d508ca3641fb6d0c56823f288d108bc7ab3cc8/typing_extensions-4.13.2-py3-none-any.whl", hash = "sha256:a439e7c04b49fec3e5d3e2beaa21755cadbbdc391694e28ccdd36ca4a1408f8c", size = 45806 }, +] + +[[package]] +name = "typing-inspection" +version = "0.4.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f8/b1/0c11f5058406b3af7609f121aaa6b609744687f1d158b3c3a5bf4cc94238/typing_inspection-0.4.1.tar.gz", hash = "sha256:6ae134cc0203c33377d43188d4064e9b357dba58cff3185f22924610e70a9d28", size = 75726 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/17/69/cd203477f944c353c31bade965f880aa1061fd6bf05ded0726ca845b6ff7/typing_inspection-0.4.1-py3-none-any.whl", hash = "sha256:389055682238f53b04f7badcb49b989835495a96700ced5dab2d8feae4b26f51", size = 14552 }, +] + +[[package]] +name = "uvicorn" +version = "0.34.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "click" }, + { name = "h11" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a6/ae/9bbb19b9e1c450cf9ecaef06463e40234d98d95bf572fab11b4f19ae5ded/uvicorn-0.34.2.tar.gz", hash = "sha256:0e929828f6186353a80b58ea719861d2629d766293b6d19baf086ba31d4f3328", size = 76815 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b1/4b/4cef6ce21a2aaca9d852a6e84ef4f135d99fcd74fa75105e2fc0c8308acd/uvicorn-0.34.2-py3-none-any.whl", hash = "sha256:deb49af569084536d269fe0a6d67e3754f104cf03aba7c11c40f01aadf33c403", size = 62483 }, +] + +[[package]] +name = "websockets" +version = "15.0.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/21/e6/26d09fab466b7ca9c7737474c52be4f76a40301b08362eb2dbc19dcc16c1/websockets-15.0.1.tar.gz", hash = "sha256:82544de02076bafba038ce055ee6412d68da13ab47f0c60cab827346de828dee", size = 177016 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/51/6b/4545a0d843594f5d0771e86463606a3988b5a09ca5123136f8a76580dd63/websockets-15.0.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:3e90baa811a5d73f3ca0bcbf32064d663ed81318ab225ee4f427ad4e26e5aff3", size = 175437 }, + { url = "https://files.pythonhosted.org/packages/f4/71/809a0f5f6a06522af902e0f2ea2757f71ead94610010cf570ab5c98e99ed/websockets-15.0.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:592f1a9fe869c778694f0aa806ba0374e97648ab57936f092fd9d87f8bc03665", size = 173096 }, + { url = "https://files.pythonhosted.org/packages/3d/69/1a681dd6f02180916f116894181eab8b2e25b31e484c5d0eae637ec01f7c/websockets-15.0.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:0701bc3cfcb9164d04a14b149fd74be7347a530ad3bbf15ab2c678a2cd3dd9a2", size = 173332 }, + { url = "https://files.pythonhosted.org/packages/a6/02/0073b3952f5bce97eafbb35757f8d0d54812b6174ed8dd952aa08429bcc3/websockets-15.0.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e8b56bdcdb4505c8078cb6c7157d9811a85790f2f2b3632c7d1462ab5783d215", size = 183152 }, + { url = "https://files.pythonhosted.org/packages/74/45/c205c8480eafd114b428284840da0b1be9ffd0e4f87338dc95dc6ff961a1/websockets-15.0.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0af68c55afbd5f07986df82831c7bff04846928ea8d1fd7f30052638788bc9b5", size = 182096 }, + { url = "https://files.pythonhosted.org/packages/14/8f/aa61f528fba38578ec553c145857a181384c72b98156f858ca5c8e82d9d3/websockets-15.0.1-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:64dee438fed052b52e4f98f76c5790513235efaa1ef7f3f2192c392cd7c91b65", size = 182523 }, + { url = "https://files.pythonhosted.org/packages/ec/6d/0267396610add5bc0d0d3e77f546d4cd287200804fe02323797de77dbce9/websockets-15.0.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:d5f6b181bb38171a8ad1d6aa58a67a6aa9d4b38d0f8c5f496b9e42561dfc62fe", size = 182790 }, + { url = "https://files.pythonhosted.org/packages/02/05/c68c5adbf679cf610ae2f74a9b871ae84564462955d991178f95a1ddb7dd/websockets-15.0.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:5d54b09eba2bada6011aea5375542a157637b91029687eb4fdb2dab11059c1b4", size = 182165 }, + { url = "https://files.pythonhosted.org/packages/29/93/bb672df7b2f5faac89761cb5fa34f5cec45a4026c383a4b5761c6cea5c16/websockets-15.0.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:3be571a8b5afed347da347bfcf27ba12b069d9d7f42cb8c7028b5e98bbb12597", size = 182160 }, + { url = "https://files.pythonhosted.org/packages/ff/83/de1f7709376dc3ca9b7eeb4b9a07b4526b14876b6d372a4dc62312bebee0/websockets-15.0.1-cp312-cp312-win32.whl", hash = "sha256:c338ffa0520bdb12fbc527265235639fb76e7bc7faafbb93f6ba80d9c06578a9", size = 176395 }, + { url = "https://files.pythonhosted.org/packages/7d/71/abf2ebc3bbfa40f391ce1428c7168fb20582d0ff57019b69ea20fa698043/websockets-15.0.1-cp312-cp312-win_amd64.whl", hash = "sha256:fcd5cf9e305d7b8338754470cf69cf81f420459dbae8a3b40cee57417f4614a7", size = 176841 }, + { url = "https://files.pythonhosted.org/packages/cb/9f/51f0cf64471a9d2b4d0fc6c534f323b664e7095640c34562f5182e5a7195/websockets-15.0.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:ee443ef070bb3b6ed74514f5efaa37a252af57c90eb33b956d35c8e9c10a1931", size = 175440 }, + { url = "https://files.pythonhosted.org/packages/8a/05/aa116ec9943c718905997412c5989f7ed671bc0188ee2ba89520e8765d7b/websockets-15.0.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:5a939de6b7b4e18ca683218320fc67ea886038265fd1ed30173f5ce3f8e85675", size = 173098 }, + { url = "https://files.pythonhosted.org/packages/ff/0b/33cef55ff24f2d92924923c99926dcce78e7bd922d649467f0eda8368923/websockets-15.0.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:746ee8dba912cd6fc889a8147168991d50ed70447bf18bcda7039f7d2e3d9151", size = 173329 }, + { url = "https://files.pythonhosted.org/packages/31/1d/063b25dcc01faa8fada1469bdf769de3768b7044eac9d41f734fd7b6ad6d/websockets-15.0.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:595b6c3969023ecf9041b2936ac3827e4623bfa3ccf007575f04c5a6aa318c22", size = 183111 }, + { url = "https://files.pythonhosted.org/packages/93/53/9a87ee494a51bf63e4ec9241c1ccc4f7c2f45fff85d5bde2ff74fcb68b9e/websockets-15.0.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:3c714d2fc58b5ca3e285461a4cc0c9a66bd0e24c5da9911e30158286c9b5be7f", size = 182054 }, + { url = "https://files.pythonhosted.org/packages/ff/b2/83a6ddf56cdcbad4e3d841fcc55d6ba7d19aeb89c50f24dd7e859ec0805f/websockets-15.0.1-cp313-cp313-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0f3c1e2ab208db911594ae5b4f79addeb3501604a165019dd221c0bdcabe4db8", size = 182496 }, + { url = "https://files.pythonhosted.org/packages/98/41/e7038944ed0abf34c45aa4635ba28136f06052e08fc2168520bb8b25149f/websockets-15.0.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:229cf1d3ca6c1804400b0a9790dc66528e08a6a1feec0d5040e8b9eb14422375", size = 182829 }, + { url = "https://files.pythonhosted.org/packages/e0/17/de15b6158680c7623c6ef0db361da965ab25d813ae54fcfeae2e5b9ef910/websockets-15.0.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:756c56e867a90fb00177d530dca4b097dd753cde348448a1012ed6c5131f8b7d", size = 182217 }, + { url = "https://files.pythonhosted.org/packages/33/2b/1f168cb6041853eef0362fb9554c3824367c5560cbdaad89ac40f8c2edfc/websockets-15.0.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:558d023b3df0bffe50a04e710bc87742de35060580a293c2a984299ed83bc4e4", size = 182195 }, + { url = "https://files.pythonhosted.org/packages/86/eb/20b6cdf273913d0ad05a6a14aed4b9a85591c18a987a3d47f20fa13dcc47/websockets-15.0.1-cp313-cp313-win32.whl", hash = "sha256:ba9e56e8ceeeedb2e080147ba85ffcd5cd0711b89576b83784d8605a7df455fa", size = 176393 }, + { url = "https://files.pythonhosted.org/packages/1b/6c/c65773d6cab416a64d191d6ee8a8b1c68a09970ea6909d16965d26bfed1e/websockets-15.0.1-cp313-cp313-win_amd64.whl", hash = "sha256:e09473f095a819042ecb2ab9465aee615bd9c2028e4ef7d933600a8401c79561", size = 176837 }, + { url = "https://files.pythonhosted.org/packages/fa/a8/5b41e0da817d64113292ab1f8247140aac61cbf6cfd085d6a0fa77f4984f/websockets-15.0.1-py3-none-any.whl", hash = "sha256:f7a866fbc1e97b5c617ee4116daaa09b722101d4a3c170c787450ba409f9736f", size = 169743 }, +]