From e913f2f11c65ab04a6f6346c1e33928bd03786fc Mon Sep 17 00:00:00 2001 From: Greg Unger <117244765+gregunger_microsoft@users.noreply.github.com> Date: Thu, 19 Feb 2026 12:46:16 -0800 Subject: [PATCH] MCP Server addition and modification of SimpleChat API to accomodate auth/session handshake. --- .gitignore | 4 + application/external_apps/mcp/.dockerignore | 9 + application/external_apps/mcp/Dockerfile | 17 + application/external_apps/mcp/README.md | 107 ++ .../mcp/deploy_mcp_containerapp.ps1 | 293 +++ application/external_apps/mcp/example.env | 15 + .../external_apps/mcp/prm_metadata.json | 14 + .../external_apps/mcp/requirements.txt | 4 + .../external_apps/mcp/run_mcp_server.ps1 | 36 + .../external_apps/mcp/server_minimal.py | 1692 +++++++++++++++++ application/single_app/app.py | 8 + .../single_app/functions_authentication.py | 42 +- .../route_external_authentication.py | 43 + application/single_app/route_external_prm.py | 27 + .../route_frontend_authentication.py | 69 +- 15 files changed, 2362 insertions(+), 18 deletions(-) create mode 100644 application/external_apps/mcp/.dockerignore create mode 100644 application/external_apps/mcp/Dockerfile create mode 100644 application/external_apps/mcp/README.md create mode 100644 application/external_apps/mcp/deploy_mcp_containerapp.ps1 create mode 100644 application/external_apps/mcp/example.env create mode 100644 application/external_apps/mcp/prm_metadata.json create mode 100644 application/external_apps/mcp/requirements.txt create mode 100644 application/external_apps/mcp/run_mcp_server.ps1 create mode 100644 application/external_apps/mcp/server_minimal.py create mode 100644 application/single_app/route_external_authentication.py create mode 100644 application/single_app/route_external_prm.py diff --git a/.gitignore b/.gitignore index 8a9839df..683d05c1 100644 --- a/.gitignore +++ b/.gitignore @@ -30,6 +30,9 @@ priv-* # temporary files flask_session +# local user workspace +gunger/ + # node modules /node_modules /package-lock.json @@ -42,3 +45,4 @@ flask_session **/my_chart.png **/sample_pie.csv **/sample_stacked_column.csv +application/external_apps/mcp/.env.azure diff --git a/application/external_apps/mcp/.dockerignore b/application/external_apps/mcp/.dockerignore new file mode 100644 index 00000000..808e1b30 --- /dev/null +++ b/application/external_apps/mcp/.dockerignore @@ -0,0 +1,9 @@ +.env +logs/ +__pycache__/ +*.pyc +*.pyo +*.pyd +.venv/ +venv/ +.env.* diff --git a/application/external_apps/mcp/Dockerfile b/application/external_apps/mcp/Dockerfile new file mode 100644 index 00000000..1cac1dd0 --- /dev/null +++ b/application/external_apps/mcp/Dockerfile @@ -0,0 +1,17 @@ +# Dockerfile +FROM python:3.11-slim + +WORKDIR /app + +COPY requirements.txt ./ +RUN pip install --no-cache-dir -r requirements.txt + +COPY . . + +ENV PYTHONUNBUFFERED=1 +ENV FASTMCP_HOST=0.0.0.0 +ENV FASTMCP_PORT=8000 + +EXPOSE 8000 + +CMD ["python", "server_minimal.py"] diff --git a/application/external_apps/mcp/README.md b/application/external_apps/mcp/README.md new file mode 100644 index 00000000..4fef9292 --- /dev/null +++ b/application/external_apps/mcp/README.md @@ -0,0 +1,107 @@ +# SimpleChat MCP Server (FastMCP) + +This MCP server provides **14 tools** for interacting with SimpleChat via the Model Context Protocol (Streamable HTTP transport). + +## Tools + +### Authentication (2 tools) +| Tool | Auth | Description | +|------|------|-------------| +| **login_via_oauth** | None | Starts device-code OAuth login. Returns `user_code` + `verification_uri` for sign-in. | +| **oauth_login_status** | Optional | Returns current login status for both PRM (bearer token) and device-code flows. | + +### User Profile (1 tool) +| Tool | Auth | SimpleChat API | Description | +|------|------|----------------|-------------| +| **show_user_profile** | Required | `POST /external/login` | Returns the authenticated user's profile, roles, and all token claims. | + +### Conversations & Chat (3 tools) +| Tool | Auth | SimpleChat API | Description | +|------|------|----------------|-------------| +| **list_conversations** | Required | `GET /api/get_conversations` | Returns all conversations for the authenticated user. | +| **get_conversation_messages** | Required | `GET /api/get_messages` | Returns messages for a specific conversation. Params: `conversation_id` (required). | +| **send_chat_message** | Required | `POST /api/chat` | Sends a message and returns AI response. Params: `message` (required), `conversation_id` (optional — creates new if empty). | + +### Personal Workspace (2 tools) +| Tool | Auth | SimpleChat API | Description | +|------|------|----------------|-------------| +| **list_personal_documents** | Required | `GET /api/documents` | Returns paginated personal documents. Params: `page`, `page_size`, `search`, `classification`, `author`, `keywords`. | +| **list_personal_prompts** | Required | `GET /api/prompts` | Returns paginated personal prompts. Params: `page`, `page_size`, `search`. | + +### Group Workspaces (3 tools) +| Tool | Auth | SimpleChat API | Description | +|------|------|----------------|-------------| +| **list_group_workspaces** | Required | `GET /api/groups` | Returns paginated group workspaces the user is a member of. Params: `page`, `page_size`, `search`. | +| **list_group_documents** | Required | `GET /api/group_documents` | Returns documents from the user's active group. Params: `page`, `page_size`, `search`, `classification`, `author`, `keywords`. | +| **list_group_prompts** | Required | `GET /api/group_prompts` | Returns prompts from the user's active group. Params: `page`, `page_size`, `search`. | + +### Public Workspaces (3 tools) +| Tool | Auth | SimpleChat API | Description | +|------|------|----------------|-------------| +| **list_public_workspaces** | Required | `GET /api/public_workspaces` | Returns paginated public workspaces. Params: `page`, `page_size`, `search`. | +| **list_public_documents** | Required | `GET /api/public_documents` | Returns documents from the user's active public workspace. Params: `page`, `page_size`, `search`. | +| **list_public_prompts** | Required | `GET /api/public_prompts` | Returns prompts from the user's active public workspace. Params: `page`, `page_size`, `search`. | + +## Prerequisites + +- SimpleChat running locally (default ) +- Entra bearer token issued for the SimpleChat API (audience `api://`) +- The token includes the **ExternalApi** role (or equivalent scope) + +## Setup + +1. Create a `.env` file based on `example.env`. +2. Install dependencies: + - `pip install -r requirements.txt` + +## Run + +- The MCP server always listens on: `http://localhost:8000/mcp` +- Run locally via script: `.\run_mcp_server.ps1` +- Run locally directly: `python server_minimal.py` + +## Environment Variables + +### Required — SimpleChat Connection +- `SIMPLECHAT_BASE_URL` — Base URL for SimpleChat (e.g. `https://localhost:5000`) +- `SIMPLECHAT_VERIFY_SSL` — Whether to verify SSL certificates (`true` or `false`) + +### Required — MCP Server Configuration +- `MCP_REQUIRE_AUTH` — Enable PRM authentication (`true` or `false`) +- `MCP_PRM_METADATA_PATH` — Path to PRM metadata JSON file (e.g. `prm_metadata.json`) +- `MCP_SESSION_TOKEN_TTL_SECONDS` — TTL in seconds for cached MCP session tokens (e.g. `3600`) +- `FASTMCP_HOST` — Bind host (set by `run_mcp_server.ps1` to `0.0.0.0`) +- `FASTMCP_PORT` — Bind port (set by `run_mcp_server.ps1` to `8000`) +- `FASTMCP_SCHEME` — URL scheme for PRM metadata (`http` for local, `https` for production) + +### Required — OAuth / Device-Code Flow +- `OAUTH_AUTHORIZATION_URL` — Entra authorization endpoint +- `OAUTH_TOKEN_URL` — Entra token endpoint +- `OAUTH_DEVICE_CODE_URL` — Entra device-code endpoint (auto-inferred from `OAUTH_TOKEN_URL` if omitted) +- `OAUTH_CLIENT_ID` — App registration client ID +- `OAUTH_CLIENT_SECRET` — App registration client secret (not sent in device-code flow for public clients) +- `OAUTH_SCOPES` — Space-separated scopes (e.g. `api:///ExternalApi User.Read offline_access openid profile`) +- `OAUTH_TIMEOUT_SECONDS` — Device-code polling timeout in seconds (e.g. `900`) + +### Optional +- `OAUTH_REDIRECT_PORT` — Redirect port for auth-code flow (default: `53682`) +- `OAUTH_USE_DEVICE_CODE` — Enable device-code flow (`true` or `false`) +- `OAUTH_OPEN_BROWSER` — Auto-open browser during device-code flow (`false` by default) + +## OAuth Login Notes + +- `login_via_oauth` uses the device-code flow. The tool response includes `user_code` + `verification_uri`, and the server prints a message to stdout. +- Background polling exchanges the device code for an access token, then creates a SimpleChat session via `/external/login`. +- PRM authentication (bearer token from MCP client) is also supported and takes priority over device-code flow. + +## PRM (Protected Resource Metadata) + +The MCP server serves PRM metadata at: +`http://localhost:8000/.well-known/oauth-protected-resource` + +Update `prm_metadata.json` with your Entra tenant, client, and scopes. + +## Deployment + +Use `deploy_mcp_containerapp.ps1` to build and deploy to Azure Container Apps. +The Dockerfile is included for container builds. diff --git a/application/external_apps/mcp/deploy_mcp_containerapp.ps1 b/application/external_apps/mcp/deploy_mcp_containerapp.ps1 new file mode 100644 index 00000000..80a23b9a --- /dev/null +++ b/application/external_apps/mcp/deploy_mcp_containerapp.ps1 @@ -0,0 +1,293 @@ +# deploy_mcp_containerapp.ps1 +# Build and deploy the MCP server to Azure Container Apps + +[CmdletBinding()] +param( + [Parameter(Mandatory = $false)] + [string]$SubscriptionId = "56013a89-2bdc-403e-9f7f-17da3c9d8ab4", + + [Parameter(Mandatory = $false)] + [string]$ResourceGroup = "aaronba-simplechat-rg", + + [Parameter(Mandatory = $false)] + [string]$Location = "", + + [Parameter(Mandatory = $false)] + [string]$ContainerAppName = "gunger-simplechat-mcp", + + [Parameter(Mandatory = $false)] + [string]$EnvironmentName = "aaronba-simplechat-v2-env", + + [Parameter(Mandatory = $false)] + [string]$AcrName = "aaronbasimplechatacr", + + [Parameter(Mandatory = $false)] + [string]$ImageName = "gunger-simplechat-mcp", + + [Parameter(Mandatory = $false)] + [string]$ImageTag = "v1", + + [Parameter(Mandatory = $false)] + [string]$SimpleChatBaseUrl = "", + + [Parameter(Mandatory = $false)] + [bool]$SimpleChatVerifySsl = $true, + + [Parameter(Mandatory = $false)] + [string]$Cpu = "0.5", + + [Parameter(Mandatory = $false)] + [string]$Memory = "1.0Gi" +) + +$ErrorActionPreference = "Stop" + +function Test-AzCliAvailable { + if (-not (Get-Command az -ErrorAction SilentlyContinue)) { + Write-Error "Azure CLI (az) not found. Install it from https://aka.ms/azure-cli and retry." + exit 1 + } +} + +function Invoke-AzCli([string[]]$Arguments) { + return & az @Arguments +} + +function Initialize-AzLogin { + try { + Invoke-AzCli @("account", "show", "--only-show-errors") | Out-Null + } catch { + Write-Host "Logging into Azure..." + Invoke-AzCli @("login", "--use-device-code") | Out-Null + } +} + +function Get-ResourceGroupLocation([string]$rgName) { + $rgLocation = Invoke-AzCli @("group", "show", "--name", $rgName, "--query", "location", "-o", "tsv", "--only-show-errors") + if (-not $rgLocation) { + Write-Error "Resource group not found: $rgName" + exit 1 + } + return $rgLocation +} + +function Get-ValidatedAcrName([string]$requestedName) { + if ($requestedName) { + return $requestedName.ToLower() + } + + Write-Error "ACR name is required. Set -AcrName to an existing registry." + exit 1 +} + +function Get-EnvFileSettings([string]$envFilePath) { + if (-not (Test-Path $envFilePath)) { + Write-Error "Env file not found: $envFilePath" + exit 1 + } + + $settings = @{} + $lines = Get-Content -Path $envFilePath -ErrorAction Stop + foreach ($line in $lines) { + $trimmed = $line.Trim() + if (-not $trimmed -or $trimmed.StartsWith("#")) { + continue + } + $parts = $trimmed.Split("=", 2) + if ($parts.Count -ne 2) { + continue + } + $key = $parts[0].Trim() + $value = $parts[1].Trim() + if ($key) { + $settings[$key] = $value + } + } + + return $settings +} + +function Get-SecretName([string]$keyName) { + return $keyName.ToLower().Replace("_", "-") +} + +function Initialize-ContainerAppExtension { + $extensions = Invoke-AzCli @("extension", "list", "--query", "[].name", "-o", "tsv", "--only-show-errors") + if ($extensions -notcontains "containerapp") { + Write-Host "Installing Azure Container Apps extension..." + Invoke-AzCli @("extension", "add", "--name", "containerapp", "--only-show-errors") | Out-Null + } +} + +Test-AzCliAvailable +Initialize-AzLogin +Initialize-ContainerAppExtension + +Invoke-AzCli @("account", "set", "--subscription", $SubscriptionId, "--only-show-errors") | Out-Null + +if (-not $Location) { + $Location = Get-ResourceGroupLocation -rgName $ResourceGroup +} + +$AcrName = Get-ValidatedAcrName -requestedName $AcrName + +Write-Host "Using resource group: $ResourceGroup" +Write-Host "Location: $Location" +Write-Host "Container App: $ContainerAppName" +Write-Host "Container Apps Environment: $EnvironmentName" +Write-Host "ACR: $AcrName" + +$rgCheck = Invoke-AzCli @("group", "show", "--name", $ResourceGroup, "--only-show-errors", "--query", "name", "-o", "tsv") +if (-not $rgCheck) { + Write-Error "Resource group not found: $ResourceGroup" + exit 1 +} + +$acrExists = Invoke-AzCli @("acr", "show", "--name", $AcrName, "--resource-group", $ResourceGroup, "--only-show-errors", "--query", "name", "-o", "tsv") 2>$null +if (-not $acrExists) { + Write-Error "ACR not found: $AcrName" + exit 1 +} + +$envExists = Invoke-AzCli @("containerapp", "env", "show", "--name", $EnvironmentName, "--resource-group", $ResourceGroup, "--only-show-errors", "--query", "name", "-o", "tsv") 2>$null +if (-not $envExists) { + Write-Error "Container Apps environment not found: $EnvironmentName" + exit 1 +} + +$scriptRoot = Split-Path -Parent $MyInvocation.MyCommand.Path +Set-Location -Path $scriptRoot + +# Prefer .env.azure for cloud deployments, fall back to .env for local values +$envFilePath = Join-Path $scriptRoot ".env.azure" +if (-not (Test-Path $envFilePath)) { + $envFilePath = Join-Path $scriptRoot ".env" + Write-Host "Using local .env (no .env.azure found)" +} else { + Write-Host "Using .env.azure for cloud deployment" +} +$envSettings = Get-EnvFileSettings -envFilePath $envFilePath + +if (-not $SimpleChatBaseUrl) { + if ($envSettings.ContainsKey("SIMPLECHAT_BASE_URL")) { + $SimpleChatBaseUrl = $envSettings["SIMPLECHAT_BASE_URL"] + } else { + Write-Error "SIMPLECHAT_BASE_URL is required. Set it in .env or pass -SimpleChatBaseUrl." + exit 1 + } +} + +if ($envSettings.ContainsKey("SIMPLECHAT_VERIFY_SSL")) { + $SimpleChatVerifySsl = $envSettings["SIMPLECHAT_VERIFY_SSL"].ToLower() -in @("1", "true", "yes", "y", "on") +} + +$imageTag = "${ImageName}:${ImageTag}" +Write-Host "Building image in ACR: $imageTag" +Invoke-AzCli @("acr", "build", "--registry", $AcrName, "--image", $imageTag, ".", "--no-logs", "--only-show-errors") | Out-Null + +$acrLoginServer = Invoke-AzCli @("acr", "show", "--name", $AcrName, "--resource-group", $ResourceGroup, "--query", "loginServer", "-o", "tsv", "--only-show-errors") +$acrCreds = (Invoke-AzCli @("acr", "credential", "show", "--name", $AcrName, "--query", "{username:username,password:passwords[0].value}", "-o", "json", "--only-show-errors")) | ConvertFrom-Json + +$containerImage = "$acrLoginServer/$imageTag" + +$appExists = Invoke-AzCli @("containerapp", "show", "--name", $ContainerAppName, "--resource-group", $ResourceGroup, "--only-show-errors", "--query", "name", "-o", "tsv") 2>$null +if (-not $appExists) { + Write-Host "Creating Container App: $ContainerAppName" + $secrets = @() + $envVars = @() + $hasBaseUrl = $false + $hasVerifySsl = $false + foreach ($entry in $envSettings.GetEnumerator()) { + $secretName = Get-SecretName -keyName $entry.Key + $secrets += "{0}={1}" -f $secretName, $entry.Value + $envVars += "{0}=secretref:{1}" -f $entry.Key, $secretName + if ($entry.Key -eq "SIMPLECHAT_BASE_URL") { + $hasBaseUrl = $true + } + if ($entry.Key -eq "SIMPLECHAT_VERIFY_SSL") { + $hasVerifySsl = $true + } + } + if (-not $hasBaseUrl) { + $envVars += "SIMPLECHAT_BASE_URL=$SimpleChatBaseUrl" + } + if (-not $hasVerifySsl) { + $envVars += "SIMPLECHAT_VERIFY_SSL=$SimpleChatVerifySsl" + } + $envVars += "FASTMCP_HOST=0.0.0.0" + $envVars += "FASTMCP_PORT=8000" + $createArgs = @( + "containerapp", "create", + "--name", $ContainerAppName, + "--resource-group", $ResourceGroup, + "--environment", $EnvironmentName, + "--image", $containerImage, + "--registry-server", $acrLoginServer, + "--registry-username", $acrCreds.username, + "--registry-password", $acrCreds.password, + "--ingress", "external", + "--target-port", "8000", + "--transport", "auto", + "--cpu", $Cpu, + "--memory", $Memory, + "--min-replicas", "1", + "--max-replicas", "1", + "--secrets" + ) + $secrets + @( + "--env-vars" + ) + $envVars + @( + "--only-show-errors" + ) + Invoke-AzCli $createArgs | Out-Null +} else { + Write-Host "Updating Container App: $ContainerAppName" + $secrets = @() + $envVars = @() + $hasBaseUrl = $false + $hasVerifySsl = $false + foreach ($entry in $envSettings.GetEnumerator()) { + $secretName = Get-SecretName -keyName $entry.Key + $secrets += "{0}={1}" -f $secretName, $entry.Value + $envVars += "{0}=secretref:{1}" -f $entry.Key, $secretName + if ($entry.Key -eq "SIMPLECHAT_BASE_URL") { + $hasBaseUrl = $true + } + if ($entry.Key -eq "SIMPLECHAT_VERIFY_SSL") { + $hasVerifySsl = $true + } + } + if (-not $hasBaseUrl) { + $envVars += "SIMPLECHAT_BASE_URL=$SimpleChatBaseUrl" + } + if (-not $hasVerifySsl) { + $envVars += "SIMPLECHAT_VERIFY_SSL=$SimpleChatVerifySsl" + } + $envVars += "FASTMCP_HOST=0.0.0.0" + $envVars += "FASTMCP_PORT=8000" + $secretArgs = @( + "containerapp", "secret", "set", + "--name", $ContainerAppName, + "--resource-group", $ResourceGroup, + "--secrets" + ) + $secrets + @( + "--only-show-errors" + ) + Invoke-AzCli $secretArgs | Out-Null + + $updateArgs = @( + "containerapp", "update", + "--name", $ContainerAppName, + "--resource-group", $ResourceGroup, + "--image", $containerImage, + "--cpu", $Cpu, + "--memory", $Memory, + "--set-env-vars" + ) + $envVars + @( + "--only-show-errors" + ) + Invoke-AzCli $updateArgs | Out-Null +} + +$appUrl = Invoke-AzCli @("containerapp", "show", "--name", $ContainerAppName, "--resource-group", $ResourceGroup, "--query", "properties.configuration.ingress.fqdn", "-o", "tsv", "--only-show-errors") +Write-Host "Deployment complete." +Write-Host "MCP Server URL: https://$appUrl" diff --git a/application/external_apps/mcp/example.env b/application/external_apps/mcp/example.env new file mode 100644 index 00000000..e453552a --- /dev/null +++ b/application/external_apps/mcp/example.env @@ -0,0 +1,15 @@ +SIMPLECHAT_BASE_URL= +SIMPLECHAT_VERIFY_SSL=false +MCP_REQUIRE_AUTH=true +MCP_PRM_METADATA_PATH=prm_metadata.json +MCP_SESSION_TOKEN_TTL_SECONDS=3600 +FASTMCP_SCHEME=http +OAUTH_AUTHORIZATION_URL= +OAUTH_TOKEN_URL= +OAUTH_DEVICE_CODE_URL= +OAUTH_CLIENT_ID= +OAUTH_CLIENT_SECRET= +OAUTH_SCOPES= +OAUTH_REDIRECT_PORT=53682 +OAUTH_USE_DEVICE_CODE=true +OAUTH_TIMEOUT_SECONDS=900 diff --git a/application/external_apps/mcp/prm_metadata.json b/application/external_apps/mcp/prm_metadata.json new file mode 100644 index 00000000..ed682fdb --- /dev/null +++ b/application/external_apps/mcp/prm_metadata.json @@ -0,0 +1,14 @@ +{ + "resource": "http://localhost:8000", + "resource_name": "SimpleChat MCP Server", + "resource_documentation": "https://microsoft.github.io/simplechat/", + "authorization_servers": [ + "https://login.microsoftonline.com/7d887458-fb0d-40bf-adb3-084d875f65db/v2.0" + ], + "scopes_supported": [ + "api://0b8c00b9-4dcd-4959-83be-7a0521ce54ce/.default" + ], + "bearer_methods_supported": [ + "header" + ] +} diff --git a/application/external_apps/mcp/requirements.txt b/application/external_apps/mcp/requirements.txt new file mode 100644 index 00000000..f8f72fd4 --- /dev/null +++ b/application/external_apps/mcp/requirements.txt @@ -0,0 +1,4 @@ +mcp +requests +python-dotenv +msal diff --git a/application/external_apps/mcp/run_mcp_server.ps1 b/application/external_apps/mcp/run_mcp_server.ps1 new file mode 100644 index 00000000..a134bef9 --- /dev/null +++ b/application/external_apps/mcp/run_mcp_server.ps1 @@ -0,0 +1,36 @@ +# run_mcp_server.ps1 +# Start MCP server with streamable HTTP transport + +$ErrorActionPreference = "Continue" + +$mcpRoot = $PSScriptRoot +$appRoot = Resolve-Path (Join-Path $mcpRoot "..\..\single_app") +$venvPython = Join-Path $appRoot ".venv\Scripts\python.exe" + +if (-not (Test-Path $venvPython)) { + Write-Error "Python venv not found: $venvPython" + exit 1 +} + +$logDir = Join-Path $mcpRoot "logs" +if (-not (Test-Path $logDir)) { + New-Item -ItemType Directory -Path $logDir | Out-Null +} +$stdoutLog = Join-Path $logDir "mcp_stdout.log" + +Set-Location -Path $mcpRoot +$env:FASTMCP_HOST = "0.0.0.0" +$env:FASTMCP_PORT = "8000" + +$existingListener = Get-NetTCPConnection -LocalPort 8000 -State Listen -ErrorAction SilentlyContinue +if ($existingListener) { + Write-Host "MCP server already running on port 8000." + exit 0 +} + +# Start detached so the script can exit while the server keeps running. +$stderrLog = Join-Path $logDir "mcp_stderr.log" + +$proc = Start-Process -FilePath $venvPython -ArgumentList @("server_minimal.py") -WorkingDirectory $mcpRoot -RedirectStandardOutput $stdoutLog -RedirectStandardError $stderrLog -WindowStyle Hidden -PassThru + +Write-Host "Started MCP server (PID $($proc.Id)) on http://localhost:8000/mcp" diff --git a/application/external_apps/mcp/server_minimal.py b/application/external_apps/mcp/server_minimal.py new file mode 100644 index 00000000..395dd18f --- /dev/null +++ b/application/external_apps/mcp/server_minimal.py @@ -0,0 +1,1692 @@ +# server_minimal.py +"""Minimal MCP server: device-code OAuth login + SimpleChat integration. + +Goals: +- Only what we need: login via device code and access SimpleChat. +- Read config from environment variables (optionally loaded from application/external_apps/mcp/.env when present). +- Verbose, safe logs (never print secrets). + +Tools exposed: +- login_via_oauth +- oauth_login_status +- show_user_profile +- list_public_workspaces +- list_personal_documents +- list_personal_prompts +- list_group_workspaces +- list_group_documents +- list_group_prompts +- list_public_documents +- list_public_prompts +- list_conversations +- get_conversation_messages +- send_chat_message +""" + +from __future__ import annotations + +import json +import os +import threading +import time +import webbrowser +from pathlib import Path +from typing import Any, Dict, Optional, cast + +import requests +from dotenv import load_dotenv +from mcp.server.fastmcp import Context, FastMCP + + +_DOTENV_PATH = Path(__file__).resolve().parent / ".env" +if _DOTENV_PATH.exists(): + load_dotenv(dotenv_path=_DOTENV_PATH, override=True) + + +def _require_env_value(name: str) -> str: + """Return a required setting from environment variables. + + Local development may provide these via application/external_apps/mcp/.env. + Azure deployments should provide these as App Settings / env vars. + """ + value = os.getenv(name, "").strip() + if not value: + source_hint = f" (loadable from {_DOTENV_PATH} when present)" if _DOTENV_PATH.exists() else "" + raise ValueError(f"Missing required environment variable {name}{source_hint}") + return value + + +def _require_env_int(name: str) -> int: + raw = _require_env_value(name) + try: + return int(raw) + except Exception as exc: + raise ValueError(f"Invalid integer for {name}: {raw!r} ({exc})") + + +def _require_env_bool(name: str) -> bool: + raw = _require_env_value(name).strip().lower() + if raw in ["1", "true", "yes", "y", "on"]: + return True + if raw in ["0", "false", "no", "n", "off"]: + return False + raise ValueError(f"Invalid boolean for {name}: {raw!r} (use true/false)") + + +DEFAULT_REQUIRE_MCP_AUTH = _require_env_bool("MCP_REQUIRE_AUTH") +DEFAULT_PRM_METADATA_PATH = _require_env_value("MCP_PRM_METADATA_PATH").strip() + +MCP_BIND_HOST = _require_env_value("FASTMCP_HOST") +MCP_BIND_PORT = _require_env_int("FASTMCP_PORT") + + +# Pass host=MCP_BIND_HOST so FastMCP does not auto-enable DNS rebinding +# protection with localhost-only allowed_hosts. When host is "0.0.0.0" +# (local and Azure), FastMCP skips the restriction — otherwise the Azure +# Container Apps FQDN in the Host header triggers a 421 Misdirected Request. +_mcp = FastMCP("simplechat-mcp-minimal", host=MCP_BIND_HOST, port=MCP_BIND_PORT) + +# Session cache: bearer_token -> requests.Session +_SESSION_CACHE: Dict[str, requests.Session] = {} +_SESSION_LOCK = threading.Lock() + +# Cache the /external/login payload (contains user + claims) per bearer token. +_LOGIN_PAYLOAD_CACHE: Dict[str, Dict[str, Any]] = {} + +# Cache bearer token per MCP streamable-http session id. This lets the server reuse +# the PRM-provided bearer token across tool calls even if the client doesn't resend it. +_MCP_SESSION_TOKEN_CACHE: Dict[str, Dict[str, Any]] = {} +_MCP_SESSION_TOKEN_TTL_SECONDS = _require_env_int("MCP_SESSION_TOKEN_TTL_SECONDS") + +_STATE_LOCK = threading.Lock() +_STATE: Dict[str, Any] = { + "event": None, + "pending": False, + "error": None, + "auth_flow": None, + "user_code": None, + "verification_uri": None, + "verification_uri_complete": None, + "expires_in": None, + "interval": None, + "access_token": None, + "simplechat_session": None, + "user_profile": None, + "token_claims": None, +} + + +def _env(name: str) -> str: + """Back-compat helper: required value from environment variables (no defaults).""" + return _require_env_value(name) + + +def _extract_bearer_token(auth_header: str) -> Optional[str]: + """Extract bearer token from Authorization header.""" + if not auth_header: + return None + token = auth_header.strip() + if token.lower().startswith("bearer "): + token = token[7:].strip() + return token or None + + +def _get_bearer_token_from_context(ctx: Optional[Context[Any, Any, Any]]) -> Optional[str]: + """Extract bearer token from the current request Context. + + This is the canonical way tools should access PRM-provided auth. + """ + if ctx is None: + return None + + request_context = getattr(ctx, "request_context", None) + request = getattr(request_context, "request", None) if request_context else None + headers = getattr(request, "headers", None) if request else None + if not headers: + return None + + auth_header = headers.get("authorization") + return _extract_bearer_token(auth_header or "") + + +def _get_or_create_simplechat_session(bearer_token: str) -> requests.Session: + """Get cached session or create new one via SimpleChat /external/login.""" + with _SESSION_LOCK: + if bearer_token in _SESSION_CACHE: + print("[MCP] Using cached SimpleChat session for token") + return _SESSION_CACHE[bearer_token] + + simplechat_base_url = _env("SIMPLECHAT_BASE_URL") + simplechat_verify_ssl = _require_env_bool("SIMPLECHAT_VERIFY_SSL") + + print("[MCP] Creating new SimpleChat session via /external/login") + + session = requests.Session() + session.headers.update({"Authorization": f"Bearer {bearer_token}"}) + + # Call SimpleChat /external/login to establish session + login_url = f"{simplechat_base_url}/external/login" + try: + response = session.post(login_url, json={}, verify=simplechat_verify_ssl, timeout=30) + + if response.status_code != 200: + try: + error_details = response.json() + except Exception: + error_details = {"raw": response.text} + raise RuntimeError(f"SimpleChat login failed ({response.status_code}): {error_details}") + + print("[MCP] SimpleChat session created successfully") + + try: + login_payload: Dict[str, Any] = response.json() + except Exception: + login_payload = {} + + # Cache the session + with _SESSION_LOCK: + _SESSION_CACHE[bearer_token] = session + if login_payload: + _LOGIN_PAYLOAD_CACHE[bearer_token] = login_payload + + return session + + except requests.exceptions.RequestException as e: + raise RuntimeError(f"Failed to connect to SimpleChat: {e}") + + +def _get_cached_login_payload(bearer_token: str) -> Optional[Dict[str, Any]]: + with _SESSION_LOCK: + payload = _LOGIN_PAYLOAD_CACHE.get(bearer_token) + return payload if isinstance(payload, dict) else None + + +def _request_device_code(device_code_url: str, client_id: str, scope: str) -> Dict[str, Any]: + print(f"[MCP] Requesting device code from {device_code_url}") + response = requests.post( + device_code_url, + data={"client_id": client_id, "scope": scope}, + timeout=30, + ) + if response.status_code != 200: + try: + payload = response.json() + except Exception: + payload = {"raw": response.text} + raise RuntimeError(f"Device code request failed ({response.status_code}): {payload}") + return response.json() + + +def _infer_device_code_url(token_url: str) -> str: + token_url = (token_url or "").strip() + if token_url.endswith("/oauth2/v2.0/token"): + return token_url.replace("/oauth2/v2.0/token", "/oauth2/v2.0/devicecode") + if token_url.endswith("/oauth2/token"): + return token_url.replace("/oauth2/token", "/oauth2/devicecode") + raise ValueError( + "Cannot infer device-code URL from OAUTH_TOKEN_URL; set OAUTH_DEVICE_CODE_URL." + ) + + +def _poll_device_code_token( + token_url: str, + client_id: str, + client_secret: str, + device_code: str, + timeout_seconds: int, + poll_interval: int, +) -> Dict[str, Any]: + start = time.time() + interval = max(1, poll_interval) + + secret_present = bool(client_secret) + print( + "[MCP] Starting token polling (PUBLIC CLIENT mode - no secret sent). " + f"token_url={token_url} client_secret_in_env={secret_present} (not used for device code flow)" + ) + + attempt = 0 + while time.time() - start < timeout_seconds: + attempt += 1 + data = { + "grant_type": "urn:ietf:params:oauth:grant-type:device_code", + "client_id": client_id, + "device_code": device_code, + } + # NOTE: Device code flow for PUBLIC CLIENTS (like this app) does NOT include client_secret. + # Only confidential clients use client_secret with device code flow. + # The AADSTS7000218 error means the app registration is NOT configured as confidential, + # so we must omit client_secret entirely. + + # Debug: show what we're sending + has_secret_key = "client_secret" in data + print(f"[MCP] Token poll attempt #{attempt}: POST data keys={list(data.keys())} has_client_secret_key={has_secret_key} (public client mode)") + + response = requests.post(token_url, data=data, timeout=30) + print(f"[MCP] Token poll attempt #{attempt}: response status={response.status_code}") + + if response.status_code == 200: + try: + return response.json() + except Exception as exc: + raise RuntimeError(f"Token response was not JSON: {exc}") + + payload: Dict[str, Any] + try: + payload = response.json() + except Exception: + payload = {"raw": response.text} + + error = str(payload.get("error", "")).lower() + if error == "authorization_pending": + time.sleep(interval) + continue + if error == "slow_down": + interval += 5 + time.sleep(interval) + continue + if error == "expired_token": + raise TimeoutError("Device code expired before login completed.") + + raise RuntimeError(f"Device-code token exchange failed ({response.status_code}): {payload}") + + raise TimeoutError("Device code login did not complete within timeout.") + + +def _start_background_poll() -> None: + token_url = _env("OAUTH_TOKEN_URL") + client_id = _env("OAUTH_CLIENT_ID") + client_secret = _env("OAUTH_CLIENT_SECRET") + timeout_seconds = _require_env_int("OAUTH_TIMEOUT_SECONDS") + + with _STATE_LOCK: + device_code = _STATE.get("device_code") + interval = int(_STATE.get("interval") or 5) + event = _STATE.get("event") + + if not device_code or not isinstance(event, threading.Event): + return + + def _worker() -> None: + try: + token_payload = _poll_device_code_token( + token_url=token_url, + client_id=client_id, + client_secret=client_secret, + device_code=device_code, + timeout_seconds=timeout_seconds, + poll_interval=interval, + ) + access_token = token_payload.get("access_token") + if not access_token: + raise RuntimeError(f"Token payload missing access_token: {token_payload}") + + simplechat_base_url = _env("SIMPLECHAT_BASE_URL") + simplechat_verify_ssl = _require_env_bool("SIMPLECHAT_VERIFY_SSL") + + session = requests.Session() + session.headers.update({"Authorization": f"Bearer {access_token}"}) + + print(f"[MCP] Creating SimpleChat session at {simplechat_base_url}/external/login") + print(f"[MCP] Token length: {len(access_token)} chars") + external_login_response = session.post( + f"{simplechat_base_url}/external/login", + verify=simplechat_verify_ssl, + timeout=30 + ) + + if external_login_response.status_code != 200: + print(f"[MCP] SimpleChat /external/login failed: {external_login_response.status_code}") + print(f"[MCP] Response body: {external_login_response.text}") + external_login_response.raise_for_status() + + external_login_payload = external_login_response.json() + print(f"[MCP] SimpleChat session created: {external_login_payload.get('session_created')}") + + user_info = external_login_payload.get("user", {}) + all_claims = external_login_payload.get("claims", {}) + + with _STATE_LOCK: + _STATE["access_token"] = access_token + _STATE["simplechat_session"] = session + _STATE["user_profile"] = user_info + _STATE["token_claims"] = all_claims + _STATE["pending"] = False + _STATE["error"] = None + print("[MCP] Device-code token exchange succeeded.") + except Exception as exc: + error_msg = str(exc) + with _STATE_LOCK: + _STATE["pending"] = False + _STATE["error"] = error_msg + # Clear stale auth fields so status returns "none" instead of leaving device_code around + _STATE["auth_flow"] = None + _STATE["user_code"] = None + _STATE["verification_uri"] = None + _STATE["device_code"] = None + print(f"[MCP] Device-code login failed: {error_msg}") + finally: + event.set() + + thread = threading.Thread(target=_worker, daemon=True) + thread.start() + + +@_mcp.tool(name="login_via_oauth") +def login_via_oauth() -> Dict[str, Any]: + """Start device-code OAuth login. + + Returns device-code instructions (user_code, verification_uri). + """ + client_id = _env("OAUTH_CLIENT_ID") + scope = _env("OAUTH_SCOPES") + + token_url = _env("OAUTH_TOKEN_URL") + explicit_device_code_url = os.getenv("OAUTH_DEVICE_CODE_URL", "").strip() + device_code_url = explicit_device_code_url or _infer_device_code_url(token_url) + + device_payload = _request_device_code(device_code_url, client_id, scope) + + device_code = device_payload.get("device_code") + user_code = device_payload.get("user_code") + verification_uri = device_payload.get("verification_uri") + verification_uri_complete = device_payload.get("verification_uri_complete") + expires_in = device_payload.get("expires_in") + interval = int(device_payload.get("interval", 5)) + + if not device_code or not user_code or not verification_uri: + raise RuntimeError(f"Device code response missing required fields: {device_payload}") + + # Never auto-open a browser unless explicitly enabled. + open_browser_raw = os.getenv("OAUTH_OPEN_BROWSER", "false").strip().lower() + open_browser = open_browser_raw in ["1", "true", "yes", "y", "on"] + if open_browser: + try: + webbrowser.open(verification_uri_complete or verification_uri) + except Exception as exc: + print(f"[MCP] webbrowser.open failed: {exc}") + + with _STATE_LOCK: + event = threading.Event() + _STATE.update( + { + "event": event, + "pending": True, + "error": None, + "auth_flow": "device_code", + "device_code": device_code, + "user_code": user_code, + "verification_uri": verification_uri, + "verification_uri_complete": verification_uri_complete, + "expires_in": expires_in, + "interval": interval, + "access_token": None, + } + ) + + _start_background_poll() + + message = ( + f"Go to {verification_uri} and enter this code: {user_code}. " + "Session will be created automatically after you finish sign-in." + ) + print(f"[MCP] {message}") + + return { + "auth_flow": "device_code", + "user_code": user_code, + "verification_uri": verification_uri, + "verification_uri_complete": verification_uri_complete, + "expires_in": expires_in, + "interval": interval, + "message": message, + } + + +@_mcp.tool(name="oauth_login_status") +def oauth_login_status(ctx: Optional[Context[Any, Any, Any]] = None) -> Dict[str, Any]: + """Return current login status. + + This is intentionally not tied to a specific login mechanism. + + - If the call is authenticated via PRM (bearer token), it reports PRM status. + - If a device-code flow was used, it also reports the device-code status. + """ + + bearer_token = _get_bearer_token_from_context(ctx) if ctx is not None else None + has_prm_bearer_token = bool(bearer_token) + + simplechat_base_url_present = bool(os.getenv("SIMPLECHAT_BASE_URL", "").strip()) + simplechat_verify_ssl_present = bool(os.getenv("SIMPLECHAT_VERIFY_SSL", "").strip()) + + prm_session_ok: Optional[bool] = None + prm_error: Optional[str] = None + prm_user: Dict[str, Any] = {} + + if has_prm_bearer_token: + if simplechat_base_url_present and simplechat_verify_ssl_present: + try: + _get_or_create_simplechat_session(cast(str, bearer_token)) + prm_session_ok = True + payload = _get_cached_login_payload(cast(str, bearer_token)) or {} + user = payload.get("user") + if isinstance(user, dict): + prm_user = { + "userId": user.get("userId"), + "displayName": user.get("displayName"), + "email": user.get("email"), + } + except Exception as exc: + prm_session_ok = False + prm_error = str(exc) + else: + prm_session_ok = None + missing: list[str] = [] + if not simplechat_base_url_present: + missing.append("SIMPLECHAT_BASE_URL") + if not simplechat_verify_ssl_present: + missing.append("SIMPLECHAT_VERIFY_SSL") + prm_error = f"Cannot validate PRM session; missing env vars: {', '.join(missing)}" + + with _STATE_LOCK: + pending = bool(_STATE.get("pending")) + device_code_has_token = bool(_STATE.get("access_token")) + device_code_error = _STATE.get("error") + device_code_status = "pending" if pending else ("complete" if device_code_has_token else "none") + + logged_in = (prm_session_ok is True) or device_code_has_token + + result: Dict[str, Any] = { + "logged_in": logged_in, + "prm": { + "has_bearer_token": has_prm_bearer_token, + "session_ok": prm_session_ok, + "error": prm_error, + "user": prm_user, + }, + "device_code": { + "status": device_code_status, + "pending": pending, + "error": device_code_error, + "auth_flow": _STATE.get("auth_flow"), + "user_code": _STATE.get("user_code"), + "verification_uri": _STATE.get("verification_uri"), + "verification_uri_complete": _STATE.get("verification_uri_complete"), + "expires_in": _STATE.get("expires_in"), + "interval": _STATE.get("interval"), + }, + "dotenv_path": str(_DOTENV_PATH), + "dotenv_found": _DOTENV_PATH.exists(), + } + + return result + + +@_mcp.tool(name="show_user_profile") +def show_user_profile(ctx: Context[Any, Any, Any]) -> Dict[str, Any]: + """Return SimpleChat user profile from the PRM bearer token. + + This tool must never initiate its own auth flow. It relies exclusively on + PRM/MCP client authentication and reuses that bearer token. + """ + bearer_token = _get_bearer_token_from_context(ctx) + auth_source = "prm" if bearer_token else "device_code" + if not bearer_token: + with _STATE_LOCK: + access_token = _STATE.get("access_token") + if isinstance(access_token, str) and access_token.strip(): + bearer_token = access_token.strip() + else: + return { + "success": False, + "error": "not_authenticated", + "message": "Not authenticated. Provide a PRM bearer token or complete device-code login.", + } + + try: + _get_or_create_simplechat_session(bearer_token) + except Exception as e: + return { + "success": False, + "error": "session_creation_failed", + "message": str(e), + } + + payload = _get_cached_login_payload(bearer_token) or {} + user = payload.get("user") + claims = payload.get("claims") + + # If SimpleChat didn't return claims (older deployed version), decode + # the JWT locally (without signature verification — already validated + # by SimpleChat during /external/login). + if not isinstance(claims, dict) or not claims: + try: + import jwt as pyjwt + claims = pyjwt.decode(bearer_token, options={"verify_signature": False}) + except Exception: + claims = {} + + if not isinstance(user, dict): + user = {} + user = cast(Dict[str, Any], user) + if not isinstance(claims, dict): + claims = {} + claims = cast(Dict[str, Any], claims) + + # Extract roles and delegated permissions with clear labels. + # Collect all roles from both the "roles" claim and the "scp" claim. + # A user can have multiple roles across both claims. + roles_from_claim = claims.get("roles", []) + if not isinstance(roles_from_claim, list): + roles_from_claim = [roles_from_claim] if roles_from_claim else [] + scp_raw = claims.get("scp", "") + roles_from_scp = scp_raw.split() if isinstance(scp_raw, str) and scp_raw.strip() else [] + # Merge and deduplicate, preserving order. + seen = set() + all_roles = [] + for r in roles_from_claim + roles_from_scp: + if r not in seen: + seen.add(r) + all_roles.append(r) + + return { + "auth_source": auth_source, + "userId": user.get("userId"), + "displayName": user.get("displayName"), + "email": user.get("email"), + "upn": claims.get("upn"), + "roles": all_roles, + "all_token_claims": claims, + } + + +@_mcp.tool(name="list_public_workspaces") +def list_public_workspaces( + ctx: Context[Any, Any, Any], + page: int = 1, + page_size: int = 25, + search: Optional[str] = None +) -> Dict[str, Any]: + """Return the authenticated user's public workspaces from SimpleChat. + + Uses the bearer token from PRM authentication to create a SimpleChat session. + """ + bearer_token = _get_bearer_token_from_context(ctx) + auth_source = "prm" if bearer_token else "device_code" + if not bearer_token: + with _STATE_LOCK: + access_token = _STATE.get("access_token") + if isinstance(access_token, str) and access_token.strip(): + bearer_token = access_token.strip() + else: + return { + "success": False, + "error": "not_authenticated", + "message": "Not authenticated. Provide a PRM bearer token or complete device-code login.", + } + + print(f"[MCP] Using token from {auth_source} authentication") + try: + session = _get_or_create_simplechat_session(bearer_token) + except Exception as e: + return { + "success": False, + "error": "session_creation_failed", + "message": str(e), + "hint": "Ensure your bearer token is valid and SimpleChat is accessible.", + } + + simplechat_base_url = _env("SIMPLECHAT_BASE_URL") + simplechat_verify_ssl = _require_env_bool("SIMPLECHAT_VERIFY_SSL") + + params: Dict[str, Any] = { + "page": page, + "page_size": page_size + } + if search: + params["search"] = search + + url = f"{simplechat_base_url}/api/public_workspaces" + print(f"[MCP] Calling SimpleChat GET {url}") + response = session.get( + url, + params=params, + verify=simplechat_verify_ssl, + timeout=30 + ) + + if response.status_code != 200: + try: + details = response.json() + except Exception: + details = {"raw": response.text} + return { + "error": "simplechat_request_failed", + "status_code": response.status_code, + "details": details, + "hint": "If this is 401/403, ensure your PRM bearer token has SimpleChat API access.", + } + + result = response.json() + if isinstance(result, dict): + result.setdefault("auth_source", auth_source) + return result + + +@_mcp.tool(name="list_personal_documents") +def list_personal_documents( + ctx: Context[Any, Any, Any], + page: int = 1, + page_size: int = 10, + search: Optional[str] = None, + classification: Optional[str] = None, + author: Optional[str] = None, + keywords: Optional[str] = None, +) -> Dict[str, Any]: + """Return the authenticated user's personal workspace documents from SimpleChat. + + Lists documents the user has uploaded or that have been shared with them. + + Args: + page: Page number (default 1). + page_size: Items per page (default 10). + search: Search by file name or title (case-insensitive substring match). + classification: Filter by document classification. Use "none" for unclassified. + author: Filter by author name (substring match). + keywords: Filter by keyword (substring match). + + Returns a paginated list of documents with metadata. + """ + bearer_token = _get_bearer_token_from_context(ctx) + auth_source = "prm" if bearer_token else "device_code" + if not bearer_token: + with _STATE_LOCK: + access_token = _STATE.get("access_token") + if isinstance(access_token, str) and access_token.strip(): + bearer_token = access_token.strip() + else: + return { + "success": False, + "error": "not_authenticated", + "message": "Not authenticated. Provide a PRM bearer token or complete device-code login.", + } + + try: + session = _get_or_create_simplechat_session(bearer_token) + except Exception as e: + return { + "success": False, + "error": "session_creation_failed", + "message": str(e), + "hint": "Ensure your bearer token is valid and SimpleChat is accessible.", + } + + simplechat_base_url = _env("SIMPLECHAT_BASE_URL") + simplechat_verify_ssl = _require_env_bool("SIMPLECHAT_VERIFY_SSL") + + params: Dict[str, Any] = { + "page": page, + "page_size": page_size, + } + if search: + params["search"] = search + if classification: + params["classification"] = classification + if author: + params["author"] = author + if keywords: + params["keywords"] = keywords + + url = f"{simplechat_base_url}/api/documents" + print(f"[MCP] Calling SimpleChat GET {url}") + response = session.get( + url, + params=params, + verify=simplechat_verify_ssl, + timeout=30, + ) + + if response.status_code != 200: + try: + details = response.json() + except Exception: + details = {"raw": response.text} + return { + "error": "simplechat_request_failed", + "status_code": response.status_code, + "details": details, + "hint": "If this is 401/403, ensure your PRM bearer token has SimpleChat API access.", + } + + result = response.json() + if isinstance(result, dict): + result.setdefault("auth_source", auth_source) + return result + + +@_mcp.tool(name="list_personal_prompts") +def list_personal_prompts( + ctx: Context[Any, Any, Any], + page: int = 1, + page_size: int = 10, + search: Optional[str] = None, +) -> Dict[str, Any]: + """Return the authenticated user's personal prompts from SimpleChat. + + Lists prompts the user has created in their personal workspace. + + Args: + page: Page number (default 1). + page_size: Items per page (default 10). + search: Search by prompt name (case-insensitive substring match). + + Returns a paginated list of prompts with name, content, and metadata. + """ + bearer_token = _get_bearer_token_from_context(ctx) + auth_source = "prm" if bearer_token else "device_code" + if not bearer_token: + with _STATE_LOCK: + access_token = _STATE.get("access_token") + if isinstance(access_token, str) and access_token.strip(): + bearer_token = access_token.strip() + else: + return { + "success": False, + "error": "not_authenticated", + "message": "Not authenticated. Provide a PRM bearer token or complete device-code login.", + } + + try: + session = _get_or_create_simplechat_session(bearer_token) + except Exception as e: + return { + "success": False, + "error": "session_creation_failed", + "message": str(e), + "hint": "Ensure your bearer token is valid and SimpleChat is accessible.", + } + + simplechat_base_url = _env("SIMPLECHAT_BASE_URL") + simplechat_verify_ssl = _require_env_bool("SIMPLECHAT_VERIFY_SSL") + + params: Dict[str, Any] = { + "page": page, + "page_size": page_size, + } + if search: + params["search"] = search + + url = f"{simplechat_base_url}/api/prompts" + print(f"[MCP] Calling SimpleChat GET {url}") + response = session.get( + url, + params=params, + verify=simplechat_verify_ssl, + timeout=30, + ) + + if response.status_code != 200: + try: + details = response.json() + except Exception: + details = {"raw": response.text} + return { + "error": "simplechat_request_failed", + "status_code": response.status_code, + "details": details, + "hint": "If this is 401/403, ensure your PRM bearer token has SimpleChat API access.", + } + + result = response.json() + if isinstance(result, dict): + result.setdefault("auth_source", auth_source) + return result + + +@_mcp.tool(name="list_group_workspaces") +def list_group_workspaces( + ctx: Context[Any, Any, Any], + page: int = 1, + page_size: int = 10, + search: Optional[str] = None, +) -> Dict[str, Any]: + """Return the authenticated user's group workspaces from SimpleChat. + + Lists groups the user is a member of (Owner, Admin, or Member). + + Args: + page: Page number (default 1). + page_size: Items per page (default 10). + search: Search by group name or description (case-insensitive substring match). + + Returns a paginated list of groups with id, name, description, userRole, status, and isActive flag. + """ + bearer_token = _get_bearer_token_from_context(ctx) + auth_source = "prm" if bearer_token else "device_code" + if not bearer_token: + with _STATE_LOCK: + access_token = _STATE.get("access_token") + if isinstance(access_token, str) and access_token.strip(): + bearer_token = access_token.strip() + else: + return { + "success": False, + "error": "not_authenticated", + "message": "Not authenticated. Provide a PRM bearer token or complete device-code login.", + } + + try: + session = _get_or_create_simplechat_session(bearer_token) + except Exception as e: + return { + "success": False, + "error": "session_creation_failed", + "message": str(e), + "hint": "Ensure your bearer token is valid and SimpleChat is accessible.", + } + + simplechat_base_url = _env("SIMPLECHAT_BASE_URL") + simplechat_verify_ssl = _require_env_bool("SIMPLECHAT_VERIFY_SSL") + + params: Dict[str, Any] = { + "page": page, + "page_size": page_size, + } + if search: + params["search"] = search + + url = f"{simplechat_base_url}/api/groups" + print(f"[MCP] Calling SimpleChat GET {url}") + response = session.get( + url, + params=params, + verify=simplechat_verify_ssl, + timeout=30, + ) + + if response.status_code != 200: + try: + details = response.json() + except Exception: + details = {"raw": response.text} + return { + "error": "simplechat_request_failed", + "status_code": response.status_code, + "details": details, + "hint": "If this is 401/403, ensure your PRM bearer token has SimpleChat API access.", + } + + result = response.json() + if isinstance(result, dict): + result.setdefault("auth_source", auth_source) + return result + + +@_mcp.tool(name="list_group_documents") +def list_group_documents( + ctx: Context[Any, Any, Any], + page: int = 1, + page_size: int = 10, + search: Optional[str] = None, + classification: Optional[str] = None, + author: Optional[str] = None, + keywords: Optional[str] = None, +) -> Dict[str, Any]: + """Return documents from the user's active group workspace in SimpleChat. + + Lists documents uploaded to the currently active group. The active group + is determined by the user's settings (activeGroupOid). + + Args: + page: Page number (default 1). + page_size: Items per page (default 10). + search: Search by file name or title (case-insensitive substring match). + classification: Filter by document classification. Use "none" for unclassified. + author: Filter by author name (substring match). + keywords: Filter by keyword (substring match). + + Returns a paginated list of group documents with metadata. + """ + bearer_token = _get_bearer_token_from_context(ctx) + auth_source = "prm" if bearer_token else "device_code" + if not bearer_token: + with _STATE_LOCK: + access_token = _STATE.get("access_token") + if isinstance(access_token, str) and access_token.strip(): + bearer_token = access_token.strip() + else: + return { + "success": False, + "error": "not_authenticated", + "message": "Not authenticated. Provide a PRM bearer token or complete device-code login.", + } + + try: + session = _get_or_create_simplechat_session(bearer_token) + except Exception as e: + return { + "success": False, + "error": "session_creation_failed", + "message": str(e), + "hint": "Ensure your bearer token is valid and SimpleChat is accessible.", + } + + simplechat_base_url = _env("SIMPLECHAT_BASE_URL") + simplechat_verify_ssl = _require_env_bool("SIMPLECHAT_VERIFY_SSL") + + params: Dict[str, Any] = { + "page": page, + "page_size": page_size, + } + if search: + params["search"] = search + if classification: + params["classification"] = classification + if author: + params["author"] = author + if keywords: + params["keywords"] = keywords + + url = f"{simplechat_base_url}/api/group_documents" + print(f"[MCP] Calling SimpleChat GET {url}") + response = session.get( + url, + params=params, + verify=simplechat_verify_ssl, + timeout=30, + ) + + if response.status_code != 200: + try: + details = response.json() + except Exception: + details = {"raw": response.text} + return { + "error": "simplechat_request_failed", + "status_code": response.status_code, + "details": details, + "hint": "If this is 401/403, ensure your PRM bearer token has SimpleChat API access. If 400, ensure you have an active group selected.", + } + + result = response.json() + if isinstance(result, dict): + result.setdefault("auth_source", auth_source) + return result + + +@_mcp.tool(name="list_group_prompts") +def list_group_prompts( + ctx: Context[Any, Any, Any], + page: int = 1, + page_size: int = 10, + search: Optional[str] = None, +) -> Dict[str, Any]: + """Return prompts from the user's active group workspace in SimpleChat. + + Lists prompts created in the currently active group. The active group + is determined by the user's settings (activeGroupOid). + + Args: + page: Page number (default 1). + page_size: Items per page (default 10). + search: Search by prompt name (case-insensitive substring match). + + Returns a paginated list of group prompts with name, content, and metadata. + """ + bearer_token = _get_bearer_token_from_context(ctx) + auth_source = "prm" if bearer_token else "device_code" + if not bearer_token: + with _STATE_LOCK: + access_token = _STATE.get("access_token") + if isinstance(access_token, str) and access_token.strip(): + bearer_token = access_token.strip() + else: + return { + "success": False, + "error": "not_authenticated", + "message": "Not authenticated. Provide a PRM bearer token or complete device-code login.", + } + + try: + session = _get_or_create_simplechat_session(bearer_token) + except Exception as e: + return { + "success": False, + "error": "session_creation_failed", + "message": str(e), + "hint": "Ensure your bearer token is valid and SimpleChat is accessible.", + } + + simplechat_base_url = _env("SIMPLECHAT_BASE_URL") + simplechat_verify_ssl = _require_env_bool("SIMPLECHAT_VERIFY_SSL") + + params: Dict[str, Any] = { + "page": page, + "page_size": page_size, + } + if search: + params["search"] = search + + url = f"{simplechat_base_url}/api/group_prompts" + print(f"[MCP] Calling SimpleChat GET {url}") + response = session.get( + url, + params=params, + verify=simplechat_verify_ssl, + timeout=30, + ) + + if response.status_code != 200: + try: + details = response.json() + except Exception: + details = {"raw": response.text} + return { + "error": "simplechat_request_failed", + "status_code": response.status_code, + "details": details, + "hint": "If this is 401/403, ensure your PRM bearer token has SimpleChat API access. If 400, ensure you have an active group selected.", + } + + result = response.json() + if isinstance(result, dict): + result.setdefault("auth_source", auth_source) + return result + + +@_mcp.tool(name="list_public_documents") +def list_public_documents( + ctx: Context[Any, Any, Any], + page: int = 1, + page_size: int = 10, + search: Optional[str] = None, +) -> Dict[str, Any]: + """Return documents from the user's active public workspace in SimpleChat. + + Lists documents uploaded to the currently active public workspace. The active + workspace is determined by the user's settings (activePublicWorkspaceOid). + + Args: + page: Page number (default 1). + page_size: Items per page (default 10). + search: Search by file name or title (case-insensitive substring match). + + Returns a paginated list of public workspace documents with metadata. + """ + bearer_token = _get_bearer_token_from_context(ctx) + auth_source = "prm" if bearer_token else "device_code" + if not bearer_token: + with _STATE_LOCK: + access_token = _STATE.get("access_token") + if isinstance(access_token, str) and access_token.strip(): + bearer_token = access_token.strip() + else: + return { + "success": False, + "error": "not_authenticated", + "message": "Not authenticated. Provide a PRM bearer token or complete device-code login.", + } + + try: + session = _get_or_create_simplechat_session(bearer_token) + except Exception as e: + return { + "success": False, + "error": "session_creation_failed", + "message": str(e), + "hint": "Ensure your bearer token is valid and SimpleChat is accessible.", + } + + simplechat_base_url = _env("SIMPLECHAT_BASE_URL") + simplechat_verify_ssl = _require_env_bool("SIMPLECHAT_VERIFY_SSL") + + params: Dict[str, Any] = { + "page": page, + "page_size": page_size, + } + if search: + params["search"] = search + + url = f"{simplechat_base_url}/api/public_documents" + print(f"[MCP] Calling SimpleChat GET {url}") + response = session.get( + url, + params=params, + verify=simplechat_verify_ssl, + timeout=30, + ) + + if response.status_code != 200: + try: + details = response.json() + except Exception: + details = {"raw": response.text} + return { + "error": "simplechat_request_failed", + "status_code": response.status_code, + "details": details, + "hint": "If this is 401/403, ensure your PRM bearer token has SimpleChat API access. If 400, ensure you have an active public workspace selected.", + } + + result = response.json() + if isinstance(result, dict): + result.setdefault("auth_source", auth_source) + return result + + +@_mcp.tool(name="list_public_prompts") +def list_public_prompts( + ctx: Context[Any, Any, Any], + page: int = 1, + page_size: int = 10, + search: Optional[str] = None, +) -> Dict[str, Any]: + """Return prompts from the user's active public workspace in SimpleChat. + + Lists prompts created in the currently active public workspace. The active + workspace is determined by the user's settings (activePublicWorkspaceOid). + + Args: + page: Page number (default 1). + page_size: Items per page (default 10). + search: Search by prompt name (case-insensitive substring match). + + Returns a paginated list of public workspace prompts with name, content, and metadata. + """ + bearer_token = _get_bearer_token_from_context(ctx) + auth_source = "prm" if bearer_token else "device_code" + if not bearer_token: + with _STATE_LOCK: + access_token = _STATE.get("access_token") + if isinstance(access_token, str) and access_token.strip(): + bearer_token = access_token.strip() + else: + return { + "success": False, + "error": "not_authenticated", + "message": "Not authenticated. Provide a PRM bearer token or complete device-code login.", + } + + try: + session = _get_or_create_simplechat_session(bearer_token) + except Exception as e: + return { + "success": False, + "error": "session_creation_failed", + "message": str(e), + "hint": "Ensure your bearer token is valid and SimpleChat is accessible.", + } + + simplechat_base_url = _env("SIMPLECHAT_BASE_URL") + simplechat_verify_ssl = _require_env_bool("SIMPLECHAT_VERIFY_SSL") + + params: Dict[str, Any] = { + "page": page, + "page_size": page_size, + } + if search: + params["search"] = search + + url = f"{simplechat_base_url}/api/public_prompts" + print(f"[MCP] Calling SimpleChat GET {url}") + response = session.get( + url, + params=params, + verify=simplechat_verify_ssl, + timeout=30, + ) + + if response.status_code != 200: + try: + details = response.json() + except Exception: + details = {"raw": response.text} + return { + "error": "simplechat_request_failed", + "status_code": response.status_code, + "details": details, + "hint": "If this is 401/403, ensure your PRM bearer token has SimpleChat API access. If 400, ensure you have an active public workspace selected.", + } + + result = response.json() + if isinstance(result, dict): + result.setdefault("auth_source", auth_source) + return result + + +@_mcp.tool(name="list_conversations") +def list_conversations( + ctx: Context[Any, Any, Any], +) -> Dict[str, Any]: + """Return the authenticated user's conversations (chats) from SimpleChat. + + Returns a list of all conversations including id, title, last_updated, + tags, classification, and pinned/hidden status. + """ + bearer_token = _get_bearer_token_from_context(ctx) + auth_source = "prm" if bearer_token else "device_code" + if not bearer_token: + with _STATE_LOCK: + access_token = _STATE.get("access_token") + if isinstance(access_token, str) and access_token.strip(): + bearer_token = access_token.strip() + else: + return { + "success": False, + "error": "not_authenticated", + "message": "Not authenticated. Provide a PRM bearer token or complete device-code login.", + } + + try: + session = _get_or_create_simplechat_session(bearer_token) + except Exception as e: + return { + "success": False, + "error": "session_creation_failed", + "message": str(e), + "hint": "Ensure your bearer token is valid and SimpleChat is accessible.", + } + + simplechat_base_url = _env("SIMPLECHAT_BASE_URL") + simplechat_verify_ssl = _require_env_bool("SIMPLECHAT_VERIFY_SSL") + + url = f"{simplechat_base_url}/api/get_conversations" + print(f"[MCP] Calling SimpleChat GET {url}") + response = session.get(url, verify=simplechat_verify_ssl, timeout=30) + + if response.status_code != 200: + try: + details = response.json() + except Exception: + details = {"raw": response.text} + return { + "error": "simplechat_request_failed", + "status_code": response.status_code, + "details": details, + "hint": "If this is 401/403, ensure your PRM bearer token has SimpleChat API access.", + } + + result = response.json() + if isinstance(result, dict): + result.setdefault("auth_source", auth_source) + return result + + +@_mcp.tool(name="get_conversation_messages") +def get_conversation_messages( + ctx: Context[Any, Any, Any], + conversation_id: str = "", +) -> Dict[str, Any]: + """Return messages for a specific conversation from SimpleChat. + + Args: + conversation_id: The UUID of the conversation to retrieve messages from. + + Returns a list of messages with role, content, timestamp, and metadata. + """ + if not conversation_id or not conversation_id.strip(): + return { + "success": False, + "error": "missing_parameter", + "message": "conversation_id is required.", + } + + bearer_token = _get_bearer_token_from_context(ctx) + auth_source = "prm" if bearer_token else "device_code" + if not bearer_token: + with _STATE_LOCK: + access_token = _STATE.get("access_token") + if isinstance(access_token, str) and access_token.strip(): + bearer_token = access_token.strip() + else: + return { + "success": False, + "error": "not_authenticated", + "message": "Not authenticated. Provide a PRM bearer token or complete device-code login.", + } + + try: + session = _get_or_create_simplechat_session(bearer_token) + except Exception as e: + return { + "success": False, + "error": "session_creation_failed", + "message": str(e), + "hint": "Ensure your bearer token is valid and SimpleChat is accessible.", + } + + simplechat_base_url = _env("SIMPLECHAT_BASE_URL") + simplechat_verify_ssl = _require_env_bool("SIMPLECHAT_VERIFY_SSL") + + url = f"{simplechat_base_url}/api/get_messages" + print(f"[MCP] Calling SimpleChat GET {url}?conversation_id={conversation_id}") + response = session.get( + url, + params={"conversation_id": conversation_id.strip()}, + verify=simplechat_verify_ssl, + timeout=30, + ) + + if response.status_code != 200: + try: + details = response.json() + except Exception: + details = {"raw": response.text} + return { + "error": "simplechat_request_failed", + "status_code": response.status_code, + "details": details, + "hint": "Verify the conversation_id is correct and belongs to your user.", + } + + result = response.json() + if isinstance(result, dict): + result.setdefault("auth_source", auth_source) + result.setdefault("conversation_id", conversation_id.strip()) + return result + + +@_mcp.tool(name="send_chat_message") +def send_chat_message( + ctx: Context[Any, Any, Any], + conversation_id: str = "", + message: str = "", +) -> Dict[str, Any]: + """Send a chat message to a SimpleChat conversation and return the AI response. + + Args: + conversation_id: The UUID of the conversation to send the message to. + If empty, a new conversation will be created automatically by SimpleChat. + message: The text message to send. + + Returns the AI reply, conversation_id, title, model info, and citations. + """ + if not message or not message.strip(): + return { + "success": False, + "error": "missing_parameter", + "message": "message is required.", + } + + bearer_token = _get_bearer_token_from_context(ctx) + auth_source = "prm" if bearer_token else "device_code" + if not bearer_token: + with _STATE_LOCK: + access_token = _STATE.get("access_token") + if isinstance(access_token, str) and access_token.strip(): + bearer_token = access_token.strip() + else: + return { + "success": False, + "error": "not_authenticated", + "message": "Not authenticated. Provide a PRM bearer token or complete device-code login.", + } + + try: + session = _get_or_create_simplechat_session(bearer_token) + except Exception as e: + return { + "success": False, + "error": "session_creation_failed", + "message": str(e), + "hint": "Ensure your bearer token is valid and SimpleChat is accessible.", + } + + simplechat_base_url = _env("SIMPLECHAT_BASE_URL") + simplechat_verify_ssl = _require_env_bool("SIMPLECHAT_VERIFY_SSL") + + payload: Dict[str, Any] = { + "message": message.strip(), + } + if conversation_id and conversation_id.strip(): + payload["conversation_id"] = conversation_id.strip() + + url = f"{simplechat_base_url}/api/chat" + print(f"[MCP] Calling SimpleChat POST {url}") + response = session.post( + url, + json=payload, + verify=simplechat_verify_ssl, + timeout=120, + ) + + if response.status_code != 200: + try: + details = response.json() + except Exception: + details = {"raw": response.text} + return { + "error": "simplechat_request_failed", + "status_code": response.status_code, + "details": details, + "hint": "Check that the conversation_id is valid and SimpleChat is configured with an AI model.", + } + + result = response.json() + if isinstance(result, dict): + result.setdefault("auth_source", auth_source) + return result + + +class _PrmAndAuthShim: + """ASGI middleware that serves PRM metadata and enforces authentication.""" + + def __init__(self, app: Any, streamable_path: str, require_auth: bool, prm_metadata_path: str) -> None: + self._app = app + self._streamable_path = streamable_path + self._require_auth = require_auth + self._prm_metadata_path = prm_metadata_path + + # Validate PRM metadata at startup (no fallbacks/defaults). + _ = self._load_prm_metadata() + + def _load_prm_metadata(self) -> Dict[str, Any]: + candidate_path = Path(self._prm_metadata_path) + if not candidate_path.is_absolute(): + candidate_path = Path(__file__).resolve().parent / candidate_path + + if not candidate_path.exists(): + raise ValueError(f"PRM metadata file not found at {candidate_path}") + + with candidate_path.open("r", encoding="utf-8") as handle: + data: Any = json.load(handle) + + if isinstance(data, dict): + return cast(Dict[str, Any], data) + raise ValueError(f"PRM metadata at {candidate_path} must be a JSON object") + + @staticmethod + def _get_request_origin(scope: Dict[str, Any]) -> str: + headers_list = list(scope.get("headers", [])) + + # Behind a reverse proxy (e.g. Azure Container Apps), TLS is terminated + # at the ingress and the ASGI scope["scheme"] is always "http". + # Check X-Forwarded-Proto first, then FASTMCP_SCHEME, then scope. + forwarded_proto_values = [ + value for (key, value) in headers_list + if (key or b"").lower() == b"x-forwarded-proto" + ] + forwarded_proto = ( + b"".join(forwarded_proto_values).decode("utf-8", errors="ignore").strip() + if forwarded_proto_values else "" + ) + + if forwarded_proto: + scheme = forwarded_proto + else: + scheme = str(scope.get("scheme") or "").strip() + if not scheme: + scheme = _require_env_value("FASTMCP_SCHEME") + + host_values = [value for (key, value) in headers_list if (key or b"").lower() == b"host"] + host = b"".join(host_values).decode("utf-8", errors="ignore").strip() + if not host: + host = f"{MCP_BIND_HOST}:{MCP_BIND_PORT}" + return f"{scheme}://{host}" + + async def _send_json(self, send: Any, status: int, payload: Dict[str, Any], headers: Optional[list[tuple[bytes, bytes]]] = None) -> None: + body = json.dumps(payload).encode("utf-8") + response_headers = [ + (b"content-type", b"application/json"), + (b"content-length", str(len(body)).encode("ascii")), + (b"cache-control", b"no-store"), + ] + if headers: + response_headers.extend(headers) + + await send({ + "type": "http.response.start", + "status": status, + "headers": response_headers, + }) + await send({ + "type": "http.response.body", + "body": body, + }) + + async def __call__(self, scope: Dict[str, Any], receive: Any, send: Any) -> None: + if scope.get("type") != "http": + await self._app(scope, receive, send) + return + + path = scope.get("path") or "" + method = scope.get("method") or "" + + origin = self._get_request_origin(scope) + prm_url = f"{origin}/.well-known/oauth-protected-resource" + streamable_path = (self._streamable_path or "").rstrip("/") + normalized_path = path.rstrip("/") + + # Serve PRM metadata + if method == "GET" and path == "/.well-known/oauth-protected-resource": + prm = self._load_prm_metadata() + prm["resource"] = f"{origin}{streamable_path}" + await self._send_json(send, 200, prm) + return + + # Enforce authentication for MCP endpoints (this is what triggers PRM handshake). + is_mcp_path = normalized_path == streamable_path or path.startswith(streamable_path + "/") + if self._require_auth and is_mcp_path: + headers_list = list(scope.get("headers", [])) + + # 1) Try Authorization header + auth_values = [value for (key, value) in headers_list if (key or b"").lower() == b"authorization"] + auth_header_bytes = b"".join(auth_values).strip() + auth_header = auth_header_bytes.decode("utf-8", errors="ignore") if auth_header_bytes else "" + bearer_token = _extract_bearer_token(auth_header) + + # 2) If missing, try cached token via MCP session id header + session_id_values = [value for (key, value) in headers_list if (key or b"").lower() == b"mcp-session-id"] + mcp_session_id = b"".join(session_id_values).decode("utf-8", errors="ignore").strip() if session_id_values else "" + + if not bearer_token and mcp_session_id: + with _SESSION_LOCK: + cached = _MCP_SESSION_TOKEN_CACHE.get(mcp_session_id) + if isinstance(cached, dict): + cached_token = cached.get("bearer_token") + expires_at = cached.get("expires_at") + if isinstance(expires_at, (int, float)) and expires_at < time.time(): + with _SESSION_LOCK: + _MCP_SESSION_TOKEN_CACHE.pop(mcp_session_id, None) + elif isinstance(cached_token, str) and cached_token.strip(): + bearer_token = cached_token.strip() + # Inject Authorization header into scope so tools can read it via Context + scope_headers = list(scope.get("headers", [])) + scope_headers.append((b"authorization", f"Bearer {bearer_token}".encode("utf-8"))) + scope["headers"] = scope_headers + + has_token = bool(bearer_token) + print( + f"[MCP PRM] {method} {path} - has_bearer_token={has_token} has_mcp_session_id={bool(mcp_session_id)}" + ) + + if not has_token: + link_target = f'<{prm_url}>; rel="oauth-protected-resource"'.encode("utf-8") + # Keep this header minimal and PRM-focused so clients can discover metadata and reuse auth silently. + scope_hint = "" + try: + prm = self._load_prm_metadata() + scopes = prm.get("scopes_supported") + if isinstance(scopes, list) and scopes and isinstance(scopes[0], str) and scopes[0].strip(): + scope_hint = scopes[0].strip() + except Exception: + scope_hint = "" + + if scope_hint: + www_auth = f'Bearer resource_metadata="{prm_url}", scope="{scope_hint}"'.encode("utf-8") + else: + www_auth = f'Bearer resource_metadata="{prm_url}"'.encode("utf-8") + await self._send_json( + send, + 401, + { + "error": "unauthorized", + "message": "Authorization required to use this MCP server.", + "hint": "Complete PRM auth in the client; the server will cache the token after the first authenticated request.", + }, + headers=[ + (b"www-authenticate", www_auth), + (b"link", link_target), + ], + ) + return + + # If we have a bearer token, capture the MCP session id from either the request + # (mcp-session-id header) or the response (base transport may assign it). + if bearer_token: + if mcp_session_id: + with _SESSION_LOCK: + _MCP_SESSION_TOKEN_CACHE[mcp_session_id] = { + "bearer_token": bearer_token, + "expires_at": time.time() + _MCP_SESSION_TOKEN_TTL_SECONDS, + } + + async def send_capture_session_id(message: Dict[str, Any]) -> None: + if message.get("type") == "http.response.start": + resp_headers = list(message.get("headers", [])) + resp_session_values = [ + value + for (key, value) in resp_headers + if (key or b"").lower() == b"mcp-session-id" + ] + resp_session_id = ( + b"".join(resp_session_values).decode("utf-8", errors="ignore").strip() + if resp_session_values + else "" + ) + if resp_session_id: + with _SESSION_LOCK: + _MCP_SESSION_TOKEN_CACHE[resp_session_id] = { + "bearer_token": bearer_token, + "expires_at": time.time() + _MCP_SESSION_TOKEN_TTL_SECONDS, + } + await send(message) + + await self._app(scope, receive, send_capture_session_id) + return + + await self._app(scope, receive, send) + + +if __name__ == "__main__": + print(f"[MCP] Starting server with MCP_REQUIRE_AUTH={DEFAULT_REQUIRE_MCP_AUTH}") + print(f"[MCP] PRM metadata path: {DEFAULT_PRM_METADATA_PATH}") + + import uvicorn + + base_app = _mcp.streamable_http_app() + + # Streamable HTTP transport is required for MCP Inspector. + if DEFAULT_REQUIRE_MCP_AUTH: + app_to_run: Any = _PrmAndAuthShim( + app=base_app, + streamable_path="/mcp", + require_auth=DEFAULT_REQUIRE_MCP_AUTH, + prm_metadata_path=DEFAULT_PRM_METADATA_PATH, + ) + print(f"[MCP] Server starting on {MCP_BIND_HOST}:{MCP_BIND_PORT}/mcp (with PRM authentication)") + else: + app_to_run = base_app + print(f"[MCP] Server starting on {MCP_BIND_HOST}:{MCP_BIND_PORT}/mcp (no authentication)") + + uvicorn.run(app_to_run, host=MCP_BIND_HOST, port=MCP_BIND_PORT, log_level="info") diff --git a/application/single_app/app.py b/application/single_app/app.py index cd04ff67..640337f7 100644 --- a/application/single_app/app.py +++ b/application/single_app/app.py @@ -142,6 +142,8 @@ from functions_global_agents import ensure_default_global_agent_exists from route_external_health import * +from route_external_authentication import * +from route_external_prm import * # =================== Session Configuration =================== def configure_sessions(settings): @@ -640,6 +642,12 @@ def list_semantic_kernel_plugins(): # ------------------- Extenral Health Routes ---------- register_route_external_health(app) +# ------------------- External Authentication Routes --- +register_route_external_authentication(app) + +# ------------------- PRM Metadata Routes -------------- +register_route_external_prm(app) + if __name__ == '__main__': settings = get_settings(use_cosmos=True) app_settings_cache.configure_app_cache(settings, get_redis_cache_infrastructure_endpoint(settings.get('redis_url', '').strip().split('.')[0])) diff --git a/application/single_app/functions_authentication.py b/application/single_app/functions_authentication.py index e4bcf480..17846ccd 100644 --- a/application/single_app/functions_authentication.py +++ b/application/single_app/functions_authentication.py @@ -1,6 +1,7 @@ # functions_authentication.py from config import * +from flask import g from functions_settings import * from functions_debug import debug_print @@ -463,18 +464,51 @@ def decorated_function(*args, **kwargs): if not is_valid: return jsonify({"message": data}), 401 - # Check for "ExternalApi" role in the token claims - roles = data.get("roles") if isinstance(data, dict) else None - if not roles or "ExternalApi" not in roles: - return jsonify({"message": "Forbidden: ExternalApi role required"}), 403 + if not is_external_api_authorized(data): + return jsonify({"message": "Forbidden: ExternalApi, User, or Admin role/scope required"}), 403 debug_print("User is valid") + if isinstance(data, dict): + g.user_claims = data # You can now access claims from `data`, e.g., data['sub'], data['name'], data['roles'] #kwargs['user_claims'] = data # Pass claims to the decorated function # NOT NEEDED FOR NOW return f(*args, **kwargs) return decorated_function + +def is_external_api_authorized(claims): + if not isinstance(claims, dict): + return False + + allowed_roles = {"ExternalApi", "User", "Admin"} + roles = claims.get("roles") + normalized_roles = [] + if isinstance(roles, list): + normalized_roles = [role for role in roles if isinstance(role, str)] + elif isinstance(roles, str): + normalized_roles = [roles] + + if any(role in allowed_roles for role in normalized_roles): + return True + + scopes_raw = "" + if isinstance(claims.get("scp"), str): + scopes_raw = claims.get("scp", "") + elif isinstance(claims.get("scope"), str): + scopes_raw = claims.get("scope", "") + + if not scopes_raw: + return False + + scope_values = [scope for scope in scopes_raw.split() if scope] + for scope in scope_values: + scope_name = scope.split("/")[-1] + if scope_name in allowed_roles: + return True + + return False + def login_required(f): @wraps(f) def decorated_function(*args, **kwargs): diff --git a/application/single_app/route_external_authentication.py b/application/single_app/route_external_authentication.py new file mode 100644 index 00000000..94cae6e0 --- /dev/null +++ b/application/single_app/route_external_authentication.py @@ -0,0 +1,43 @@ +# route_external_authentication.py +"""External authentication routes for API-to-API SSO.""" + +from config import * +from functions_authentication import accesstoken_required +from swagger_wrapper import swagger_route, get_auth_security +from functions_debug import debug_print +from flask import g + + +def register_route_external_authentication(app): + @app.route('/external/login', methods=['POST']) + @swagger_route(security=get_auth_security()) + @accesstoken_required + def external_login(): + """ + Creates a server-side session using a validated Entra bearer token. + Returns session details for external clients (e.g., MCP servers). + """ + claims = getattr(g, "user_claims", None) + if not isinstance(claims, dict): + return jsonify({"error": "Unauthorized", "message": "No user claims available"}), 401 + + session["user"] = claims + + session_id = getattr(session, "sid", None) or session.get("session_id") or session.get("_id") + if not session_id: + session_id = str(uuid4()) + session["session_id"] = session_id + + response_payload = { + "session_created": True, + "session_id": session_id, + "user": { + "userId": claims.get("oid") or claims.get("sub"), + "displayName": claims.get("name"), + "email": claims.get("preferred_username") or claims.get("email") + }, + "claims": claims + } + + debug_print(f"External login session created for user {response_payload['user'].get('userId')}") + return jsonify(response_payload), 200 diff --git a/application/single_app/route_external_prm.py b/application/single_app/route_external_prm.py new file mode 100644 index 00000000..b960ca8d --- /dev/null +++ b/application/single_app/route_external_prm.py @@ -0,0 +1,27 @@ +# route_external_prm.py +"""Protected Resource Metadata (PRM) endpoint for SimpleChat.""" + +from config import * +from swagger_wrapper import swagger_route, get_auth_security + + +def register_route_external_prm(app): + @app.route('/.well-known/oauth-protected-resource', methods=['GET']) + @swagger_route(security=get_auth_security()) + def get_prm_metadata(): + resource = request.host_url.rstrip('/') + metadata = { + "resource": resource, + "resource_name": "SimpleChat", + "resource_documentation": "https://microsoft.github.io/simplechat/", + "authorization_servers": [ + f"https://login.microsoftonline.com/{TENANT_ID}/v2.0" + ], + "scopes_supported": [ + f"api://{CLIENT_ID}/.default" + ], + "bearer_methods_supported": [ + "header" + ] + } + return jsonify(metadata), 200 diff --git a/application/single_app/route_frontend_authentication.py b/application/single_app/route_frontend_authentication.py index 022ecf84..07b2c521 100644 --- a/application/single_app/route_frontend_authentication.py +++ b/application/single_app/route_frontend_authentication.py @@ -2,6 +2,7 @@ from unittest import result from config import * +from urllib.parse import urlparse from functions_authentication import _build_msal_app, _load_cache, _save_cache from functions_debug import debug_print from swagger_wrapper import swagger_route, get_auth_security @@ -28,6 +29,26 @@ def build_front_door_urls(front_door_url): return home_url, login_redirect_url +def _parse_bool(value, default=False): + if value is None: + return default + if isinstance(value, bool): + return value + text = str(value).strip().lower() + if text in ["1", "true", "yes", "y", "on"]: + return True + if text in ["0", "false", "no", "n", "off"]: + return False + return default + +def _is_local_redirect_uri(redirect_uri): + if not redirect_uri: + return False + parsed = urlparse(redirect_uri) + if parsed.scheme not in ["http", "https"]: + return False + return parsed.hostname in ["localhost", "127.0.0.1"] + def register_route_frontend_authentication(app): @app.route('/login') @swagger_route(security=get_auth_security()) @@ -165,6 +186,8 @@ def authorized(): @app.route('/getATokenApi') # This is your redirect URI path @swagger_route(security=get_auth_security()) def authorized_api(): + create_session = _parse_bool(request.args.get('create_session'), default=False) + request_redirect_uri = request.args.get('redirect_uri') # Check for errors passed back from Azure AD if request.args.get('error'): error = request.args.get('error') @@ -177,22 +200,28 @@ def authorized_api(): print("Authorization code not found in callback.") return "Authorization code not found", 400 - # Build MSAL app WITH session cache (will be loaded by _build_msal_app via _load_cache) - msal_app = _build_msal_app(cache=_load_cache()) # Load existing cache + # Build MSAL app (use cache only if a session will be created) + if create_session: + msal_app = _build_msal_app(cache=_load_cache()) # Load existing cache + else: + msal_app = _build_msal_app() # Get settings for redirect URI (same logic as other routes) - from functions_settings import get_settings - settings = get_settings() - - if settings.get('enable_front_door', False): - front_door_url = settings.get('front_door_url') - if front_door_url: - home_url, login_redirect_url = build_front_door_urls(front_door_url) - redirect_uri = login_redirect_url - else: - redirect_uri = LOGIN_REDIRECT_URL or url_for('authorized', _external=True, _scheme='https') + if request_redirect_uri and _is_local_redirect_uri(request_redirect_uri): + redirect_uri = request_redirect_uri else: - redirect_uri = url_for('authorized', _external=True, _scheme='https') + from functions_settings import get_settings + settings = get_settings() + + if settings.get('enable_front_door', False): + front_door_url = settings.get('front_door_url') + if front_door_url: + home_url, login_redirect_url = build_front_door_urls(front_door_url) + redirect_uri = login_redirect_url + else: + redirect_uri = LOGIN_REDIRECT_URL or url_for('authorized', _external=True, _scheme='https') + else: + redirect_uri = url_for('authorized', _external=True, _scheme='https') result = msal_app.acquire_token_by_authorization_code( code=code, @@ -204,8 +233,20 @@ def authorized_api(): error_description = result.get("error_description", result.get("error")) print(f"Token acquisition failure: {error_description}") return f"Login failure: {error_description}", 500 + response_payload = dict(result) + + if create_session: + session["user"] = result.get("id_token_claims") + _save_cache(msal_app.token_cache) + session_id = getattr(session, "sid", None) or session.get("session_id") or session.get("_id") + if not session_id: + session_id = str(uuid4()) + session["session_id"] = session_id + if "session_id" not in response_payload: + response_payload["session_id"] = session_id - return jsonify(result, 200) + response_payload["session_created"] = create_session + return jsonify(response_payload), 200 @app.route('/logout') @swagger_route(security=get_auth_security())