ai-parrot 0.17.2__cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- agentui/.prettierrc +15 -0
- agentui/QUICKSTART.md +272 -0
- agentui/README.md +59 -0
- agentui/env.example +16 -0
- agentui/jsconfig.json +14 -0
- agentui/package-lock.json +4242 -0
- agentui/package.json +34 -0
- agentui/scripts/postinstall/apply-patches.mjs +260 -0
- agentui/src/app.css +61 -0
- agentui/src/app.d.ts +13 -0
- agentui/src/app.html +12 -0
- agentui/src/components/LoadingSpinner.svelte +64 -0
- agentui/src/components/ThemeSwitcher.svelte +159 -0
- agentui/src/components/index.js +4 -0
- agentui/src/lib/api/bots.ts +60 -0
- agentui/src/lib/api/chat.ts +22 -0
- agentui/src/lib/api/http.ts +25 -0
- agentui/src/lib/components/BotCard.svelte +33 -0
- agentui/src/lib/components/ChatBubble.svelte +63 -0
- agentui/src/lib/components/Toast.svelte +21 -0
- agentui/src/lib/config.ts +20 -0
- agentui/src/lib/stores/auth.svelte.ts +73 -0
- agentui/src/lib/stores/theme.svelte.js +64 -0
- agentui/src/lib/stores/toast.svelte.ts +31 -0
- agentui/src/lib/utils/conversation.ts +39 -0
- agentui/src/routes/+layout.svelte +20 -0
- agentui/src/routes/+page.svelte +232 -0
- agentui/src/routes/login/+page.svelte +200 -0
- agentui/src/routes/talk/[agentId]/+page.svelte +297 -0
- agentui/src/routes/talk/[agentId]/+page.ts +7 -0
- agentui/static/README.md +1 -0
- agentui/svelte.config.js +11 -0
- agentui/tailwind.config.ts +53 -0
- agentui/tsconfig.json +3 -0
- agentui/vite.config.ts +10 -0
- ai_parrot-0.17.2.dist-info/METADATA +472 -0
- ai_parrot-0.17.2.dist-info/RECORD +535 -0
- ai_parrot-0.17.2.dist-info/WHEEL +6 -0
- ai_parrot-0.17.2.dist-info/entry_points.txt +2 -0
- ai_parrot-0.17.2.dist-info/licenses/LICENSE +21 -0
- ai_parrot-0.17.2.dist-info/top_level.txt +6 -0
- crew-builder/.prettierrc +15 -0
- crew-builder/QUICKSTART.md +259 -0
- crew-builder/README.md +113 -0
- crew-builder/env.example +17 -0
- crew-builder/jsconfig.json +14 -0
- crew-builder/package-lock.json +4182 -0
- crew-builder/package.json +37 -0
- crew-builder/scripts/postinstall/apply-patches.mjs +260 -0
- crew-builder/src/app.css +62 -0
- crew-builder/src/app.d.ts +13 -0
- crew-builder/src/app.html +12 -0
- crew-builder/src/components/LoadingSpinner.svelte +64 -0
- crew-builder/src/components/ThemeSwitcher.svelte +149 -0
- crew-builder/src/components/index.js +9 -0
- crew-builder/src/lib/api/bots.ts +60 -0
- crew-builder/src/lib/api/chat.ts +80 -0
- crew-builder/src/lib/api/client.ts +56 -0
- crew-builder/src/lib/api/crew/crew.ts +136 -0
- crew-builder/src/lib/api/index.ts +5 -0
- crew-builder/src/lib/api/o365/auth.ts +65 -0
- crew-builder/src/lib/auth/auth.ts +54 -0
- crew-builder/src/lib/components/AgentNode.svelte +43 -0
- crew-builder/src/lib/components/BotCard.svelte +33 -0
- crew-builder/src/lib/components/ChatBubble.svelte +67 -0
- crew-builder/src/lib/components/ConfigPanel.svelte +278 -0
- crew-builder/src/lib/components/JsonTreeNode.svelte +76 -0
- crew-builder/src/lib/components/JsonViewer.svelte +24 -0
- crew-builder/src/lib/components/MarkdownEditor.svelte +48 -0
- crew-builder/src/lib/components/ThemeToggle.svelte +36 -0
- crew-builder/src/lib/components/Toast.svelte +67 -0
- crew-builder/src/lib/components/Toolbar.svelte +157 -0
- crew-builder/src/lib/components/index.ts +10 -0
- crew-builder/src/lib/config.ts +8 -0
- crew-builder/src/lib/stores/auth.svelte.ts +228 -0
- crew-builder/src/lib/stores/crewStore.ts +369 -0
- crew-builder/src/lib/stores/theme.svelte.js +145 -0
- crew-builder/src/lib/stores/toast.svelte.ts +69 -0
- crew-builder/src/lib/utils/conversation.ts +39 -0
- crew-builder/src/lib/utils/markdown.ts +122 -0
- crew-builder/src/lib/utils/talkHistory.ts +47 -0
- crew-builder/src/routes/+layout.svelte +20 -0
- crew-builder/src/routes/+page.svelte +539 -0
- crew-builder/src/routes/agents/+page.svelte +247 -0
- crew-builder/src/routes/agents/[agentId]/+page.svelte +288 -0
- crew-builder/src/routes/agents/[agentId]/+page.ts +7 -0
- crew-builder/src/routes/builder/+page.svelte +204 -0
- crew-builder/src/routes/crew/ask/+page.svelte +1052 -0
- crew-builder/src/routes/crew/ask/+page.ts +1 -0
- crew-builder/src/routes/integrations/o365/+page.svelte +304 -0
- crew-builder/src/routes/login/+page.svelte +197 -0
- crew-builder/src/routes/talk/[agentId]/+page.svelte +487 -0
- crew-builder/src/routes/talk/[agentId]/+page.ts +7 -0
- crew-builder/static/README.md +1 -0
- crew-builder/svelte.config.js +11 -0
- crew-builder/tailwind.config.ts +53 -0
- crew-builder/tsconfig.json +3 -0
- crew-builder/vite.config.ts +10 -0
- mcp_servers/calculator_server.py +309 -0
- parrot/__init__.py +27 -0
- parrot/__pycache__/__init__.cpython-310.pyc +0 -0
- parrot/__pycache__/version.cpython-310.pyc +0 -0
- parrot/_version.py +34 -0
- parrot/a2a/__init__.py +48 -0
- parrot/a2a/client.py +658 -0
- parrot/a2a/discovery.py +89 -0
- parrot/a2a/mixin.py +257 -0
- parrot/a2a/models.py +376 -0
- parrot/a2a/server.py +770 -0
- parrot/agents/__init__.py +29 -0
- parrot/bots/__init__.py +12 -0
- parrot/bots/a2a_agent.py +19 -0
- parrot/bots/abstract.py +3139 -0
- parrot/bots/agent.py +1129 -0
- parrot/bots/basic.py +9 -0
- parrot/bots/chatbot.py +669 -0
- parrot/bots/data.py +1618 -0
- parrot/bots/database/__init__.py +5 -0
- parrot/bots/database/abstract.py +3071 -0
- parrot/bots/database/cache.py +286 -0
- parrot/bots/database/models.py +468 -0
- parrot/bots/database/prompts.py +154 -0
- parrot/bots/database/retries.py +98 -0
- parrot/bots/database/router.py +269 -0
- parrot/bots/database/sql.py +41 -0
- parrot/bots/db/__init__.py +6 -0
- parrot/bots/db/abstract.py +556 -0
- parrot/bots/db/bigquery.py +602 -0
- parrot/bots/db/cache.py +85 -0
- parrot/bots/db/documentdb.py +668 -0
- parrot/bots/db/elastic.py +1014 -0
- parrot/bots/db/influx.py +898 -0
- parrot/bots/db/mock.py +96 -0
- parrot/bots/db/multi.py +783 -0
- parrot/bots/db/prompts.py +185 -0
- parrot/bots/db/sql.py +1255 -0
- parrot/bots/db/tools.py +212 -0
- parrot/bots/document.py +680 -0
- parrot/bots/hrbot.py +15 -0
- parrot/bots/kb.py +170 -0
- parrot/bots/mcp.py +36 -0
- parrot/bots/orchestration/README.md +463 -0
- parrot/bots/orchestration/__init__.py +1 -0
- parrot/bots/orchestration/agent.py +155 -0
- parrot/bots/orchestration/crew.py +3330 -0
- parrot/bots/orchestration/fsm.py +1179 -0
- parrot/bots/orchestration/hr.py +434 -0
- parrot/bots/orchestration/storage/__init__.py +4 -0
- parrot/bots/orchestration/storage/memory.py +100 -0
- parrot/bots/orchestration/storage/mixin.py +119 -0
- parrot/bots/orchestration/verify.py +202 -0
- parrot/bots/product.py +204 -0
- parrot/bots/prompts/__init__.py +96 -0
- parrot/bots/prompts/agents.py +155 -0
- parrot/bots/prompts/data.py +216 -0
- parrot/bots/prompts/output_generation.py +8 -0
- parrot/bots/scraper/__init__.py +3 -0
- parrot/bots/scraper/models.py +122 -0
- parrot/bots/scraper/scraper.py +1173 -0
- parrot/bots/scraper/templates.py +115 -0
- parrot/bots/stores/__init__.py +5 -0
- parrot/bots/stores/local.py +172 -0
- parrot/bots/webdev.py +81 -0
- parrot/cli.py +17 -0
- parrot/clients/__init__.py +16 -0
- parrot/clients/base.py +1491 -0
- parrot/clients/claude.py +1191 -0
- parrot/clients/factory.py +129 -0
- parrot/clients/google.py +4567 -0
- parrot/clients/gpt.py +1975 -0
- parrot/clients/grok.py +432 -0
- parrot/clients/groq.py +986 -0
- parrot/clients/hf.py +582 -0
- parrot/clients/models.py +18 -0
- parrot/conf.py +395 -0
- parrot/embeddings/__init__.py +9 -0
- parrot/embeddings/base.py +157 -0
- parrot/embeddings/google.py +98 -0
- parrot/embeddings/huggingface.py +74 -0
- parrot/embeddings/openai.py +84 -0
- parrot/embeddings/processor.py +88 -0
- parrot/exceptions.c +13868 -0
- parrot/exceptions.cpython-310-x86_64-linux-gnu.so +0 -0
- parrot/exceptions.pxd +22 -0
- parrot/exceptions.pxi +15 -0
- parrot/exceptions.pyx +44 -0
- parrot/generators/__init__.py +29 -0
- parrot/generators/base.py +200 -0
- parrot/generators/html.py +293 -0
- parrot/generators/react.py +205 -0
- parrot/generators/streamlit.py +203 -0
- parrot/generators/template.py +105 -0
- parrot/handlers/__init__.py +4 -0
- parrot/handlers/agent.py +861 -0
- parrot/handlers/agents/__init__.py +1 -0
- parrot/handlers/agents/abstract.py +900 -0
- parrot/handlers/bots.py +338 -0
- parrot/handlers/chat.py +915 -0
- parrot/handlers/creation.sql +192 -0
- parrot/handlers/crew/ARCHITECTURE.md +362 -0
- parrot/handlers/crew/README_BOTMANAGER_PERSISTENCE.md +303 -0
- parrot/handlers/crew/README_REDIS_PERSISTENCE.md +366 -0
- parrot/handlers/crew/__init__.py +0 -0
- parrot/handlers/crew/handler.py +801 -0
- parrot/handlers/crew/models.py +229 -0
- parrot/handlers/crew/redis_persistence.py +523 -0
- parrot/handlers/jobs/__init__.py +10 -0
- parrot/handlers/jobs/job.py +384 -0
- parrot/handlers/jobs/mixin.py +627 -0
- parrot/handlers/jobs/models.py +115 -0
- parrot/handlers/jobs/worker.py +31 -0
- parrot/handlers/models.py +596 -0
- parrot/handlers/o365_auth.py +105 -0
- parrot/handlers/stream.py +337 -0
- parrot/interfaces/__init__.py +6 -0
- parrot/interfaces/aws.py +143 -0
- parrot/interfaces/credentials.py +113 -0
- parrot/interfaces/database.py +27 -0
- parrot/interfaces/google.py +1123 -0
- parrot/interfaces/hierarchy.py +1227 -0
- parrot/interfaces/http.py +651 -0
- parrot/interfaces/images/__init__.py +0 -0
- parrot/interfaces/images/plugins/__init__.py +24 -0
- parrot/interfaces/images/plugins/abstract.py +58 -0
- parrot/interfaces/images/plugins/analisys.py +148 -0
- parrot/interfaces/images/plugins/classify.py +150 -0
- parrot/interfaces/images/plugins/classifybase.py +182 -0
- parrot/interfaces/images/plugins/detect.py +150 -0
- parrot/interfaces/images/plugins/exif.py +1103 -0
- parrot/interfaces/images/plugins/hash.py +52 -0
- parrot/interfaces/images/plugins/vision.py +104 -0
- parrot/interfaces/images/plugins/yolo.py +66 -0
- parrot/interfaces/images/plugins/zerodetect.py +197 -0
- parrot/interfaces/o365.py +978 -0
- parrot/interfaces/onedrive.py +822 -0
- parrot/interfaces/sharepoint.py +1435 -0
- parrot/interfaces/soap.py +257 -0
- parrot/loaders/__init__.py +8 -0
- parrot/loaders/abstract.py +1131 -0
- parrot/loaders/audio.py +199 -0
- parrot/loaders/basepdf.py +53 -0
- parrot/loaders/basevideo.py +1568 -0
- parrot/loaders/csv.py +409 -0
- parrot/loaders/docx.py +116 -0
- parrot/loaders/epubloader.py +316 -0
- parrot/loaders/excel.py +199 -0
- parrot/loaders/factory.py +55 -0
- parrot/loaders/files/__init__.py +0 -0
- parrot/loaders/files/abstract.py +39 -0
- parrot/loaders/files/html.py +26 -0
- parrot/loaders/files/text.py +63 -0
- parrot/loaders/html.py +152 -0
- parrot/loaders/markdown.py +442 -0
- parrot/loaders/pdf.py +373 -0
- parrot/loaders/pdfmark.py +320 -0
- parrot/loaders/pdftables.py +506 -0
- parrot/loaders/ppt.py +476 -0
- parrot/loaders/qa.py +63 -0
- parrot/loaders/splitters/__init__.py +10 -0
- parrot/loaders/splitters/base.py +138 -0
- parrot/loaders/splitters/md.py +228 -0
- parrot/loaders/splitters/token.py +143 -0
- parrot/loaders/txt.py +26 -0
- parrot/loaders/video.py +89 -0
- parrot/loaders/videolocal.py +218 -0
- parrot/loaders/videounderstanding.py +377 -0
- parrot/loaders/vimeo.py +167 -0
- parrot/loaders/web.py +599 -0
- parrot/loaders/youtube.py +504 -0
- parrot/manager/__init__.py +5 -0
- parrot/manager/manager.py +1030 -0
- parrot/mcp/__init__.py +28 -0
- parrot/mcp/adapter.py +105 -0
- parrot/mcp/cli.py +174 -0
- parrot/mcp/client.py +119 -0
- parrot/mcp/config.py +75 -0
- parrot/mcp/integration.py +842 -0
- parrot/mcp/oauth.py +933 -0
- parrot/mcp/server.py +225 -0
- parrot/mcp/transports/__init__.py +3 -0
- parrot/mcp/transports/base.py +279 -0
- parrot/mcp/transports/grpc_session.py +163 -0
- parrot/mcp/transports/http.py +312 -0
- parrot/mcp/transports/mcp.proto +108 -0
- parrot/mcp/transports/quic.py +1082 -0
- parrot/mcp/transports/sse.py +330 -0
- parrot/mcp/transports/stdio.py +309 -0
- parrot/mcp/transports/unix.py +395 -0
- parrot/mcp/transports/websocket.py +547 -0
- parrot/memory/__init__.py +16 -0
- parrot/memory/abstract.py +209 -0
- parrot/memory/agent.py +32 -0
- parrot/memory/cache.py +175 -0
- parrot/memory/core.py +555 -0
- parrot/memory/file.py +153 -0
- parrot/memory/mem.py +131 -0
- parrot/memory/redis.py +613 -0
- parrot/models/__init__.py +46 -0
- parrot/models/basic.py +118 -0
- parrot/models/compliance.py +208 -0
- parrot/models/crew.py +395 -0
- parrot/models/detections.py +654 -0
- parrot/models/generation.py +85 -0
- parrot/models/google.py +223 -0
- parrot/models/groq.py +23 -0
- parrot/models/openai.py +30 -0
- parrot/models/outputs.py +285 -0
- parrot/models/responses.py +938 -0
- parrot/notifications/__init__.py +743 -0
- parrot/openapi/__init__.py +3 -0
- parrot/openapi/components.yaml +641 -0
- parrot/openapi/config.py +322 -0
- parrot/outputs/__init__.py +32 -0
- parrot/outputs/formats/__init__.py +108 -0
- parrot/outputs/formats/altair.py +359 -0
- parrot/outputs/formats/application.py +122 -0
- parrot/outputs/formats/base.py +351 -0
- parrot/outputs/formats/bokeh.py +356 -0
- parrot/outputs/formats/card.py +424 -0
- parrot/outputs/formats/chart.py +436 -0
- parrot/outputs/formats/d3.py +255 -0
- parrot/outputs/formats/echarts.py +310 -0
- parrot/outputs/formats/generators/__init__.py +0 -0
- parrot/outputs/formats/generators/abstract.py +61 -0
- parrot/outputs/formats/generators/panel.py +145 -0
- parrot/outputs/formats/generators/streamlit.py +86 -0
- parrot/outputs/formats/generators/terminal.py +63 -0
- parrot/outputs/formats/holoviews.py +310 -0
- parrot/outputs/formats/html.py +147 -0
- parrot/outputs/formats/jinja2.py +46 -0
- parrot/outputs/formats/json.py +87 -0
- parrot/outputs/formats/map.py +933 -0
- parrot/outputs/formats/markdown.py +172 -0
- parrot/outputs/formats/matplotlib.py +237 -0
- parrot/outputs/formats/mixins/__init__.py +0 -0
- parrot/outputs/formats/mixins/emaps.py +855 -0
- parrot/outputs/formats/plotly.py +341 -0
- parrot/outputs/formats/seaborn.py +310 -0
- parrot/outputs/formats/table.py +397 -0
- parrot/outputs/formats/template_report.py +138 -0
- parrot/outputs/formats/yaml.py +125 -0
- parrot/outputs/formatter.py +152 -0
- parrot/outputs/templates/__init__.py +95 -0
- parrot/pipelines/__init__.py +0 -0
- parrot/pipelines/abstract.py +210 -0
- parrot/pipelines/detector.py +124 -0
- parrot/pipelines/models.py +90 -0
- parrot/pipelines/planogram.py +3002 -0
- parrot/pipelines/table.sql +97 -0
- parrot/plugins/__init__.py +106 -0
- parrot/plugins/importer.py +80 -0
- parrot/py.typed +0 -0
- parrot/registry/__init__.py +18 -0
- parrot/registry/registry.py +594 -0
- parrot/scheduler/__init__.py +1189 -0
- parrot/scheduler/models.py +60 -0
- parrot/security/__init__.py +16 -0
- parrot/security/prompt_injection.py +268 -0
- parrot/security/security_events.sql +25 -0
- parrot/services/__init__.py +1 -0
- parrot/services/mcp/__init__.py +8 -0
- parrot/services/mcp/config.py +13 -0
- parrot/services/mcp/server.py +295 -0
- parrot/services/o365_remote_auth.py +235 -0
- parrot/stores/__init__.py +7 -0
- parrot/stores/abstract.py +352 -0
- parrot/stores/arango.py +1090 -0
- parrot/stores/bigquery.py +1377 -0
- parrot/stores/cache.py +106 -0
- parrot/stores/empty.py +10 -0
- parrot/stores/faiss_store.py +1157 -0
- parrot/stores/kb/__init__.py +9 -0
- parrot/stores/kb/abstract.py +68 -0
- parrot/stores/kb/cache.py +165 -0
- parrot/stores/kb/doc.py +325 -0
- parrot/stores/kb/hierarchy.py +346 -0
- parrot/stores/kb/local.py +457 -0
- parrot/stores/kb/prompt.py +28 -0
- parrot/stores/kb/redis.py +659 -0
- parrot/stores/kb/store.py +115 -0
- parrot/stores/kb/user.py +374 -0
- parrot/stores/models.py +59 -0
- parrot/stores/pgvector.py +3 -0
- parrot/stores/postgres.py +2853 -0
- parrot/stores/utils/__init__.py +0 -0
- parrot/stores/utils/chunking.py +197 -0
- parrot/telemetry/__init__.py +3 -0
- parrot/telemetry/mixin.py +111 -0
- parrot/template/__init__.py +3 -0
- parrot/template/engine.py +259 -0
- parrot/tools/__init__.py +23 -0
- parrot/tools/abstract.py +644 -0
- parrot/tools/agent.py +363 -0
- parrot/tools/arangodbsearch.py +537 -0
- parrot/tools/arxiv_tool.py +188 -0
- parrot/tools/calculator/__init__.py +3 -0
- parrot/tools/calculator/operations/__init__.py +38 -0
- parrot/tools/calculator/operations/calculus.py +80 -0
- parrot/tools/calculator/operations/statistics.py +76 -0
- parrot/tools/calculator/tool.py +150 -0
- parrot/tools/cloudwatch.py +988 -0
- parrot/tools/codeinterpreter/__init__.py +127 -0
- parrot/tools/codeinterpreter/executor.py +371 -0
- parrot/tools/codeinterpreter/internals.py +473 -0
- parrot/tools/codeinterpreter/models.py +643 -0
- parrot/tools/codeinterpreter/prompts.py +224 -0
- parrot/tools/codeinterpreter/tool.py +664 -0
- parrot/tools/company_info/__init__.py +6 -0
- parrot/tools/company_info/tool.py +1138 -0
- parrot/tools/correlationanalysis.py +437 -0
- parrot/tools/database/abstract.py +286 -0
- parrot/tools/database/bq.py +115 -0
- parrot/tools/database/cache.py +284 -0
- parrot/tools/database/models.py +95 -0
- parrot/tools/database/pg.py +343 -0
- parrot/tools/databasequery.py +1159 -0
- parrot/tools/db.py +1800 -0
- parrot/tools/ddgo.py +370 -0
- parrot/tools/decorators.py +271 -0
- parrot/tools/dftohtml.py +282 -0
- parrot/tools/document.py +549 -0
- parrot/tools/ecs.py +819 -0
- parrot/tools/edareport.py +368 -0
- parrot/tools/elasticsearch.py +1049 -0
- parrot/tools/employees.py +462 -0
- parrot/tools/epson/__init__.py +96 -0
- parrot/tools/excel.py +683 -0
- parrot/tools/file/__init__.py +13 -0
- parrot/tools/file/abstract.py +76 -0
- parrot/tools/file/gcs.py +378 -0
- parrot/tools/file/local.py +284 -0
- parrot/tools/file/s3.py +511 -0
- parrot/tools/file/tmp.py +309 -0
- parrot/tools/file/tool.py +501 -0
- parrot/tools/file_reader.py +129 -0
- parrot/tools/flowtask/__init__.py +19 -0
- parrot/tools/flowtask/tool.py +761 -0
- parrot/tools/gittoolkit.py +508 -0
- parrot/tools/google/__init__.py +18 -0
- parrot/tools/google/base.py +169 -0
- parrot/tools/google/tools.py +1251 -0
- parrot/tools/googlelocation.py +5 -0
- parrot/tools/googleroutes.py +5 -0
- parrot/tools/googlesearch.py +5 -0
- parrot/tools/googlesitesearch.py +5 -0
- parrot/tools/googlevoice.py +2 -0
- parrot/tools/gvoice.py +695 -0
- parrot/tools/ibisworld/README.md +225 -0
- parrot/tools/ibisworld/__init__.py +11 -0
- parrot/tools/ibisworld/tool.py +366 -0
- parrot/tools/jiratoolkit.py +1718 -0
- parrot/tools/manager.py +1098 -0
- parrot/tools/math.py +152 -0
- parrot/tools/metadata.py +476 -0
- parrot/tools/msteams.py +1621 -0
- parrot/tools/msword.py +635 -0
- parrot/tools/multidb.py +580 -0
- parrot/tools/multistoresearch.py +369 -0
- parrot/tools/networkninja.py +167 -0
- parrot/tools/nextstop/__init__.py +4 -0
- parrot/tools/nextstop/base.py +286 -0
- parrot/tools/nextstop/employee.py +733 -0
- parrot/tools/nextstop/store.py +462 -0
- parrot/tools/notification.py +435 -0
- parrot/tools/o365/__init__.py +42 -0
- parrot/tools/o365/base.py +295 -0
- parrot/tools/o365/bundle.py +522 -0
- parrot/tools/o365/events.py +554 -0
- parrot/tools/o365/mail.py +992 -0
- parrot/tools/o365/onedrive.py +497 -0
- parrot/tools/o365/sharepoint.py +641 -0
- parrot/tools/openapi_toolkit.py +904 -0
- parrot/tools/openweather.py +527 -0
- parrot/tools/pdfprint.py +1001 -0
- parrot/tools/powerbi.py +518 -0
- parrot/tools/powerpoint.py +1113 -0
- parrot/tools/pricestool.py +146 -0
- parrot/tools/products/__init__.py +246 -0
- parrot/tools/prophet_tool.py +171 -0
- parrot/tools/pythonpandas.py +630 -0
- parrot/tools/pythonrepl.py +910 -0
- parrot/tools/qsource.py +436 -0
- parrot/tools/querytoolkit.py +395 -0
- parrot/tools/quickeda.py +827 -0
- parrot/tools/resttool.py +553 -0
- parrot/tools/retail/__init__.py +0 -0
- parrot/tools/retail/bby.py +528 -0
- parrot/tools/sandboxtool.py +703 -0
- parrot/tools/sassie/__init__.py +352 -0
- parrot/tools/scraping/__init__.py +7 -0
- parrot/tools/scraping/docs/select.md +466 -0
- parrot/tools/scraping/documentation.md +1278 -0
- parrot/tools/scraping/driver.py +436 -0
- parrot/tools/scraping/models.py +576 -0
- parrot/tools/scraping/options.py +85 -0
- parrot/tools/scraping/orchestrator.py +517 -0
- parrot/tools/scraping/readme.md +740 -0
- parrot/tools/scraping/tool.py +3115 -0
- parrot/tools/seasonaldetection.py +642 -0
- parrot/tools/shell_tool/__init__.py +5 -0
- parrot/tools/shell_tool/actions.py +408 -0
- parrot/tools/shell_tool/engine.py +155 -0
- parrot/tools/shell_tool/models.py +322 -0
- parrot/tools/shell_tool/tool.py +442 -0
- parrot/tools/site_search.py +214 -0
- parrot/tools/textfile.py +418 -0
- parrot/tools/think.py +378 -0
- parrot/tools/toolkit.py +298 -0
- parrot/tools/webapp_tool.py +187 -0
- parrot/tools/whatif.py +1279 -0
- parrot/tools/workday/MULTI_WSDL_EXAMPLE.md +249 -0
- parrot/tools/workday/__init__.py +6 -0
- parrot/tools/workday/models.py +1389 -0
- parrot/tools/workday/tool.py +1293 -0
- parrot/tools/yfinance_tool.py +306 -0
- parrot/tools/zipcode.py +217 -0
- parrot/utils/__init__.py +2 -0
- parrot/utils/helpers.py +73 -0
- parrot/utils/parsers/__init__.py +5 -0
- parrot/utils/parsers/toml.c +12078 -0
- parrot/utils/parsers/toml.cpython-310-x86_64-linux-gnu.so +0 -0
- parrot/utils/parsers/toml.pyx +21 -0
- parrot/utils/toml.py +11 -0
- parrot/utils/types.cpp +20936 -0
- parrot/utils/types.cpython-310-x86_64-linux-gnu.so +0 -0
- parrot/utils/types.pyx +213 -0
- parrot/utils/uv.py +11 -0
- parrot/version.py +10 -0
- parrot/yaml-rs/Cargo.lock +350 -0
- parrot/yaml-rs/Cargo.toml +19 -0
- parrot/yaml-rs/pyproject.toml +19 -0
- parrot/yaml-rs/python/yaml_rs/__init__.py +81 -0
- parrot/yaml-rs/src/lib.rs +222 -0
- requirements/docker-compose.yml +24 -0
- requirements/requirements-dev.txt +21 -0
parrot/mcp/oauth.py
ADDED
|
@@ -0,0 +1,933 @@
|
|
|
1
|
+
import os
|
|
2
|
+
import sys
|
|
3
|
+
from typing import Optional, Dict, Any
|
|
4
|
+
from dataclasses import dataclass, field
|
|
5
|
+
import asyncio
|
|
6
|
+
import time
|
|
7
|
+
import base64
|
|
8
|
+
import hashlib
|
|
9
|
+
import secrets
|
|
10
|
+
import json
|
|
11
|
+
from urllib.parse import urlencode
|
|
12
|
+
from aiohttp import web, ClientSession
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def _b64url(data: bytes) -> str:
|
|
16
|
+
return base64.urlsafe_b64encode(data).rstrip(b"=").decode()
|
|
17
|
+
|
|
18
|
+
def _now() -> int:
|
|
19
|
+
return int(time.time())
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
# ---- API Key Authentication ----
|
|
23
|
+
|
|
24
|
+
@dataclass
|
|
25
|
+
class APIKeyRecord:
|
|
26
|
+
"""Record for an issued API key."""
|
|
27
|
+
key: str
|
|
28
|
+
user_id: str
|
|
29
|
+
created_at: float
|
|
30
|
+
expires_at: Optional[float] = None
|
|
31
|
+
scopes: list[str] = field(default_factory=list)
|
|
32
|
+
description: str = ""
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class APIKeyStore:
|
|
36
|
+
"""
|
|
37
|
+
In-memory API key store with session logging.
|
|
38
|
+
|
|
39
|
+
Provides API key issuance, validation, and session tracking for
|
|
40
|
+
MCP server authentication.
|
|
41
|
+
"""
|
|
42
|
+
|
|
43
|
+
def __init__(self):
|
|
44
|
+
self._keys: Dict[str, APIKeyRecord] = {}
|
|
45
|
+
self._sessions: list[Dict[str, Any]] = []
|
|
46
|
+
|
|
47
|
+
def issue_key(
|
|
48
|
+
self,
|
|
49
|
+
user_id: str,
|
|
50
|
+
scopes: Optional[list[str]] = None,
|
|
51
|
+
ttl: Optional[int] = None,
|
|
52
|
+
description: str = ""
|
|
53
|
+
) -> APIKeyRecord:
|
|
54
|
+
"""
|
|
55
|
+
Issue a new API key for a user.
|
|
56
|
+
|
|
57
|
+
Args:
|
|
58
|
+
user_id: User identifier
|
|
59
|
+
scopes: Optional list of scopes for the key
|
|
60
|
+
ttl: Time-to-live in seconds (None for no expiration)
|
|
61
|
+
description: Human-readable description
|
|
62
|
+
|
|
63
|
+
Returns:
|
|
64
|
+
APIKeyRecord with the issued key
|
|
65
|
+
"""
|
|
66
|
+
key = f"mcp_key_{secrets.token_urlsafe(32)}"
|
|
67
|
+
now = _now()
|
|
68
|
+
expires_at = (now + ttl) if ttl else None
|
|
69
|
+
|
|
70
|
+
record = APIKeyRecord(
|
|
71
|
+
key=key,
|
|
72
|
+
user_id=user_id,
|
|
73
|
+
created_at=now,
|
|
74
|
+
expires_at=expires_at,
|
|
75
|
+
scopes=scopes or [],
|
|
76
|
+
description=description,
|
|
77
|
+
)
|
|
78
|
+
self._keys[key] = record
|
|
79
|
+
return record
|
|
80
|
+
|
|
81
|
+
def validate_key(self, key: str) -> Optional[APIKeyRecord]:
|
|
82
|
+
"""
|
|
83
|
+
Validate an API key.
|
|
84
|
+
|
|
85
|
+
Args:
|
|
86
|
+
key: The API key to validate
|
|
87
|
+
|
|
88
|
+
Returns:
|
|
89
|
+
APIKeyRecord if valid, None if invalid or expired
|
|
90
|
+
"""
|
|
91
|
+
if not key:
|
|
92
|
+
return None
|
|
93
|
+
|
|
94
|
+
record = self._keys.get(key)
|
|
95
|
+
if not record:
|
|
96
|
+
return None
|
|
97
|
+
|
|
98
|
+
# Check expiration
|
|
99
|
+
if record.expires_at and record.expires_at <= _now():
|
|
100
|
+
return None
|
|
101
|
+
|
|
102
|
+
return record
|
|
103
|
+
|
|
104
|
+
def revoke_key(self, key: str) -> bool:
|
|
105
|
+
"""
|
|
106
|
+
Revoke an API key.
|
|
107
|
+
|
|
108
|
+
Args:
|
|
109
|
+
key: The API key to revoke
|
|
110
|
+
|
|
111
|
+
Returns:
|
|
112
|
+
True if revoked, False if key not found
|
|
113
|
+
"""
|
|
114
|
+
if key in self._keys:
|
|
115
|
+
del self._keys[key]
|
|
116
|
+
return True
|
|
117
|
+
return False
|
|
118
|
+
|
|
119
|
+
def log_session_start(self, key: str, user_id: str, timestamp: float) -> None:
|
|
120
|
+
"""
|
|
121
|
+
Log the start of a session using an API key.
|
|
122
|
+
|
|
123
|
+
Args:
|
|
124
|
+
key: The API key used
|
|
125
|
+
user_id: User identifier
|
|
126
|
+
timestamp: Session start timestamp
|
|
127
|
+
"""
|
|
128
|
+
self._sessions.append({
|
|
129
|
+
"key": key[:16] + "...", # Truncate for security
|
|
130
|
+
"user_id": user_id,
|
|
131
|
+
"started_at": timestamp,
|
|
132
|
+
"started_at_iso": time.strftime(
|
|
133
|
+
"%Y-%m-%dT%H:%M:%SZ", time.gmtime(timestamp)
|
|
134
|
+
),
|
|
135
|
+
})
|
|
136
|
+
|
|
137
|
+
def get_sessions(
|
|
138
|
+
self, user_id: Optional[str] = None, limit: int = 100
|
|
139
|
+
) -> list[Dict[str, Any]]:
|
|
140
|
+
"""
|
|
141
|
+
Get session logs.
|
|
142
|
+
|
|
143
|
+
Args:
|
|
144
|
+
user_id: Optional filter by user ID
|
|
145
|
+
limit: Maximum number of sessions to return
|
|
146
|
+
|
|
147
|
+
Returns:
|
|
148
|
+
List of session records
|
|
149
|
+
"""
|
|
150
|
+
sessions = self._sessions
|
|
151
|
+
if user_id:
|
|
152
|
+
sessions = [s for s in sessions if s["user_id"] == user_id]
|
|
153
|
+
return sessions[-limit:]
|
|
154
|
+
|
|
155
|
+
def list_keys(self, user_id: Optional[str] = None) -> list[APIKeyRecord]:
|
|
156
|
+
"""
|
|
157
|
+
List all API keys.
|
|
158
|
+
|
|
159
|
+
Args:
|
|
160
|
+
user_id: Optional filter by user ID
|
|
161
|
+
|
|
162
|
+
Returns:
|
|
163
|
+
List of API key records
|
|
164
|
+
"""
|
|
165
|
+
keys = list(self._keys.values())
|
|
166
|
+
if user_id:
|
|
167
|
+
keys = [k for k in keys if k.user_id == user_id]
|
|
168
|
+
return keys
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
# ---- External OAuth2 Integration ----
|
|
172
|
+
|
|
173
|
+
class ExternalOAuthValidator:
|
|
174
|
+
"""
|
|
175
|
+
Validates tokens against external OAuth2 servers using RFC 7662 introspection.
|
|
176
|
+
|
|
177
|
+
Use this for integrating with external identity providers like Azure AD,
|
|
178
|
+
Keycloak, Okta, etc.
|
|
179
|
+
"""
|
|
180
|
+
|
|
181
|
+
def __init__(
|
|
182
|
+
self,
|
|
183
|
+
introspection_endpoint: str,
|
|
184
|
+
client_id: str,
|
|
185
|
+
client_secret: str,
|
|
186
|
+
resource_server_url: Optional[str] = None,
|
|
187
|
+
http_timeout: float = 15.0,
|
|
188
|
+
):
|
|
189
|
+
"""
|
|
190
|
+
Initialize external OAuth validator.
|
|
191
|
+
|
|
192
|
+
Args:
|
|
193
|
+
introspection_endpoint: Token introspection endpoint URL
|
|
194
|
+
client_id: Client ID for introspection requests
|
|
195
|
+
client_secret: Client secret for introspection requests
|
|
196
|
+
resource_server_url: Expected audience/resource URL
|
|
197
|
+
http_timeout: HTTP request timeout in seconds
|
|
198
|
+
"""
|
|
199
|
+
self.introspection_endpoint = introspection_endpoint
|
|
200
|
+
self.client_id = client_id
|
|
201
|
+
self.client_secret = client_secret
|
|
202
|
+
self.resource_server_url = resource_server_url
|
|
203
|
+
self.http_timeout = http_timeout
|
|
204
|
+
self._token_cache: Dict[str, Dict[str, Any]] = {}
|
|
205
|
+
|
|
206
|
+
async def validate_token(self, token: str) -> Optional[Dict[str, Any]]:
|
|
207
|
+
"""
|
|
208
|
+
Validate a token via introspection.
|
|
209
|
+
|
|
210
|
+
Args:
|
|
211
|
+
token: Bearer token to validate
|
|
212
|
+
|
|
213
|
+
Returns:
|
|
214
|
+
Token info dict if valid, None if invalid
|
|
215
|
+
"""
|
|
216
|
+
if not token:
|
|
217
|
+
return None
|
|
218
|
+
|
|
219
|
+
try:
|
|
220
|
+
info = await self.get_token_info(token)
|
|
221
|
+
if not info.get("active", False):
|
|
222
|
+
return None
|
|
223
|
+
|
|
224
|
+
# Validate audience if configured
|
|
225
|
+
if self.resource_server_url:
|
|
226
|
+
aud = info.get("aud", [])
|
|
227
|
+
if isinstance(aud, str):
|
|
228
|
+
aud = [aud]
|
|
229
|
+
if self.resource_server_url not in aud:
|
|
230
|
+
return None
|
|
231
|
+
|
|
232
|
+
return info
|
|
233
|
+
except Exception:
|
|
234
|
+
return None
|
|
235
|
+
|
|
236
|
+
async def get_token_info(self, token: str) -> Dict[str, Any]:
|
|
237
|
+
"""
|
|
238
|
+
Get token info from introspection endpoint (RFC 7662).
|
|
239
|
+
|
|
240
|
+
Args:
|
|
241
|
+
token: Bearer token to introspect
|
|
242
|
+
|
|
243
|
+
Returns:
|
|
244
|
+
Token introspection response
|
|
245
|
+
|
|
246
|
+
Raises:
|
|
247
|
+
Exception on HTTP or validation errors
|
|
248
|
+
"""
|
|
249
|
+
# Check cache first
|
|
250
|
+
cached = self._token_cache.get(token)
|
|
251
|
+
if cached and cached.get("_cached_until", 0) > _now():
|
|
252
|
+
return cached
|
|
253
|
+
|
|
254
|
+
# Prepare introspection request
|
|
255
|
+
params = {
|
|
256
|
+
"token": token,
|
|
257
|
+
"client_id": self.client_id,
|
|
258
|
+
"client_secret": self.client_secret,
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
async with ClientSession() as session:
|
|
262
|
+
async with session.post(
|
|
263
|
+
self.introspection_endpoint,
|
|
264
|
+
data=params,
|
|
265
|
+
headers={"Content-Type": "application/x-www-form-urlencoded"},
|
|
266
|
+
timeout=self.http_timeout,
|
|
267
|
+
) as response:
|
|
268
|
+
if response.status != 200:
|
|
269
|
+
text = await response.text()
|
|
270
|
+
raise RuntimeError(
|
|
271
|
+
f"Introspection failed: {response.status} - {text}"
|
|
272
|
+
)
|
|
273
|
+
|
|
274
|
+
info = await response.json()
|
|
275
|
+
|
|
276
|
+
# Cache with TTL
|
|
277
|
+
if info.get("active"):
|
|
278
|
+
exp = info.get("exp", _now() + 60)
|
|
279
|
+
info["_cached_until"] = min(exp, _now() + 300) # Max 5 min cache
|
|
280
|
+
self._token_cache[token] = info
|
|
281
|
+
|
|
282
|
+
return info
|
|
283
|
+
|
|
284
|
+
def clear_cache(self) -> None:
|
|
285
|
+
"""Clear the token cache."""
|
|
286
|
+
self._token_cache.clear()
|
|
287
|
+
|
|
288
|
+
|
|
289
|
+
# ---- OAuth Client Models ----
|
|
290
|
+
|
|
291
|
+
@dataclass
|
|
292
|
+
class OAuthClient:
|
|
293
|
+
client_id: str
|
|
294
|
+
client_secret: str
|
|
295
|
+
client_name: str
|
|
296
|
+
redirect_uris: list[str]
|
|
297
|
+
scopes: list[str] = field(default_factory=list)
|
|
298
|
+
created_at: float = field(default_factory=time.time)
|
|
299
|
+
|
|
300
|
+
|
|
301
|
+
class ClientRegistry:
|
|
302
|
+
"""
|
|
303
|
+
Minimal in-memory Dynamic Client Registration (RFC 7591) registry.
|
|
304
|
+
Suitable for local development / proxy-style OAuth flows.
|
|
305
|
+
"""
|
|
306
|
+
|
|
307
|
+
def __init__(self):
|
|
308
|
+
self._clients: Dict[str, OAuthClient] = {}
|
|
309
|
+
|
|
310
|
+
def register(self, metadata: Dict[str, Any]) -> OAuthClient:
|
|
311
|
+
if "redirect_uris" not in metadata:
|
|
312
|
+
raise ValueError("redirect_uris is required for client registration")
|
|
313
|
+
|
|
314
|
+
client_id = metadata.get("client_id") or secrets.token_urlsafe(16)
|
|
315
|
+
client_secret = metadata.get("client_secret") or secrets.token_urlsafe(32)
|
|
316
|
+
client_name = metadata.get("client_name") or metadata.get("client_name", "mcp-client")
|
|
317
|
+
redirect_uris = metadata["redirect_uris"]
|
|
318
|
+
scopes = metadata.get("scope", "") or metadata.get("scopes", [])
|
|
319
|
+
if isinstance(scopes, str):
|
|
320
|
+
scopes = scopes.split()
|
|
321
|
+
|
|
322
|
+
client = OAuthClient(
|
|
323
|
+
client_id=client_id,
|
|
324
|
+
client_secret=client_secret,
|
|
325
|
+
client_name=client_name,
|
|
326
|
+
redirect_uris=redirect_uris,
|
|
327
|
+
scopes=scopes,
|
|
328
|
+
)
|
|
329
|
+
self._clients[client_id] = client
|
|
330
|
+
return client
|
|
331
|
+
|
|
332
|
+
def get(self, client_id: str) -> Optional[OAuthClient]:
|
|
333
|
+
return self._clients.get(client_id)
|
|
334
|
+
|
|
335
|
+
|
|
336
|
+
class OAuthAuthorizationServer:
|
|
337
|
+
"""In-memory OAuth 2.0 authorization server for MCP transports."""
|
|
338
|
+
|
|
339
|
+
def __init__(
|
|
340
|
+
self,
|
|
341
|
+
*,
|
|
342
|
+
default_scopes: Optional[list[str]] = None,
|
|
343
|
+
allow_dynamic_registration: bool = True,
|
|
344
|
+
token_ttl: int = 3600,
|
|
345
|
+
code_ttl: int = 600,
|
|
346
|
+
):
|
|
347
|
+
self.registry = ClientRegistry()
|
|
348
|
+
self.default_scopes = default_scopes or ["mcp:access"]
|
|
349
|
+
self.allow_dynamic_registration = allow_dynamic_registration
|
|
350
|
+
self.token_ttl = token_ttl
|
|
351
|
+
self.code_ttl = code_ttl
|
|
352
|
+
self._codes: Dict[str, Dict[str, Any]] = {}
|
|
353
|
+
self._tokens: Dict[str, Dict[str, Any]] = {}
|
|
354
|
+
|
|
355
|
+
def register_routes(self, app: web.Application) -> None:
|
|
356
|
+
app.router.add_get("/.well-known/oauth-authorization-server", self._handle_discovery)
|
|
357
|
+
app.router.add_post("/oauth/register", self._handle_registration)
|
|
358
|
+
app.router.add_get("/oauth/authorize", self._handle_authorize)
|
|
359
|
+
app.router.add_post("/oauth/token", self._handle_token)
|
|
360
|
+
|
|
361
|
+
def bearer_token_from_header(self, header: Optional[str]) -> Optional[str]:
|
|
362
|
+
if not header:
|
|
363
|
+
return None
|
|
364
|
+
if not header.lower().startswith("bearer "):
|
|
365
|
+
return None
|
|
366
|
+
return header.split(" ", 1)[1].strip()
|
|
367
|
+
|
|
368
|
+
def is_token_valid(self, token: Optional[str]) -> bool:
|
|
369
|
+
if not token:
|
|
370
|
+
return False
|
|
371
|
+
stored = self._tokens.get(token)
|
|
372
|
+
if not stored:
|
|
373
|
+
return False
|
|
374
|
+
return stored.get("expires_at", 0) > _now()
|
|
375
|
+
|
|
376
|
+
def _build_base_url(self, request: web.Request) -> str:
|
|
377
|
+
return f"{request.scheme}://{request.host}"
|
|
378
|
+
|
|
379
|
+
async def _handle_discovery(self, request: web.Request) -> web.Response:
|
|
380
|
+
base_url = self._build_base_url(request)
|
|
381
|
+
metadata = {
|
|
382
|
+
"issuer": base_url,
|
|
383
|
+
"authorization_endpoint": f"{base_url}/oauth/authorize",
|
|
384
|
+
"token_endpoint": f"{base_url}/oauth/token",
|
|
385
|
+
"registration_endpoint": f"{base_url}/oauth/register",
|
|
386
|
+
"response_types_supported": ["code"],
|
|
387
|
+
"grant_types_supported": ["authorization_code"],
|
|
388
|
+
"code_challenge_methods_supported": ["S256"],
|
|
389
|
+
"token_endpoint_auth_methods_supported": ["client_secret_post", "none"],
|
|
390
|
+
"scopes_supported": self.default_scopes,
|
|
391
|
+
}
|
|
392
|
+
return web.json_response(metadata)
|
|
393
|
+
|
|
394
|
+
async def _handle_registration(self, request: web.Request) -> web.Response:
|
|
395
|
+
if not self.allow_dynamic_registration:
|
|
396
|
+
return web.json_response({"error": "registration_not_supported"}, status=400)
|
|
397
|
+
|
|
398
|
+
try:
|
|
399
|
+
data = await request.json()
|
|
400
|
+
except Exception:
|
|
401
|
+
return web.json_response({"error": "invalid_request"}, status=400)
|
|
402
|
+
|
|
403
|
+
try:
|
|
404
|
+
client = self.registry.register(data)
|
|
405
|
+
except Exception as exc: # pragma: no cover - defensive
|
|
406
|
+
return web.json_response(
|
|
407
|
+
{
|
|
408
|
+
"error": "invalid_client_metadata",
|
|
409
|
+
"error_description": str(exc),
|
|
410
|
+
},
|
|
411
|
+
status=400,
|
|
412
|
+
)
|
|
413
|
+
|
|
414
|
+
return web.json_response(
|
|
415
|
+
{
|
|
416
|
+
"client_id": client.client_id,
|
|
417
|
+
"client_secret": client.client_secret,
|
|
418
|
+
"client_id_issued_at": int(client.created_at),
|
|
419
|
+
"client_secret_expires_at": 0,
|
|
420
|
+
"client_name": client.client_name,
|
|
421
|
+
"redirect_uris": client.redirect_uris,
|
|
422
|
+
"scope": " ".join(client.scopes or self.default_scopes),
|
|
423
|
+
},
|
|
424
|
+
status=201,
|
|
425
|
+
)
|
|
426
|
+
|
|
427
|
+
async def _handle_authorize(self, request: web.Request) -> web.StreamResponse:
|
|
428
|
+
params = request.query
|
|
429
|
+
client_id = params.get("client_id")
|
|
430
|
+
redirect_uri = params.get("redirect_uri")
|
|
431
|
+
state = params.get("state")
|
|
432
|
+
response_type = params.get("response_type")
|
|
433
|
+
code_challenge = params.get("code_challenge")
|
|
434
|
+
code_challenge_method = params.get("code_challenge_method", "plain")
|
|
435
|
+
|
|
436
|
+
if response_type != "code":
|
|
437
|
+
return web.Response(status=400, text="unsupported response_type")
|
|
438
|
+
|
|
439
|
+
client = self.registry.get(client_id) if client_id else None
|
|
440
|
+
if not client:
|
|
441
|
+
return web.Response(status=400, text="Invalid Client ID")
|
|
442
|
+
|
|
443
|
+
if redirect_uri not in client.redirect_uris:
|
|
444
|
+
return web.Response(status=400, text="Invalid Redirect URI")
|
|
445
|
+
|
|
446
|
+
scopes = params.get("scope", "").split()
|
|
447
|
+
if not scopes:
|
|
448
|
+
scopes = client.scopes or self.default_scopes
|
|
449
|
+
|
|
450
|
+
code = self._issue_code(
|
|
451
|
+
client_id=client_id,
|
|
452
|
+
redirect_uri=redirect_uri,
|
|
453
|
+
scope=scopes,
|
|
454
|
+
code_challenge=code_challenge,
|
|
455
|
+
code_challenge_method=code_challenge_method,
|
|
456
|
+
)
|
|
457
|
+
|
|
458
|
+
target = f"{redirect_uri}?code={code}"
|
|
459
|
+
if state:
|
|
460
|
+
target += f"&state={state}"
|
|
461
|
+
return web.HTTPFound(target)
|
|
462
|
+
|
|
463
|
+
async def _handle_token(self, request: web.Request) -> web.Response:
|
|
464
|
+
data = await request.post()
|
|
465
|
+
grant_type = data.get("grant_type")
|
|
466
|
+
code = data.get("code")
|
|
467
|
+
client_id = data.get("client_id")
|
|
468
|
+
|
|
469
|
+
if grant_type != "authorization_code":
|
|
470
|
+
return web.json_response({"error": "unsupported_grant_type"}, status=400)
|
|
471
|
+
|
|
472
|
+
record = self._codes.pop(code, None)
|
|
473
|
+
if not record:
|
|
474
|
+
return web.json_response({"error": "invalid_grant"}, status=400)
|
|
475
|
+
|
|
476
|
+
if record["expires_at"] <= _now():
|
|
477
|
+
return web.json_response({"error": "invalid_grant"}, status=400)
|
|
478
|
+
|
|
479
|
+
if client_id != record["client_id"]:
|
|
480
|
+
return web.json_response({"error": "invalid_client"}, status=400)
|
|
481
|
+
|
|
482
|
+
if record.get("code_challenge"):
|
|
483
|
+
verifier = data.get("code_verifier")
|
|
484
|
+
if not verifier:
|
|
485
|
+
return web.json_response({"error": "invalid_request"}, status=400)
|
|
486
|
+
computed = _b64url(hashlib.sha256(verifier.encode()).digest())
|
|
487
|
+
if computed != record["code_challenge"]:
|
|
488
|
+
return web.json_response({"error": "invalid_grant"}, status=400)
|
|
489
|
+
|
|
490
|
+
token_payload = self._issue_token(client_id=client_id, scope=record["scope"])
|
|
491
|
+
return web.json_response(token_payload)
|
|
492
|
+
|
|
493
|
+
def _issue_code(
|
|
494
|
+
self,
|
|
495
|
+
*,
|
|
496
|
+
client_id: str,
|
|
497
|
+
redirect_uri: str,
|
|
498
|
+
scope: list[str],
|
|
499
|
+
code_challenge: Optional[str],
|
|
500
|
+
code_challenge_method: Optional[str],
|
|
501
|
+
) -> str:
|
|
502
|
+
code = f"auth_code_{secrets.token_urlsafe(10)}"
|
|
503
|
+
self._codes[code] = {
|
|
504
|
+
"client_id": client_id,
|
|
505
|
+
"redirect_uri": redirect_uri,
|
|
506
|
+
"scope": scope,
|
|
507
|
+
"code_challenge": code_challenge,
|
|
508
|
+
"code_challenge_method": code_challenge_method,
|
|
509
|
+
"expires_at": _now() + self.code_ttl,
|
|
510
|
+
}
|
|
511
|
+
return code
|
|
512
|
+
|
|
513
|
+
def _issue_token(self, *, client_id: str, scope: list[str]) -> Dict[str, Any]:
|
|
514
|
+
access_token = f"mcp_token_{secrets.token_urlsafe(32)}"
|
|
515
|
+
expires_at = _now() + self.token_ttl
|
|
516
|
+
payload = {
|
|
517
|
+
"access_token": access_token,
|
|
518
|
+
"token_type": "Bearer",
|
|
519
|
+
"expires_in": self.token_ttl,
|
|
520
|
+
"expires_at": expires_at,
|
|
521
|
+
"scope": " ".join(scope or self.default_scopes),
|
|
522
|
+
"client_id": client_id,
|
|
523
|
+
}
|
|
524
|
+
self._tokens[access_token] = payload
|
|
525
|
+
return payload
|
|
526
|
+
|
|
527
|
+
|
|
528
|
+
class TokenStore:
|
|
529
|
+
"""Abstract token store interface."""
|
|
530
|
+
async def get(self, user_id: str, server_name: str) -> Optional[Dict[str, Any]]: ...
|
|
531
|
+
async def set(self, user_id: str, server_name: str, token: Dict[str, Any]) -> None: ...
|
|
532
|
+
async def delete(self, user_id: str, server_name: str) -> None: ...
|
|
533
|
+
|
|
534
|
+
class InMemoryTokenStore(TokenStore):
|
|
535
|
+
"""Simple in-memory token store (not persistent)."""
|
|
536
|
+
def __init__(self):
|
|
537
|
+
self._data = {}
|
|
538
|
+
|
|
539
|
+
async def get(self, user_id, server_name):
|
|
540
|
+
return self._data.get((user_id, server_name))
|
|
541
|
+
|
|
542
|
+
async def set(self, user_id, server_name, token):
|
|
543
|
+
self._data[(user_id, server_name)] = token
|
|
544
|
+
|
|
545
|
+
async def delete(self, user_id, server_name):
|
|
546
|
+
self._data.pop((user_id, server_name), None)
|
|
547
|
+
|
|
548
|
+
class RedisTokenStore(TokenStore):
|
|
549
|
+
"""Redis-based token store."""
|
|
550
|
+
def __init__(self, redis):
|
|
551
|
+
self.redis = redis
|
|
552
|
+
|
|
553
|
+
@staticmethod
|
|
554
|
+
def _key(user_id: str, server_name: str) -> str:
|
|
555
|
+
return f"mcp:oauth:{server_name}:{user_id}"
|
|
556
|
+
|
|
557
|
+
async def get(self, user_id, server_name):
|
|
558
|
+
raw = await self.redis.get(self._key(user_id, server_name))
|
|
559
|
+
return json.loads(raw) if raw else None
|
|
560
|
+
|
|
561
|
+
async def set(self, user_id, server_name, token):
|
|
562
|
+
# store with TTL ~ refresh time + cushion if you want, or none
|
|
563
|
+
await self.redis.set(self._key(user_id, server_name), json.dumps(token))
|
|
564
|
+
|
|
565
|
+
async def delete(self, user_id, server_name):
|
|
566
|
+
await self.redis.delete(self._key(user_id, server_name))
|
|
567
|
+
|
|
568
|
+
|
|
569
|
+
# ---- Simple Dynamic Client Registration ----
|
|
570
|
+
|
|
571
|
+
@dataclass
|
|
572
|
+
class RegisteredClient:
|
|
573
|
+
"""Represents a registered OAuth client."""
|
|
574
|
+
client_id: str
|
|
575
|
+
client_secret: str
|
|
576
|
+
client_name: str
|
|
577
|
+
redirect_uris: list[str]
|
|
578
|
+
scopes: list[str] = field(default_factory=list)
|
|
579
|
+
created_at: float = field(default_factory=time.time)
|
|
580
|
+
|
|
581
|
+
|
|
582
|
+
class ClientRegistry:
|
|
583
|
+
"""
|
|
584
|
+
Minimal in-memory Dynamic Client Registration (RFC 7591) registry.
|
|
585
|
+
Suitable for local development / proxy-style OAuth flows.
|
|
586
|
+
"""
|
|
587
|
+
def __init__(self):
|
|
588
|
+
self._clients: Dict[str, RegisteredClient] = {}
|
|
589
|
+
|
|
590
|
+
def register(self, metadata: Dict[str, Any]) -> RegisteredClient:
|
|
591
|
+
if "redirect_uris" not in metadata:
|
|
592
|
+
raise ValueError("redirect_uris is required for client registration")
|
|
593
|
+
|
|
594
|
+
client_id = metadata.get("client_id") or secrets.token_urlsafe(16)
|
|
595
|
+
client_secret = metadata.get("client_secret") or secrets.token_urlsafe(32)
|
|
596
|
+
client_name = metadata.get("client_name") or metadata.get("client_name", "mcp-client")
|
|
597
|
+
redirect_uris = metadata["redirect_uris"]
|
|
598
|
+
scopes = metadata.get("scope", "") or metadata.get("scopes", [])
|
|
599
|
+
if isinstance(scopes, str):
|
|
600
|
+
scopes = scopes.split()
|
|
601
|
+
|
|
602
|
+
client = RegisteredClient(
|
|
603
|
+
client_id=client_id,
|
|
604
|
+
client_secret=client_secret,
|
|
605
|
+
client_name=client_name,
|
|
606
|
+
redirect_uris=redirect_uris,
|
|
607
|
+
scopes=scopes,
|
|
608
|
+
)
|
|
609
|
+
self._clients[client_id] = client
|
|
610
|
+
return client
|
|
611
|
+
|
|
612
|
+
def get(self, client_id: str) -> Optional[RegisteredClient]:
|
|
613
|
+
return self._clients.get(client_id)
|
|
614
|
+
|
|
615
|
+
|
|
616
|
+
class OAuthManager:
|
|
617
|
+
"""
|
|
618
|
+
Manages Authorization Code + PKCE flow, token storage, auto refresh,
|
|
619
|
+
and supplies a token string for headers.
|
|
620
|
+
"""
|
|
621
|
+
def __init__(
|
|
622
|
+
self,
|
|
623
|
+
*,
|
|
624
|
+
user_id: str,
|
|
625
|
+
server_name: str,
|
|
626
|
+
client_id: str,
|
|
627
|
+
auth_url: str,
|
|
628
|
+
token_url: str,
|
|
629
|
+
scopes: list[str],
|
|
630
|
+
redirect_host: str = "127.0.0.1",
|
|
631
|
+
redirect_port: int = 8765,
|
|
632
|
+
redirect_path: str = "/mcp/oauth/callback",
|
|
633
|
+
token_store: TokenStore,
|
|
634
|
+
client_secret: str | None = None, # if provider requires it
|
|
635
|
+
extra_token_params: dict | None = None,
|
|
636
|
+
http_timeout: float = 15.0,
|
|
637
|
+
):
|
|
638
|
+
self.user_id = user_id
|
|
639
|
+
self.server_name = server_name
|
|
640
|
+
self.client_id = client_id
|
|
641
|
+
self.client_secret = client_secret
|
|
642
|
+
self.auth_url = auth_url
|
|
643
|
+
self.token_url = token_url
|
|
644
|
+
self.scopes = scopes
|
|
645
|
+
self.redirect_host = redirect_host
|
|
646
|
+
self.redirect_port = redirect_port
|
|
647
|
+
self.redirect_path = redirect_path
|
|
648
|
+
self.redirect_uri = f"http://{redirect_host}:{redirect_port}{redirect_path}"
|
|
649
|
+
self.token_store = token_store
|
|
650
|
+
self.extra_token_params = extra_token_params or {}
|
|
651
|
+
self.http_timeout = http_timeout
|
|
652
|
+
|
|
653
|
+
self._state = secrets.token_urlsafe(24)
|
|
654
|
+
self._verifier = _b64url(os.urandom(32))
|
|
655
|
+
self._challenge = _b64url(hashlib.sha256(self._verifier.encode()).digest())
|
|
656
|
+
self._token: dict | None = None
|
|
657
|
+
self._ready = asyncio.Event()
|
|
658
|
+
|
|
659
|
+
def token_supplier(self) -> Optional[str]:
|
|
660
|
+
# Synchronous hook invoked by the HTTP client layer.
|
|
661
|
+
# We return the current access_token if not expired; otherwise None (caller should await ensure_token()).
|
|
662
|
+
if not self._token:
|
|
663
|
+
return None
|
|
664
|
+
# If near expiry (e.g., within 60s), signal refresh needed
|
|
665
|
+
if self._token.get("expires_at") and self._token["expires_at"] - _now() < 60:
|
|
666
|
+
return None
|
|
667
|
+
return self._token.get("access_token")
|
|
668
|
+
|
|
669
|
+
async def ensure_token(self) -> str:
|
|
670
|
+
"""
|
|
671
|
+
Ensures a fresh access token exists:
|
|
672
|
+
- Load from store
|
|
673
|
+
- If expired and refresh_token present -> refresh
|
|
674
|
+
- Else run interactive authorization (PKCE) with local callback
|
|
675
|
+
Returns access_token.
|
|
676
|
+
"""
|
|
677
|
+
# 1) Load cached
|
|
678
|
+
cached = await self.token_store.get(self.user_id, self.server_name)
|
|
679
|
+
if cached:
|
|
680
|
+
self._token = cached
|
|
681
|
+
|
|
682
|
+
# 2) If valid, return
|
|
683
|
+
if self._is_token_valid(self._token):
|
|
684
|
+
return self._token["access_token"]
|
|
685
|
+
|
|
686
|
+
# 3) Try refresh
|
|
687
|
+
if self._token and self._token.get("refresh_token"):
|
|
688
|
+
ok = await self._refresh()
|
|
689
|
+
if ok:
|
|
690
|
+
return self._token["access_token"]
|
|
691
|
+
|
|
692
|
+
# 4) Interactive auth
|
|
693
|
+
await self._authorize_interactive()
|
|
694
|
+
return self._token["access_token"]
|
|
695
|
+
|
|
696
|
+
def _is_token_valid(self, tok: Optional[dict]) -> bool:
|
|
697
|
+
if not tok:
|
|
698
|
+
return False
|
|
699
|
+
exp = tok.get("expires_at")
|
|
700
|
+
return bool(tok.get("access_token")) and exp and exp > _now() + 30
|
|
701
|
+
|
|
702
|
+
async def _authorize_interactive(self):
|
|
703
|
+
app = web.Application()
|
|
704
|
+
app.add_routes([web.get(self.redirect_path, self._handle_callback)])
|
|
705
|
+
|
|
706
|
+
runner = web.AppRunner(app)
|
|
707
|
+
await runner.setup()
|
|
708
|
+
site = web.TCPSite(runner, self.redirect_host, self.redirect_port)
|
|
709
|
+
await site.start()
|
|
710
|
+
|
|
711
|
+
# Build auth URL
|
|
712
|
+
params = {
|
|
713
|
+
"response_type": "code",
|
|
714
|
+
"client_id": self.client_id,
|
|
715
|
+
"redirect_uri": self.redirect_uri,
|
|
716
|
+
"scope": " ".join(self.scopes),
|
|
717
|
+
"state": self._state,
|
|
718
|
+
"code_challenge": self._challenge,
|
|
719
|
+
"code_challenge_method": "S256",
|
|
720
|
+
}
|
|
721
|
+
url = f"{self.auth_url}?{urlencode(params)}"
|
|
722
|
+
|
|
723
|
+
# Print URL (or open in browser)
|
|
724
|
+
print(f"[OAuth] Please authenticate here:\n{url}", flush=True, file=sys.stderr)
|
|
725
|
+
|
|
726
|
+
try:
|
|
727
|
+
await asyncio.wait_for(self._ready.wait(), timeout=300) # 5 minutes
|
|
728
|
+
finally:
|
|
729
|
+
await runner.cleanup()
|
|
730
|
+
|
|
731
|
+
if not self._token:
|
|
732
|
+
raise RuntimeError("OAuth failed: no token captured")
|
|
733
|
+
|
|
734
|
+
await self.token_store.set(self.user_id, self.server_name, self._token)
|
|
735
|
+
|
|
736
|
+
async def _handle_callback(self, request: web.Request):
|
|
737
|
+
if request.query.get("state") != self._state:
|
|
738
|
+
return web.Response(status=400, text="Invalid OAuth state")
|
|
739
|
+
code = request.query.get("code")
|
|
740
|
+
if not code:
|
|
741
|
+
return web.Response(status=400, text="Missing code")
|
|
742
|
+
|
|
743
|
+
# Exchange
|
|
744
|
+
async with ClientSession() as sess:
|
|
745
|
+
data = {
|
|
746
|
+
"grant_type": "authorization_code",
|
|
747
|
+
"code": code,
|
|
748
|
+
"redirect_uri": self.redirect_uri,
|
|
749
|
+
"client_id": self.client_id,
|
|
750
|
+
"code_verifier": self._verifier,
|
|
751
|
+
**self.extra_token_params,
|
|
752
|
+
}
|
|
753
|
+
if self.client_secret:
|
|
754
|
+
data["client_secret"] = self.client_secret
|
|
755
|
+
|
|
756
|
+
async with sess.post(self.token_url, data=data, timeout=self.http_timeout) as resp:
|
|
757
|
+
tok = await resp.json()
|
|
758
|
+
if resp.status != 200:
|
|
759
|
+
return web.Response(status=resp.status, text=str(tok))
|
|
760
|
+
|
|
761
|
+
self._token = self._normalize_token(tok)
|
|
762
|
+
self._ready.set()
|
|
763
|
+
return web.Response(text="Authentication complete. You can close this window.")
|
|
764
|
+
|
|
765
|
+
async def _refresh(self) -> bool:
|
|
766
|
+
async with ClientSession() as sess:
|
|
767
|
+
data = {
|
|
768
|
+
"grant_type": "refresh_token",
|
|
769
|
+
"refresh_token": self._token["refresh_token"],
|
|
770
|
+
"client_id": self.client_id,
|
|
771
|
+
**self.extra_token_params,
|
|
772
|
+
}
|
|
773
|
+
if self.client_secret:
|
|
774
|
+
data["client_secret"] = self.client_secret
|
|
775
|
+
|
|
776
|
+
async with sess.post(self.token_url, data=data, timeout=self.http_timeout) as resp:
|
|
777
|
+
tok = await resp.json()
|
|
778
|
+
if resp.status != 200 or "access_token" not in tok:
|
|
779
|
+
return False
|
|
780
|
+
|
|
781
|
+
self._token = self._normalize_token(tok, prev=self._token)
|
|
782
|
+
await self.token_store.set(self.user_id, self.server_name, self._token)
|
|
783
|
+
return True
|
|
784
|
+
|
|
785
|
+
def _normalize_token(self, tok: Dict[str, Any], prev: Dict[str, Any] | None = None) -> Dict[str, Any]:
|
|
786
|
+
# Expect providers to return: access_token, token_type, expires_in, refresh_token?
|
|
787
|
+
expires_in = int(tok.get("expires_in", 3600))
|
|
788
|
+
out = {
|
|
789
|
+
"access_token": tok["access_token"],
|
|
790
|
+
"token_type": tok.get("token_type", "Bearer"),
|
|
791
|
+
"expires_in": expires_in,
|
|
792
|
+
"expires_at": _now() + expires_in,
|
|
793
|
+
"refresh_token": tok.get("refresh_token") or (prev.get("refresh_token") if prev else None),
|
|
794
|
+
"scope": tok.get("scope"),
|
|
795
|
+
"raw": tok,
|
|
796
|
+
}
|
|
797
|
+
return out
|
|
798
|
+
|
|
799
|
+
|
|
800
|
+
class OAuthRoutesMixin:
|
|
801
|
+
"""Shared OAuth/DCR utilities for HTTP and SSE transports."""
|
|
802
|
+
|
|
803
|
+
def _init_oauth_support(self):
|
|
804
|
+
self.client_registry = ClientRegistry()
|
|
805
|
+
self._auth_codes: Dict[str, Dict[str, Any]] = {}
|
|
806
|
+
|
|
807
|
+
def _oauth_paths(self) -> Dict[str, str]:
|
|
808
|
+
base = self.base_path.rstrip("/")
|
|
809
|
+
base = base if base else ""
|
|
810
|
+
return {
|
|
811
|
+
"discovery": f"{base}/.well-known/oauth-authorization-server",
|
|
812
|
+
"register": f"{base}/oauth/register",
|
|
813
|
+
"authorize": f"{base}/oauth/authorize",
|
|
814
|
+
"token": f"{base}/oauth/token",
|
|
815
|
+
}
|
|
816
|
+
|
|
817
|
+
def _add_oauth_routes(self, router: web.UrlDispatcher):
|
|
818
|
+
paths = self._oauth_paths()
|
|
819
|
+
router.add_get(paths["discovery"], self._handle_discovery)
|
|
820
|
+
router.add_post(paths["register"], self._handle_registration)
|
|
821
|
+
router.add_get(paths["authorize"], self._handle_authorize)
|
|
822
|
+
router.add_post(paths["token"], self._handle_token)
|
|
823
|
+
|
|
824
|
+
async def _handle_discovery(self, request: web.Request) -> web.Response:
|
|
825
|
+
"""RFC 8414: Authorization Server Metadata."""
|
|
826
|
+
base_url = f"{request.scheme}://{request.host}"
|
|
827
|
+
paths = self._oauth_paths()
|
|
828
|
+
metadata = {
|
|
829
|
+
"issuer": base_url,
|
|
830
|
+
"authorization_endpoint": f"{base_url}{paths['authorize']}",
|
|
831
|
+
"token_endpoint": f"{base_url}{paths['token']}",
|
|
832
|
+
"registration_endpoint": f"{base_url}{paths['register']}",
|
|
833
|
+
"response_types_supported": ["code"],
|
|
834
|
+
"grant_types_supported": ["authorization_code"],
|
|
835
|
+
"code_challenge_methods_supported": ["S256"],
|
|
836
|
+
"token_endpoint_auth_methods_supported": ["client_secret_post", "none"],
|
|
837
|
+
}
|
|
838
|
+
return web.json_response(metadata)
|
|
839
|
+
|
|
840
|
+
async def _handle_registration(self, request: web.Request) -> web.Response:
|
|
841
|
+
"""RFC 7591: Dynamic Client Registration."""
|
|
842
|
+
try:
|
|
843
|
+
data = await request.json()
|
|
844
|
+
client = self.client_registry.register(data)
|
|
845
|
+
self.logger.info(
|
|
846
|
+
"Dynamically registered client: %s (%s)",
|
|
847
|
+
client.client_name,
|
|
848
|
+
client.client_id,
|
|
849
|
+
)
|
|
850
|
+
return web.json_response(
|
|
851
|
+
{
|
|
852
|
+
"client_id": client.client_id,
|
|
853
|
+
"client_secret": client.client_secret,
|
|
854
|
+
"client_id_issued_at": int(client.created_at),
|
|
855
|
+
"client_secret_expires_at": 0,
|
|
856
|
+
"client_name": client.client_name,
|
|
857
|
+
"redirect_uris": client.redirect_uris,
|
|
858
|
+
"scope": " ".join(client.scopes),
|
|
859
|
+
},
|
|
860
|
+
status=201,
|
|
861
|
+
)
|
|
862
|
+
except Exception as e: # pylint: disable=broad-except
|
|
863
|
+
self.logger.error(f"DCR Error: {e}")
|
|
864
|
+
return web.json_response(
|
|
865
|
+
{"error": "invalid_client_metadata", "error_description": str(e)},
|
|
866
|
+
status=400,
|
|
867
|
+
)
|
|
868
|
+
|
|
869
|
+
async def _handle_authorize(self, request: web.Request) -> web.Response:
|
|
870
|
+
"""Simplified OAuth 2.0 Authorization Endpoint (auto-approves)."""
|
|
871
|
+
params = request.query
|
|
872
|
+
client_id = params.get("client_id")
|
|
873
|
+
redirect_uri = params.get("redirect_uri")
|
|
874
|
+
state = params.get("state")
|
|
875
|
+
code_challenge = params.get("code_challenge")
|
|
876
|
+
code_challenge_method = params.get("code_challenge_method", "S256")
|
|
877
|
+
|
|
878
|
+
client = self.client_registry.get(client_id) if client_id else None
|
|
879
|
+
if not client:
|
|
880
|
+
return web.Response(text="Invalid Client ID", status=400)
|
|
881
|
+
|
|
882
|
+
if redirect_uri not in client.redirect_uris:
|
|
883
|
+
return web.Response(text="Invalid Redirect URI", status=400)
|
|
884
|
+
|
|
885
|
+
auth_code = f"auth_code_{secrets.token_urlsafe(16)}"
|
|
886
|
+
self._auth_codes[auth_code] = {
|
|
887
|
+
"client_id": client_id,
|
|
888
|
+
"redirect_uri": redirect_uri,
|
|
889
|
+
"scopes": client.scopes,
|
|
890
|
+
"issued_at": time.time(),
|
|
891
|
+
"code_challenge": code_challenge,
|
|
892
|
+
"code_challenge_method": code_challenge_method,
|
|
893
|
+
}
|
|
894
|
+
|
|
895
|
+
target = f"{redirect_uri}?code={auth_code}"
|
|
896
|
+
if state:
|
|
897
|
+
target += f"&state={state}"
|
|
898
|
+
|
|
899
|
+
return web.HTTPFound(target)
|
|
900
|
+
|
|
901
|
+
async def _handle_token(self, request: web.Request) -> web.Response:
|
|
902
|
+
"""OAuth 2.0 Token Endpoint (authorization_code)."""
|
|
903
|
+
data = await request.post()
|
|
904
|
+
grant_type = data.get("grant_type")
|
|
905
|
+
code = data.get("code")
|
|
906
|
+
client_id = data.get("client_id")
|
|
907
|
+
client_secret = data.get("client_secret")
|
|
908
|
+
|
|
909
|
+
if grant_type != "authorization_code":
|
|
910
|
+
return web.json_response({"error": "unsupported_grant_type"}, status=400)
|
|
911
|
+
|
|
912
|
+
record = self._auth_codes.pop(code, None)
|
|
913
|
+
if not record:
|
|
914
|
+
return web.json_response({"error": "invalid_grant"}, status=400)
|
|
915
|
+
|
|
916
|
+
if client_id != record["client_id"]:
|
|
917
|
+
return web.json_response({"error": "invalid_client"}, status=400)
|
|
918
|
+
|
|
919
|
+
# Validate client secret if provided in registry
|
|
920
|
+
client = self.client_registry.get(client_id)
|
|
921
|
+
if client and client.client_secret and client_secret and client_secret != client.client_secret:
|
|
922
|
+
return web.json_response({"error": "invalid_client"}, status=401)
|
|
923
|
+
|
|
924
|
+
access_token = f"mcp_token_{secrets.token_urlsafe(32)}"
|
|
925
|
+
|
|
926
|
+
return web.json_response(
|
|
927
|
+
{
|
|
928
|
+
"access_token": access_token,
|
|
929
|
+
"token_type": "Bearer",
|
|
930
|
+
"expires_in": 3600,
|
|
931
|
+
"scope": " ".join(record.get("scopes") or []),
|
|
932
|
+
}
|
|
933
|
+
)
|