Compare commits

...

11 Commits

Author SHA1 Message Date
Anthony LC
3cb7aeb7ec 💩(backend) add document content endpoint
Get the content of a document in markdown format.
Ex: http://localhost:8071/api/v1.0/documents/<ID>/content/
2025-06-03 12:12:19 +02:00
Anthony LC
23860065e1 env.example 2025-06-03 11:19:18 +02:00
Anthony LC
f459c56121 📝(mcp) add doc to use mcp with vscode 2025-05-30 17:48:16 +02:00
Quentin BEY
4a81e1526e 👷(hackdays) publish the MCP docker image
Publish the MCP Docker image on our registry.
2025-05-28 11:29:38 +02:00
Quentin BEY
abcd61cf2f 👷(hackdays) publish the docker image
Publish the Docker images to deploy on a dedicated instance for the
Hackdays.
2025-05-28 11:29:38 +02:00
Quentin BEY
c1a591fb4f 🧱(mcp) add server deployment
Provide the helm chart declaration to deploy the MCP server.
2025-05-28 11:29:38 +02:00
Quentin BEY
83d8478b5d 💩(mcp) add a local MCP server configuration
This provides a way to start a local MCP server:
 - provided a user token, the MCP can create document
 - can be run locally and work with cursor or mcphost
2025-05-28 11:29:38 +02:00
Quentin BEY
6bd136c76e 💩(user-tokens) add back & front for Token auth
This provides:
 - a frontend to allow user to create/delete User Token
 - the authentication process to allow any API to be called when
   authenticating with a User Token.
2025-05-26 14:51:08 +02:00
Quentin BEY
e929fcc682 💩(resource-server) open all APIs to RS
This provides a base configuration to allow to access all
API via OIDC resource server authentication.
2025-05-26 11:39:43 +02:00
Quentin BEY
fa819bc1ff 🐛(auth) allow several auth backend on m2m API
The previous `ServerToServerAuthentication` was raising authentication
failed error if anything is wrong (the header, the token) which prevents
any possibility to have several authentication backends.
2025-05-26 11:39:43 +02:00
Quentin BEY
43e529da2a 🔒️(oidc) disable OIDC authentication on API
Our authentication flow uses the Django authentication which creates a
session for the User. Then the session is used to make API calls,
therefore we don't need to accept OIDC tokens directly on the API.

Accepting the OIDC token on the API can allow to bypass the "resource
server mode" which allows to restrict provided information according to
the Service Provider which makes the request.
2025-05-26 11:39:43 +02:00
45 changed files with 2422 additions and 27 deletions

View File

@@ -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

View File

@@ -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

View File

@@ -25,13 +25,15 @@ from django.utils.translation import gettext_lazy as _
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 @@ class DocumentViewSet(
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 @@ class DocumentViewSet(
}
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,

View File

@@ -6,6 +6,15 @@ from rest_framework.authentication import BaseAuthentication
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 @@ class ServerToServerAuthentication(BaseAuthentication):
# 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."""

View File

@@ -839,6 +839,7 @@ class Document(MP_Node, BaseModel):
"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,

View File

@@ -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

View File

@@ -4,15 +4,22 @@ from django.conf import settings
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 @@ urlpatterns = [
[
*router.urls,
*oidc_urls,
*resource_server_urls,
re_path(
r"^documents/(?P<resource_id>[0-9a-z-]*)/",
include(document_related_router.urls),

View File

View File

@@ -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

View File

@@ -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/<token_id>/
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)

View File

@@ -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."""

View File

@@ -10,6 +10,9 @@ For the full list of settings and their values, see
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
@@ -303,6 +306,7 @@ class Base(Configuration):
"django_filters",
"dockerflow.django",
"rest_framework",
"knox",
"parler",
"treebeard",
"easy_thumbnails",
@@ -327,8 +331,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 +598,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

View File

@@ -36,6 +36,7 @@ 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",

View File

@@ -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 = () => {
) : (
<Box $align="center" $gap={spacingsTokens['sm']} $direction="row">
<ButtonLogin />
<Button
onClick={() => router.push(`/user-tokens`)}
aria-label={t('API Tokens', 'API Tokens')}
color="primary-text"
>
{t('API Tokens', 'API Tokens')}
</Button>
<LanguagePicker />
<LaGaufre />
</Box>

View File

@@ -0,0 +1,3 @@
export * from './useListUserTokens';
export * from './useCreateUserToken';
export * from './useDeleteUserToken';

View File

@@ -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<NewUserToken> => {
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<NewUserToken>;
};
export function useCreateUserToken() {
return useMutation<NewUserToken, APIError>({
mutationFn: createUserToken,
});
}

View File

@@ -0,0 +1,30 @@
import { useMutation } from '@tanstack/react-query';
import { APIError, errorCauses, fetchAPI } from '@/api';
export const deleteUserToken = async (digest: string): Promise<void> => {
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<void, APIError, DeleteUserTokenParams>({
mutationFn: ({ digest }) => deleteUserToken(digest),
});
}

View File

@@ -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<UserToken[]> => {
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<UserToken[]>;
};
export function useListUserTokens() {
return useQuery<UserToken[], APIError>({
queryKey: ['userTokens'],
queryFn: listUserTokens,
});
}

View File

@@ -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<UserTokenWithId[]>([]);
const [isLoading, setIsLoading] = useState<boolean>(false);
const [error, setError] = useState<string | null>(null);
const [newToken, setNewToken] = useState<NewUserToken | null>(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 }) => (
<CunninghamButton
onClick={() => {
handleDeleteToken(row.digest);
}}
color="danger"
size="small"
icon={<span className="material-icons">delete</span>}
aria-label="Delete token"
>
Delete
</CunninghamButton>
),
},
];
return (
<Card
$direction="column"
$width="100%"
$padding={{
top: 'base',
horizontal: 'md',
bottom: 'md',
}}
>
<Box $direction="row" $justify="space-between" $align="center">
<h2 style={{ marginBottom: 'var(--c--theme--spacing--medium, 16px)' }}>
User token management
</h2>
<CunninghamButton
onClick={() => void handleCreateToken()}
disabled={isLoading}
>
{isLoading ? 'Generating...' : 'Generate New Token'}
</CunninghamButton>
</Box>
{newToken && (
<Box
$background="var(--c--theme--colors--success-100)"
$padding="md"
$radius="10px"
$margin={{ bottom: 'var(--c--theme--spacing--medium, 16px)' }}
$direction="column"
>
<span style={{ marginLeft: 16 }}>
<strong>New Token:</strong> <code>{newToken.token_key}</code>
</span>
<span style={{ marginLeft: 16 }}>
<strong>Digest:</strong> <code>{newToken.digest}</code>
</span>
<span style={{ marginLeft: 16 }}>
<strong>Expires:</strong>{' '}
<code>{new Date(newToken.expiry).toLocaleString()}</code>
</span>
</Box>
)}
{isLoading && !tokens.length && (
<Box $margin={{ bottom: 'var(--c--theme--spacing--small, 8px)' }}>
Loading...
</Box>
)}
{error && (
<Box
$color="var(--c--theme--colors--danger-500, red)"
$margin={{ bottom: 'var(--c--theme--spacing--small, 8px)' }}
>
{error}
</Box>
)}
<DataGrid<UserTokenWithId>
rows={tokens}
columns={columns}
isLoading={isLoading}
emptyCta={<div>No tokens found.</div>}
/>
{modalState.isOpen && (
<Modal
isOpen={modalState.isOpen}
onClose={() => setModalState((prev) => ({ ...prev, isOpen: false }))}
title={modalState.title}
size={modalState.size} // Use ModalSize enum directly
actions={
modalState.isConfirmation ? (
<Box $width="100%" $direction="row" $justify="space-between">
<CunninghamButton
onClick={() =>
setModalState((prev) => ({ ...prev, isOpen: false }))
}
color="secondary"
>
Cancel
</CunninghamButton>
<CunninghamButton
onClick={() => {
if (modalState.onConfirm) {
modalState.onConfirm();
}
setModalState((prev) => ({ ...prev, isOpen: false }));
}}
color="danger"
>
{modalState.confirmText || 'Confirm'}
</CunninghamButton>
</Box>
) : (
<CunninghamButton
onClick={() =>
setModalState((prev) => ({ ...prev, isOpen: false }))
}
color="primary"
>
Close
</CunninghamButton>
)
}
>
{modalState.message}
</Modal>
)}
</Card>
);
};

View File

@@ -0,0 +1 @@
export { UserTokenManager } from './components/UserTokenManager';

View File

@@ -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;
}

View File

@@ -0,0 +1,13 @@
import { UserTokenManager } from '@/features/user-tokens';
import { MainLayout } from '@/layouts';
import { NextPageWithLayout } from '@/types/next';
const UserTokensPage: NextPageWithLayout = () => {
return <UserTokenManager />;
};
UserTokensPage.getLayout = function getLayout(page: React.ReactElement) {
return <MainLayout backgroundColor="grey">{page}</MainLayout>;
};
export default UserTokensPage;

View File

@@ -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

View File

@@ -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) }}
*/}}

View File

@@ -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 }}

View File

@@ -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 }}

View File

@@ -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 }}

View File

@@ -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

View File

@@ -0,0 +1,4 @@
.venv
.env
docker-compose.yaml
Dockerfile

View File

@@ -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=<API_URL_HERE>
# Token generated from the frontend application
# see http://localhost:3000/user-tokens/
DOCS_API_TOKEN=<TOKEN_HERE>

View File

@@ -0,0 +1 @@
3.12

35
src/mcp_server/Dockerfile Normal file
View File

@@ -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"]

84
src/mcp_server/Makefile Normal file
View File

@@ -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"

154
src/mcp_server/README.md Normal file
View File

@@ -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=<some_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.

View File

@@ -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

View File

@@ -0,0 +1,3 @@
"""MCP Server package."""
__version__ = "0.1.0"

View File

@@ -0,0 +1 @@
"""Authentication module for the MCP server."""

View File

@@ -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

View File

@@ -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

View File

@@ -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"

View File

@@ -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,
)

View File

@@ -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

View File

@@ -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()]))

View File

@@ -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"]

488
src/mcp_server/uv.lock generated Normal file
View File

@@ -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 },
]