diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index 3e6be37d..b2e4e124 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -16,6 +16,9 @@ STYLE Always conform to the coding styles defined in styleguide.md in the root of the repo when generating code. If the styleguide.md is missing, try to check the readme.md in the repo root. If readme.md is missing or contains no useful style information, use the default style of the language. If the default style is not defined, follow best practices, accessibility guidelines, and readability. Use @terminal when answering questions about Git. +MCP TOOL RESPONSE FORMATTING +When displaying results from MCP Server tool calls (e.g. #simplechat-mcp-server-local, #simplechat-mcp-server-cloud), ALWAYS format the response as a readable markdown table. Never show raw JSON output. Extract the most relevant fields and present them in a clean table. For search results, include columns like: #, Name/Title, Score, Key Details. For document lists, include: #, File Name, Classification, Upload Date. For conversations, include: #, Title, Date, Message Count. Adapt columns to fit the data returned. Always show the total count and auth_source at the top. + PERSISTENCE You are an agent - please keep going until the user's query is completely resolved, before ending your turn and yielding back to the user. Only terminate your turn when you are sure that the problem is solved. diff --git a/.gitignore b/.gitignore index 8a9839df..e3c36e8c 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,6 @@ flask_session **/my_chart.png **/sample_pie.csv **/sample_stacked_column.csv +application/external_apps/mcp/.env.azure +application/external_apps/mcp/logs/mcp_stderr.log +application/external_apps/mcp/logs/mcp_stdout.log 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..2713b5ea --- /dev/null +++ b/application/external_apps/mcp/README.md @@ -0,0 +1,109 @@ +# SimpleChat MCP Server (FastMCP) + +This MCP server provides **9 active 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`. | + +### Search (1 tool) +| Tool | Auth | SimpleChat API | Description | +|------|------|----------------|-------------| +| **search_documents** | Required | `POST /api/search` | Hybrid (text + vector) search across documents. Params: `query` (required), `doc_scope` (`all`/`personal`/`group`/`public`), `document_id`, `top_n`, `active_group_id`, `active_public_workspace_id`. | + +### Disabled Tools (code intact, decorator commented out) +The following tools are present in the codebase but currently disabled: +- `list_public_workspaces` — `GET /api/public_workspaces` +- `list_group_workspaces` — `GET /api/groups` +- `list_group_documents` — `GET /api/group_documents` +- `list_group_prompts` — `GET /api/group_prompts` +- `list_public_documents` — `GET /api/public_documents` +- `list_public_prompts` — `GET /api/public_prompts` + +To re-enable a tool, uncomment its `@_mcp.tool(...)` decorator in `server_minimal.py`. + +## 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` + +The `prm_metadata.json` file is a structural template. At runtime, `authorization_servers` and `scopes_supported` are dynamically set from `OAUTH_TOKEN_URL` and `OAUTH_CLIENT_ID` environment variables. The `resource` field is set from the request origin. All three values are required — missing env vars cause an immediate startup error. + +## 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..ce41c93a --- /dev/null +++ b/application/external_apps/mcp/example.env @@ -0,0 +1,17 @@ +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_HOST=0.0.0.0 +FASTMCP_PORT=8000 +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..fd456ccc --- /dev/null +++ b/application/external_apps/mcp/prm_metadata.json @@ -0,0 +1,14 @@ +{ + "resource": "DYNAMIC_AT_RUNTIME", + "resource_name": "SimpleChat MCP Server", + "resource_documentation": "https://microsoft.github.io/simplechat/", + "authorization_servers": [ + "DYNAMIC_FROM_OAUTH_TOKEN_URL" + ], + "scopes_supported": [ + "DYNAMIC_FROM_OAUTH_CLIENT_ID" + ], + "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..faa88fde --- /dev/null +++ b/application/external_apps/mcp/server_minimal.py @@ -0,0 +1,1811 @@ +# 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 + + +@_mcp.tool(name="search_documents") +def search_documents( + ctx: Context[Any, Any, Any], + query: str = "", + doc_scope: str = "all", + document_id: Optional[str] = None, + top_n: int = 12, + active_group_id: Optional[str] = None, + active_public_workspace_id: Optional[str] = None, +) -> Dict[str, Any]: + """Search across documents in SimpleChat using hybrid (text + vector) search. + + Args: + query: The search query text (required). + doc_scope: Scope of search — "all", "personal", "group", or "public". Default "all". + document_id: Restrict search to a specific document ID (optional). + top_n: Maximum number of results to return. Default 12. + active_group_id: Group ID to search within when doc_scope is "group" or "all" (optional). + active_public_workspace_id: Public workspace ID when doc_scope is "public" or "all" (optional). + + Returns search results with chunk text, file names, scores, and metadata. + """ + if not query or not query.strip(): + return { + "success": False, + "error": "missing_parameter", + "message": "query is required.", + } + + if doc_scope not in ("all", "personal", "group", "public"): + return { + "success": False, + "error": "invalid_parameter", + "message": f"Invalid doc_scope: {doc_scope}. Must be one of: all, personal, group, public.", + } + + 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] = { + "query": query.strip(), + "doc_scope": doc_scope, + "top_n": top_n, + } + if document_id: + payload["document_id"] = document_id + if active_group_id: + payload["active_group_id"] = active_group_id + if active_public_workspace_id: + payload["active_public_workspace_id"] = active_public_workspace_id + + url = f"{simplechat_base_url}/api/search" + print(f"[MCP] Calling SimpleChat POST {url}") + response = session.post( + url, + json=payload, + verify=simplechat_verify_ssl, + timeout=60, + ) + + 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 + + +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 not isinstance(data, dict): + raise ValueError(f"PRM metadata at {candidate_path} must be a JSON object") + + # Dynamically set authorization_servers and scopes_supported from + # environment variables (.env locally, Azure App Settings in the cloud). + # These are required — missing values cause an immediate startup error. + token_url = _require_env_value("OAUTH_TOKEN_URL") + client_id = _require_env_value("OAUTH_CLIENT_ID") + + # Extract tenant-specific authority from token URL + # e.g. https://login.microsoftonline.com/{tenant}/oauth2/v2.0/token + # -> https://login.microsoftonline.com/{tenant}/v2.0 + authority = token_url.replace("/oauth2/v2.0/token", "/v2.0").replace("/oauth2/token", "/v2.0") + data["authorization_servers"] = [authority] + data["scopes_supported"] = [f"api://{client_id}/.default"] + + # resource is set dynamically at serve time from the request origin + # (see __call__ method), so we don't set it here. + + return cast(Dict[str, Any], data) + + @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..91acae00 100644 --- a/application/single_app/app.py +++ b/application/single_app/app.py @@ -51,6 +51,7 @@ from route_frontend_safety import * from route_frontend_feedback import * from route_frontend_notifications import * +from route_frontend_search import * from route_backend_chats import * from route_backend_conversations import * @@ -142,6 +143,7 @@ from functions_global_agents import ensure_default_global_agent_exists from route_external_health import * +from route_external_authentication import * # =================== Session Configuration =================== def configure_sessions(settings): @@ -580,6 +582,9 @@ def list_semantic_kernel_plugins(): # ------------------- Notifications Routes -------------- register_route_frontend_notifications(app) +# ------------------- Search Routes ---------------------- +register_route_frontend_search(app) + # ------------------- API Chat Routes -------------------- register_route_backend_chats(app) @@ -640,6 +645,9 @@ def list_semantic_kernel_plugins(): # ------------------- Extenral Health Routes ---------- register_route_external_health(app) +# ------------------- External Authentication Routes --- +register_route_external_authentication(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..a7619aa2 --- /dev/null +++ b/application/single_app/route_external_authentication.py @@ -0,0 +1,58 @@ +# 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 + +# TODO: GJU /getATokenApi should really live here and not route_external_authentication.py + +def register_route_external_authentication(app): + @app.route('/external/login', methods=['POST']) + @swagger_route(security=get_auth_security()) + @accesstoken_required + def external_login(): + """ + *** TODO: GJU + 1) Check headers of request to make sure this hard-coded API key exists: + MCP Server API KEY: pYVuDbG3V8NpMVrQm0g9dVwoLa3kLZ4D + + 2) Check to make sure MCP Server identified by this API Key: pYVuDbG3V8NpMVrQm0g9dVwoLa3kLZ4D is enabled. + + 3) if #2 is enabled, check user claims for "CoPilotUser" role + + if all checks are valid: create and return session + + if not valid: error + """ + + """ + 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_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()) diff --git a/application/single_app/route_frontend_search.py b/application/single_app/route_frontend_search.py new file mode 100644 index 00000000..0f97acb5 --- /dev/null +++ b/application/single_app/route_frontend_search.py @@ -0,0 +1,80 @@ +# route_frontend_search.py +"""Search routes exposing hybrid search as an API endpoint.""" + +from config import * +from functions_authentication import * +from functions_search import hybrid_search +from functions_debug import debug_print +from swagger_wrapper import swagger_route, get_auth_security + + +def register_route_frontend_search(app): + @app.route('/api/search', methods=['POST']) + @swagger_route(security=get_auth_security()) + @login_required + @user_required + def api_search(): + """ + Perform a hybrid search across user, group, and/or public document indexes. + + Expects JSON body: + query (str, required): The search query text. + doc_scope (str, optional): One of "all", "personal", "group", "public". Default "all". + document_id (str, optional): Restrict search to a specific document. + top_n (int, optional): Max results to return. Default 12. + active_group_id (str, optional): Group ID when doc_scope is "group" or "all". + active_public_workspace_id (str, optional): Public workspace ID when doc_scope is "public" or "all". + + Returns JSON: + { "results": [...], "count": int } + """ + user_id = get_current_user_id() + if not user_id: + return jsonify({"error": "User not authenticated"}), 401 + + data = request.get_json(silent=True) or {} + query = (data.get("query") or "").strip() + if not query: + return jsonify({"error": "Missing required field: query"}), 400 + + doc_scope = data.get("doc_scope", "all") + if doc_scope not in ("all", "personal", "group", "public"): + return jsonify({"error": f"Invalid doc_scope: {doc_scope}. Must be one of: all, personal, group, public"}), 400 + + document_id = data.get("document_id") + top_n = data.get("top_n", 12) + active_group_id = data.get("active_group_id") + active_public_workspace_id = data.get("active_public_workspace_id") + + try: + top_n = int(top_n) + except (TypeError, ValueError): + return jsonify({"error": "top_n must be an integer"}), 400 + + debug_print( + f"API search request", + "SEARCH_API", + user_id=user_id, + query=query[:40], + doc_scope=doc_scope, + top_n=top_n + ) + + try: + results = hybrid_search( + query=query, + user_id=user_id, + document_id=document_id, + top_n=top_n, + doc_scope=doc_scope, + active_group_id=active_group_id, + active_public_workspace_id=active_public_workspace_id, + ) + except Exception as e: + debug_print(f"Search error: {e}", "SEARCH_API") + return jsonify({"error": "Search failed", "message": str(e)}), 500 + + if results is None: + return jsonify({"error": "Search failed — could not generate embedding"}), 500 + + return jsonify({"results": results, "count": len(results)}), 200 diff --git a/application/single_app/static/images/custom_logo.png b/application/single_app/static/images/custom_logo.png deleted file mode 100644 index ecf6e652..00000000 Binary files a/application/single_app/static/images/custom_logo.png and /dev/null differ diff --git a/application/single_app/static/images/custom_logo_dark.png b/application/single_app/static/images/custom_logo_dark.png deleted file mode 100644 index 4f281945..00000000 Binary files a/application/single_app/static/images/custom_logo_dark.png and /dev/null differ