diff --git a/.claude/settings.local.json b/.claude/settings.local.json new file mode 100644 index 00000000..458af962 --- /dev/null +++ b/.claude/settings.local.json @@ -0,0 +1,11 @@ +{ + "permissions": { + "allow": [ + "Bash(az webapp log tail:*)", + "Bash(az functionapp config appsettings list:*)", + "Bash(dotnet build:*)" + ], + "deny": [], + "ask": [] + } +} diff --git a/.github/ISSUE_TEMPLATE/GENERAL.md b/.github/ISSUE_TEMPLATE/GENERAL.md index 991da020..84cea1c7 100644 --- a/.github/ISSUE_TEMPLATE/GENERAL.md +++ b/.github/ISSUE_TEMPLATE/GENERAL.md @@ -6,6 +6,7 @@ labels: '' assignees: '' --- + diff --git a/.github/ISSUE_TEMPLATE/custom.md b/.github/ISSUE_TEMPLATE/custom.md new file mode 100644 index 00000000..48d5f81f --- /dev/null +++ b/.github/ISSUE_TEMPLATE/custom.md @@ -0,0 +1,10 @@ +--- +name: Custom issue template +about: Describe this issue template's purpose here. +title: '' +labels: '' +assignees: '' + +--- + + diff --git a/.github/ISSUE_TEMPLATE/task-template.md b/.github/ISSUE_TEMPLATE/task-template.md new file mode 100644 index 00000000..d105d71e --- /dev/null +++ b/.github/ISSUE_TEMPLATE/task-template.md @@ -0,0 +1,14 @@ +--- +name: Task template +about: task 작성 +title: 'Onedrive-download/Task/#34-1: Entra ID 등록' +labels: '' +assignees: '' + +--- + +### #feat-num User Story 1.1 Task +--- +main 설명 + +_부연 설명_ diff --git a/.github/ISSUE_TEMPLATE/user-story---tasks.md b/.github/ISSUE_TEMPLATE/user-story---tasks.md new file mode 100644 index 00000000..f8b8a076 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/user-story---tasks.md @@ -0,0 +1,16 @@ +--- +name: user story & tasks +about: user story 및 하위 태스크 작성 +title: 'Onedrive-download/User Story 1.2:' +labels: '' +assignees: '' + +--- + +### 📬 User Story 2 (main title of user story) +#### 개발자로서, ~~ +_부연 설명 및 참고 사항_ + +--- +* **Tasks** + - [ ] task 내용 diff --git a/.vscode/mcp.json b/.vscode/mcp.json index 3bd62f4f..f9c08e89 100644 --- a/.vscode/mcp.json +++ b/.vscode/mcp.json @@ -1,32 +1,16 @@ { + "inputs": [ + { + "type": "promptString", + "id": "apim-fqdn", + "description": "Azure API Management FQDN" + } + ], "servers": { - "awesome-copilot": { - "type": "stdio", - "command": "docker", - "args": [ - "run", - "-i", - "--rm", - "ghcr.io/microsoft/mcp-dotnet-samples/awesome-copilot:latest" - ] - }, - "microsoft.docs.mcp": { - "type": "http", - "url": "https://learn.microsoft.com/api/mcp" - }, - "github": { + "onedrive-download": { "type": "http", - "url": "https://api.githubcopilot.com/mcp/" - }, - "sequential-thinking": { - "type": "stdio", - "command": "docker", - "args": [ - "run", - "--rm", - "-i", - "mcp/sequentialthinking" - ] + "url": "https://${input:apim-fqdn}/mcp" } } -} \ No newline at end of file +} + diff --git a/Dockerfile.onedrive-download b/Dockerfile.onedrive-download new file mode 100644 index 00000000..be3a98c0 --- /dev/null +++ b/Dockerfile.onedrive-download @@ -0,0 +1,27 @@ +# syntax=docker/dockerfile:1 + +FROM --platform=$BUILDPLATFORM mcr.microsoft.com/dotnet/sdk:9.0-alpine AS build + +COPY ./shared/McpSamples.Shared /source/shared/McpSamples.Shared +COPY ./onedrive-download/src/McpSamples.OnedriveDownload.HybridApp /source/onedrive-download/src/McpSamples.OnedriveDownload.HybridApp + +WORKDIR /source/onedrive-download/src/McpSamples.OnedriveDownload.HybridApp + +ARG TARGETARCH +RUN case "$TARGETARCH" in \ + "amd64") RID="linux-musl-x64" ;; \ + "arm64") RID="linux-musl-arm64" ;; \ + *) RID="linux-musl-x64" ;; \ + esac && \ + dotnet publish -c Release -o /app -r $RID --self-contained false + +FROM mcr.microsoft.com/dotnet/aspnet:9.0-alpine AS final + +WORKDIR /app + +COPY --from=build /app . + +USER $APP_UID + +ENTRYPOINT ["dotnet", "McpSamples.OnedriveDownload.HybridApp.dll"] + diff --git a/onedrive-download/.dockerignore b/onedrive-download/.dockerignore new file mode 100644 index 00000000..9e03c484 --- /dev/null +++ b/onedrive-download/.dockerignore @@ -0,0 +1,32 @@ +# Include any files or directories that you don't want to be copied to your +# container here (e.g., local build artifacts, temporary files, etc.). +# +# For more help, visit the .dockerignore file reference guide at +# https://docs.docker.com/go/build-context-dockerignore/ + +**/.DS_Store +**/.classpath +**/.dockerignore +**/.env +**/.git +**/.gitignore +**/.project +**/.settings +**/.toolstarget +**/.vs +**/.vscode +**/*.*proj.user +**/*.dbmdl +**/*.jfm +**/bin +**/charts +**/docker-compose* +**/compose.y*ml +**/Dockerfile* +**/node_modules +**/npm-debug.log +**/obj +**/secrets.dev.yaml +**/values.dev.yaml +LICENSE +README.md diff --git a/onedrive-download/.gitignore b/onedrive-download/.gitignore new file mode 100644 index 00000000..2021217a --- /dev/null +++ b/onedrive-download/.gitignore @@ -0,0 +1,28 @@ +# User-specific files +*.user +*.userosscache +*.sln.docstates + +# Build results +[Bb]in/ +[Oo]bj/ + +# Visual Studio +.vs/ + +# From md_file/.gitignore +feature.md +feature2.md + +# Environment files with sensitive data +.env.local +.env*.local +.azure/ +.claude/ + +# Development settings with sensitive data (use .example template instead) +appsettings.Development.json +appsettings.*.json + +# Generated files from Azure File Share sync +generated/ diff --git a/onedrive-download/.vscode/launch.json b/onedrive-download/.vscode/launch.json new file mode 100644 index 00000000..9de376aa --- /dev/null +++ b/onedrive-download/.vscode/launch.json @@ -0,0 +1,25 @@ +{ + "version": "0.2.0", + "configurations": [ + { + "name": ".NET Core Launch (web)", + "type": "coreclr", + "request": "launch", + "preLaunchTask": "build", + "program": "${workspaceFolder}/src/McpSamples.OnedriveDownload.HybridApp/bin/Debug/net9.0/McpSamples.OnedriveDownload.HybridApp.dll", + "args": [], + "cwd": "${workspaceFolder}/src/McpSamples.OnedriveDownload.HybridApp", + "stopAtEntry": false, + "serverReadyAction": { + "action": "openExternally", + "pattern": "\\bNow listening on:\\s+(https?:\\/\\/\\S+)" + }, + "env": { + "ASPNETCORE_ENVIRONMENT": "Development" + }, + "sourceFileMap": { + "/Views": "${workspaceFolder}/Views" + } + } + ] +} \ No newline at end of file diff --git a/onedrive-download/.vscode/mcp.http.remote-apim.json b/onedrive-download/.vscode/mcp.http.remote-apim.json new file mode 100644 index 00000000..1e1d8554 --- /dev/null +++ b/onedrive-download/.vscode/mcp.http.remote-apim.json @@ -0,0 +1,16 @@ +{ + "inputs": [ + { + "type": "promptString", + "id": "apim-fqdn", + "description": "Azure API Management FQDN" + } + ], + "servers": { + "onedrive-download": { + "type": "http", + "url": "https://${input:apim-fqdn}/mcp" + } + } +} + diff --git a/onedrive-download/.vscode/mcp.http.remote-func.json b/onedrive-download/.vscode/mcp.http.remote-func.json new file mode 100644 index 00000000..c20a546d --- /dev/null +++ b/onedrive-download/.vscode/mcp.http.remote-func.json @@ -0,0 +1,16 @@ +{ + "inputs": [ + { + "type": "promptString", + "id": "functionAppFqdn", + "description": "Azure Function App FQDN" + } + ], + "servers": { + "onedrive-download": { + "type": "http", + "url": "https://${input:functionAppFqdn}/mcp" + } + } +} + diff --git a/onedrive-download/.vscode/tasks.json b/onedrive-download/.vscode/tasks.json new file mode 100644 index 00000000..238be7c5 --- /dev/null +++ b/onedrive-download/.vscode/tasks.json @@ -0,0 +1,30 @@ +{ + "version": "2.0.0", + "tasks": [ + { + "label": "build", + "command": "dotnet", + "type": "process", + "args": [ + "build", + "${workspaceFolder}/src/McpSamples.OnedriveDownload.HybridApp/McpSamples.OnedriveDownload.HybridApp.csproj", + "/property:GenerateFullPaths=true", + "/consoleloggerparameters:NoSummary" + ], + "problemMatcher": "$msCompile" + }, + { + "label": "Set Function App FQDN", + "type": "shell", + "command": "powershell", + "args": [ + "-Command", + "& { $fqdn = Read-Host 'Enter Function App FQDN (e.g., func-app-name.azurewebsites.net)'; $appsettingsPath = '${workspaceFolder}/src/McpSamples.OnedriveDownload.HybridApp/appsettings.json'; $content = Get-Content $appsettingsPath | ConvertFrom-Json; $content | Add-Member -NotePropertyName 'FunctionAppFqdn' -NotePropertyValue $fqdn -Force; $content | ConvertTo-Json | Set-Content $appsettingsPath; Write-Host \"FQDN updated: $fqdn\" }" + ], + "presentation": { + "reveal": "always", + "panel": "new" + } + } + ] +} \ No newline at end of file diff --git a/onedrive-download/McpOnedriveDownload.sln b/onedrive-download/McpOnedriveDownload.sln new file mode 100644 index 00000000..b19f9595 --- /dev/null +++ b/onedrive-download/McpOnedriveDownload.sln @@ -0,0 +1,39 @@ + +Microsoft Visual Studio Solution File, Format Version 12.00 +# Visual Studio Version 17 +VisualStudioVersion = 17.0.31903.59 +MinimumVisualStudioVersion = 10.0.40219.1 +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "src", "src", "{827E0CD3-B72D-47B6-A68D-7590B98EB39B}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "McpSamples.OnedriveDownload.HybridApp", "src\McpSamples.OnedriveDownload.HybridApp\McpSamples.OnedriveDownload.HybridApp.csproj", "{71D5E732-06DF-430C-80AF-F0996DCE338B}" +EndProject +Global + GlobalSection(SolutionConfigurationPlatforms) = preSolution + Debug|Any CPU = Debug|Any CPU + Debug|x64 = Debug|x64 + Debug|x86 = Debug|x86 + Release|Any CPU = Release|Any CPU + Release|x64 = Release|x64 + Release|x86 = Release|x86 + EndGlobalSection + GlobalSection(ProjectConfigurationPlatforms) = postSolution + {71D5E732-06DF-430C-80AF-F0996DCE338B}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {71D5E732-06DF-430C-80AF-F0996DCE338B}.Debug|Any CPU.Build.0 = Debug|Any CPU + {71D5E732-06DF-430C-80AF-F0996DCE338B}.Debug|x64.ActiveCfg = Debug|Any CPU + {71D5E732-06DF-430C-80AF-F0996DCE338B}.Debug|x64.Build.0 = Debug|Any CPU + {71D5E732-06DF-430C-80AF-F0996DCE338B}.Debug|x86.ActiveCfg = Debug|Any CPU + {71D5E732-06DF-430C-80AF-F0996DCE338B}.Debug|x86.Build.0 = Debug|Any CPU + {71D5E732-06DF-430C-80AF-F0996DCE338B}.Release|Any CPU.ActiveCfg = Release|Any CPU + {71D5E732-06DF-430C-80AF-F0996DCE338B}.Release|Any CPU.Build.0 = Release|Any CPU + {71D5E732-06DF-430C-80AF-F0996DCE338B}.Release|x64.ActiveCfg = Release|Any CPU + {71D5E732-06DF-430C-80AF-F0996DCE338B}.Release|x64.Build.0 = Release|Any CPU + {71D5E732-06DF-430C-80AF-F0996DCE338B}.Release|x86.ActiveCfg = Release|Any CPU + {71D5E732-06DF-430C-80AF-F0996DCE338B}.Release|x86.Build.0 = Release|Any CPU + EndGlobalSection + GlobalSection(SolutionProperties) = preSolution + HideSolutionNode = FALSE + EndGlobalSection + GlobalSection(NestedProjects) = preSolution + {71D5E732-06DF-430C-80AF-F0996DCE338B} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B} + EndGlobalSection +EndGlobal diff --git a/onedrive-download/README.md b/onedrive-download/README.md new file mode 100644 index 00000000..b0bb6a55 --- /dev/null +++ b/onedrive-download/README.md @@ -0,0 +1,118 @@ +# MCP Server: OneDrive Download + +This is an MCP server that downloads files from OneDrive and provides secure access through temporary SAS tokens. + +## Prerequisites + +- [.NET 9 SDK](https://dotnet.microsoft.com/download/dotnet/9.0) +- [Visual Studio Code](https://code.visualstudio.com/) with + - [C# Dev Kit](https://marketplace.visualstudio.com/items/?itemName=ms-dotnettools.csdevkit) extension +- [Azure CLI](https://learn.microsoft.com/cli/azure/install-azure-cli) +- [Azure Developer CLI](https://learn.microsoft.com/azure/developer/azure-developer-cli/install-azd) + +## What's Included + +- OneDrive Download MCP server with: + - User token passthrough authentication with Microsoft Entra ID + - Secure file download with time-bound SAS tokens + - Azure File Share integration for file storage + + | Building Block | Name | Description | Usage | + |----------------|-------------------------------|----------------------------------------------------------|------------------------------------| + | Tools | `download_file_from_onedrive_url` | Download a file from OneDrive URL and return SAS link. | `#download_file_from_onedrive_url` | + +## Getting Started + +### Getting repository root + +1. Get the repository root. + + ```bash + # bash/zsh + REPOSITORY_ROOT=$(git rev-parse --show-toplevel) + ``` + + ```powershell + # PowerShell + $REPOSITORY_ROOT = git rev-parse --show-toplevel + ``` + +### Running MCP server on Azure + +1. **IMPORTANT** Check whether you have the necessary permissions: + - Your Azure account must have the `Microsoft.Authorization/roleAssignments/write` permission, such as [Role Based Access Control Administrator](https://learn.microsoft.com/azure/role-based-access-control/built-in-roles/privileged#role-based-access-control-administrator), [User Access Administrator](https://learn.microsoft.com/azure/role-based-access-control/built-in-roles/privileged#user-access-administrator), or [Owner](https://learn.microsoft.com/azure/role-based-access-control/built-in-roles/privileged#owner) at the subscription level. + - Your Azure account must also have the `Microsoft.Resources/deployments/write` permission at the subscription level. + +1. Navigate to the directory. + + ```bash + cd $REPOSITORY_ROOT/onedrive-download + ``` + +1. Login to Azure. + + ```bash + # Login with Azure Developer CLI + azd auth login + ``` + +1. Deploy the MCP server app to Azure. + + ```bash + azd up + ``` + + While provisioning and deploying, you'll be asked to provide subscription ID, location, environment name. + +1. After the deployment is complete, get the information by running the following commands: + + - Azure Functions Apps FQDN: + + ```bash + azd env get-value AZURE_RESOURCE_MCP_ONEDRIVE_DOWNLOAD_FQDN + ``` + + - Azure API Management FQDN: + + ```bash + azd env get-value AZURE_RESOURCE_MCP_ONEDRIVE_DOWNLOAD_GATEWAY_FQDN + ``` + +## Connect MCP server to an MCP host/client + +### VS Code + Agent Mode + Remote MCP server + +1. **For remotely running MCP server as Function app (HTTP):** + + ```bash + mkdir -p $REPOSITORY_ROOT/.vscode + cp $REPOSITORY_ROOT/onedrive-download/.vscode/mcp.http.remote-func.json \ + $REPOSITORY_ROOT/.vscode/mcp.json + ``` + + ```powershell + New-Item -Type Directory -Path $REPOSITORY_ROOT/.vscode -Force + Copy-Item -Path $REPOSITORY_ROOT/onedrive-download/.vscode/mcp.http.remote-func.json ` + -Destination $REPOSITORY_ROOT/.vscode/mcp.json -Force + ``` + +1. **For remotely running MCP server via API Management (HTTP):** + + ```bash + mkdir -p $REPOSITORY_ROOT/.vscode + cp $REPOSITORY_ROOT/onedrive-download/.vscode/mcp.http.remote-apim.json \ + $REPOSITORY_ROOT/.vscode/mcp.json + ``` + + ```powershell + New-Item -Type Directory -Path $REPOSITORY_ROOT/.vscode -Force + Copy-Item -Path $REPOSITORY_ROOT/onedrive-download/.vscode/mcp.http.remote-apim.json ` + -Destination $REPOSITORY_ROOT/.vscode/mcp.json -Force + ``` + +1. Open Command Palette by typing `F1` or `Ctrl`+`Shift`+`P` on Windows or `Cmd`+`Shift`+`P` on Mac OS, and search `MCP: List Servers`. +1. Choose `onedrive-download` then click `Start Server`. +1. When prompted, enter the following values: + - The FQDN of Azure Functions Apps (from `AZURE_RESOURCE_MCP_ONEDRIVE_DOWNLOAD_FQDN`). + - The FQDN of Azure API Management (from `AZURE_RESOURCE_MCP_ONEDRIVE_DOWNLOAD_GATEWAY_FQDN`). +1. Enter a OneDrive sharing URL and the tool will download the file and return a secure download link. diff --git a/onedrive-download/azure.yaml b/onedrive-download/azure.yaml new file mode 100644 index 00000000..d250a54b --- /dev/null +++ b/onedrive-download/azure.yaml @@ -0,0 +1,14 @@ +# yaml-language-server: $schema=https://raw.githubusercontent.com/Azure/azure-dev/main/schemas/v1.0/azure.yaml.json + +name: onedrive-download + +metadata: + template: azd-init@1.14.0 + +services: + onedrive-download: + project: ./src/McpSamples.OnedriveDownload.HybridApp + host: function + language: csharp + package: + outputPath: bin/Release/net9.0/publish diff --git a/onedrive-download/infra/abbreviations.json b/onedrive-download/infra/abbreviations.json new file mode 100644 index 00000000..1533dee5 --- /dev/null +++ b/onedrive-download/infra/abbreviations.json @@ -0,0 +1,136 @@ +{ + "analysisServicesServers": "as", + "apiManagementService": "apim-", + "appConfigurationStores": "appcs-", + "appManagedEnvironments": "cae-", + "appContainerApps": "ca-", + "authorizationPolicyDefinitions": "policy-", + "automationAutomationAccounts": "aa-", + "blueprintBlueprints": "bp-", + "blueprintBlueprintsArtifacts": "bpa-", + "cacheRedis": "redis-", + "cdnProfiles": "cdnp-", + "cdnProfilesEndpoints": "cdne-", + "cognitiveServicesAccounts": "cog-", + "cognitiveServicesFormRecognizer": "cog-fr-", + "cognitiveServicesTextAnalytics": "cog-ta-", + "computeAvailabilitySets": "avail-", + "computeCloudServices": "cld-", + "computeDiskEncryptionSets": "des", + "computeDisks": "disk", + "computeDisksOs": "osdisk", + "computeGalleries": "gal", + "computeSnapshots": "snap-", + "computeVirtualMachines": "vm", + "computeVirtualMachineScaleSets": "vmss-", + "containerInstanceContainerGroups": "ci", + "containerRegistryRegistries": "cr", + "containerServiceManagedClusters": "aks-", + "databricksWorkspaces": "dbw-", + "dataFactoryFactories": "adf-", + "dataLakeAnalyticsAccounts": "dla", + "dataLakeStoreAccounts": "dls", + "dataMigrationServices": "dms-", + "dBforMySQLServers": "mysql-", + "dBforPostgreSQLServers": "psql-", + "devicesIotHubs": "iot-", + "devicesProvisioningServices": "provs-", + "devicesProvisioningServicesCertificates": "pcert-", + "documentDBDatabaseAccounts": "cosmos-", + "documentDBMongoDatabaseAccounts": "cosmon-", + "eventGridDomains": "evgd-", + "eventGridDomainsTopics": "evgt-", + "eventGridEventSubscriptions": "evgs-", + "eventHubNamespaces": "evhns-", + "eventHubNamespacesEventHubs": "evh-", + "hdInsightClustersHadoop": "hadoop-", + "hdInsightClustersHbase": "hbase-", + "hdInsightClustersKafka": "kafka-", + "hdInsightClustersMl": "mls-", + "hdInsightClustersSpark": "spark-", + "hdInsightClustersStorm": "storm-", + "hybridComputeMachines": "arcs-", + "insightsActionGroups": "ag-", + "insightsComponents": "appi-", + "keyVaultVaults": "kv-", + "kubernetesConnectedClusters": "arck", + "kustoClusters": "dec", + "kustoClustersDatabases": "dedb", + "logicIntegrationAccounts": "ia-", + "logicWorkflows": "logic-", + "machineLearningServicesWorkspaces": "mlw-", + "managedIdentityUserAssignedIdentities": "id-", + "managementManagementGroups": "mg-", + "migrateAssessmentProjects": "migr-", + "networkApplicationGateways": "agw-", + "networkApplicationSecurityGroups": "asg-", + "networkAzureFirewalls": "afw-", + "networkBastionHosts": "bas-", + "networkConnections": "con-", + "networkDnsZones": "dnsz-", + "networkExpressRouteCircuits": "erc-", + "networkFirewallPolicies": "afwp-", + "networkFirewallPoliciesWebApplication": "waf", + "networkFirewallPoliciesRuleGroups": "wafrg", + "networkFrontDoors": "fd-", + "networkFrontdoorWebApplicationFirewallPolicies": "fdfp-", + "networkLoadBalancersExternal": "lbe-", + "networkLoadBalancersInternal": "lbi-", + "networkLoadBalancersInboundNatRules": "rule-", + "networkLocalNetworkGateways": "lgw-", + "networkNatGateways": "ng-", + "networkNetworkInterfaces": "nic-", + "networkNetworkSecurityGroups": "nsg-", + "networkNetworkSecurityGroupsSecurityRules": "nsgsr-", + "networkNetworkWatchers": "nw-", + "networkPrivateDnsZones": "pdnsz-", + "networkPrivateLinkServices": "pl-", + "networkPublicIPAddresses": "pip-", + "networkPublicIPPrefixes": "ippre-", + "networkRouteFilters": "rf-", + "networkRouteTables": "rt-", + "networkRouteTablesRoutes": "udr-", + "networkTrafficManagerProfiles": "traf-", + "networkVirtualNetworkGateways": "vgw-", + "networkVirtualNetworks": "vnet-", + "networkVirtualNetworksSubnets": "snet-", + "networkVirtualNetworksVirtualNetworkPeerings": "peer-", + "networkVirtualWans": "vwan-", + "networkVpnGateways": "vpng-", + "networkVpnGatewaysVpnConnections": "vcn-", + "networkVpnGatewaysVpnSites": "vst-", + "notificationHubsNamespaces": "ntfns-", + "notificationHubsNamespacesNotificationHubs": "ntf-", + "operationalInsightsWorkspaces": "log-", + "portalDashboards": "dash-", + "powerBIDedicatedCapacities": "pbi-", + "purviewAccounts": "pview-", + "recoveryServicesVaults": "rsv-", + "resourcesResourceGroups": "rg-", + "searchSearchServices": "srch-", + "serviceBusNamespaces": "sb-", + "serviceBusNamespacesQueues": "sbq-", + "serviceBusNamespacesTopics": "sbt-", + "serviceEndPointPolicies": "se-", + "serviceFabricClusters": "sf-", + "signalRServiceSignalR": "sigr", + "sqlManagedInstances": "sqlmi-", + "sqlServers": "sql-", + "sqlServersDataWarehouse": "sqldw-", + "sqlServersDatabases": "sqldb-", + "sqlServersDatabasesStretch": "sqlstrdb-", + "storageStorageAccounts": "st", + "storageStorageAccountsVm": "stvm", + "storSimpleManagers": "ssimp", + "streamAnalyticsCluster": "asa-", + "synapseWorkspaces": "syn", + "synapseWorkspacesAnalyticsWorkspaces": "synw", + "synapseWorkspacesSqlPoolsDedicated": "syndp", + "synapseWorkspacesSqlPoolsSpark": "synsp", + "timeSeriesInsightsEnvironments": "tsi-", + "webServerFarms": "plan-", + "webSitesAppService": "app-", + "webSitesAppServiceEnvironment": "ase-", + "webSitesFunctions": "func-", + "webStaticSites": "stapp-" +} diff --git a/onedrive-download/infra/bicepconfig.json b/onedrive-download/infra/bicepconfig.json new file mode 100644 index 00000000..a0c229d6 --- /dev/null +++ b/onedrive-download/infra/bicepconfig.json @@ -0,0 +1,8 @@ +{ + "experimentalFeaturesEnabled": { + "extensibility": true + }, + "extensions": { + "microsoftGraphV1": "br:mcr.microsoft.com/bicep/extensions/microsoftgraph/v1.0:1.0.0" + } +} \ No newline at end of file diff --git a/onedrive-download/infra/main.bicep b/onedrive-download/infra/main.bicep new file mode 100644 index 00000000..fb907975 --- /dev/null +++ b/onedrive-download/infra/main.bicep @@ -0,0 +1,47 @@ +targetScope = 'subscription' + +@minLength(1) +@maxLength(64) +@description('Name of the environment that can be used as part of naming resource convention') +param environmentName string + +@minLength(1) +@description('Primary location for all resources') +@metadata({ + azd: { + type: 'location' + } +}) +param location string + +// Tags that should be applied to all resources. +// +// Note that 'azd-service-name' tags should be applied separately to service host resources. +// Example usage: +// tags: union(tags, { 'azd-service-name': }) +var tags = { + 'azd-env-name': environmentName +} + +// Organize resources in a resource group +resource rg 'Microsoft.Resources/resourceGroups@2021-04-01' = { + name: 'rg-${environmentName}' + location: location + tags: tags +} + +module resources 'resources.bicep' = { + scope: rg + name: 'resources' + params: { + location: location + tags: tags + azdServiceName: 'onedrive-download' + } +} + +output AZURE_RESOURCE_MCP_ONEDRIVE_DOWNLOAD_ID string = resources.outputs.AZURE_RESOURCE_MCP_ONEDRIVE_DOWNLOAD_ID +output AZURE_RESOURCE_MCP_ONEDRIVE_DOWNLOAD_NAME string = resources.outputs.AZURE_RESOURCE_MCP_ONEDRIVE_DOWNLOAD_NAME +output AZURE_RESOURCE_MCP_ONEDRIVE_DOWNLOAD_FQDN string = resources.outputs.AZURE_RESOURCE_MCP_ONEDRIVE_DOWNLOAD_FQDN +output AZURE_RESOURCE_MCP_ONEDRIVE_DOWNLOAD_GATEWAY_FQDN string = resources.outputs.AZURE_RESOURCE_MCP_ONEDRIVE_DOWNLOAD_GATEWAY_FQDN +output AZURE_CLIENT_ID string = resources.outputs.mcpAppId diff --git a/onedrive-download/infra/main.parameters.json b/onedrive-download/infra/main.parameters.json new file mode 100644 index 00000000..f1600cfb --- /dev/null +++ b/onedrive-download/infra/main.parameters.json @@ -0,0 +1,12 @@ +{ + "$schema": "https://schema.management.azure.com/schemas/2019-04-01/deploymentParameters.json#", + "contentVersion": "1.0.0.0", + "parameters": { + "environmentName": { + "value": "${AZURE_ENV_NAME}" + }, + "location": { + "value": "${AZURE_LOCATION}" + } + } +} diff --git a/onedrive-download/infra/modules/apim.bicep b/onedrive-download/infra/modules/apim.bicep new file mode 100644 index 00000000..56fa69b5 --- /dev/null +++ b/onedrive-download/infra/modules/apim.bicep @@ -0,0 +1,92 @@ +/** + * @module apim-v1 + * @description This module defines the Azure API Management (APIM) resources using Bicep. + * It includes configurations for creating and managing APIM instance. + * This is version 1 (v1) of the APIM Bicep module. + */ + +// ------------------ +// PARAMETERS +// ------------------ + + +@description('The name of the API Management instance. Defaults to "apim-".') +param apiManagementName string + +@description('The location of the API Management instance. Defaults to the resource group location.') +param location string = resourceGroup().location + +@description('The email address of the publisher. Defaults to "noreply@microsoft.com".') +param publisherEmail string = 'noreply@microsoft.com' + +@description('The name of the publisher. Defaults to "Microsoft".') +param publisherName string = 'Microsoft' + +@description('Name of the APIM Logger') +param apimLoggerName string = 'apim-logger' + +@description('Description of the APIM Logger') +param apimLoggerDescription string = 'APIM Logger for OpenAI API' + +@description('The pricing tier of this API Management service') +@allowed([ + 'Consumption' + 'Developer' + 'Basic' + 'Basicv2' + 'Standard' + 'Standardv2' + 'Premium' +]) +param apimSku string = 'Basicv2' + +@description('The instrumentation key for Application Insights') +param appInsightsInstrumentationKey string = '' + +@description('The resource ID for Application Insights') +param appInsightsId string = '' + +// ------------------ +// VARIABLES +// ------------------ + +// ------------------ +// RESOURCES +// ------------------ + +// https://learn.microsoft.com/azure/templates/microsoft.apimanagement/service +resource apimService 'Microsoft.ApiManagement/service@2024-06-01-preview' = { + name: apiManagementName + location: location + sku: { + name: apimSku + capacity: 1 + } + properties: { + publisherEmail: publisherEmail + publisherName: publisherName + } +} + +// Create a logger only if we have an App Insights ID and instrumentation key. +resource apimLogger 'Microsoft.ApiManagement/service/loggers@2021-12-01-preview' = if (!empty(appInsightsId) && !empty(appInsightsInstrumentationKey)) { + name: apimLoggerName + parent: apimService + properties: { + credentials: { + instrumentationKey: appInsightsInstrumentationKey + } + description: apimLoggerDescription + isBuffered: false + loggerType: 'applicationInsights' + resourceId: appInsightsId + } +} + +// ------------------ +// OUTPUTS +// ------------------ + +output id string = apimService.id +output name string = apimService.name +output gatewayUrl string = apimService.properties.gatewayUrl diff --git a/onedrive-download/infra/modules/downloads.policy.xml b/onedrive-download/infra/modules/downloads.policy.xml new file mode 100644 index 00000000..9e43dcb2 --- /dev/null +++ b/onedrive-download/infra/modules/downloads.policy.xml @@ -0,0 +1,41 @@ + + + + + + + + * + + + GET + HEAD + OPTIONS + + +
*
+
+
+ +
+ + + + + + + + * + + + GET, HEAD, OPTIONS + + + + + +
diff --git a/onedrive-download/infra/modules/mcp-api.bicep b/onedrive-download/infra/modules/mcp-api.bicep new file mode 100644 index 00000000..2940ebc3 --- /dev/null +++ b/onedrive-download/infra/modules/mcp-api.bicep @@ -0,0 +1,177 @@ +@description('The name of the API Management service') +param apimServiceName string + +@description('The name of the App Service hosting the MCP endpoints') +param functionAppName string + +@description('The ID of the MCP Entra application') +param mcpAppId string + +@description('The tenant ID of the MCP Entra application') +param mcpAppTenantId string + +// Get reference to the existing APIM service +resource apimService 'Microsoft.ApiManagement/service@2023-05-01-preview' existing = { + name: apimServiceName +} + +// Get reference to the App Service (Web App) +resource functionApp 'Microsoft.Web/sites@2023-12-01' existing = { + name: functionAppName +} + +// Create a named value in APIM to store the function key +resource functionHostKeyNamedValue 'Microsoft.ApiManagement/service/namedValues@2023-05-01-preview' = { + parent: apimService + name: 'function-host-key' + properties: { + displayName: 'function-host-key' + secret: true + value: listKeys('${functionApp.id}/host/default', functionApp.apiVersion).masterKey + } +} + +// Create or update named values for MCP OAuth configuration +resource mcpTenantIdNamedValue 'Microsoft.ApiManagement/service/namedValues@2021-08-01' = { + parent: apimService + name: 'McpTenantId' + properties: { + displayName: 'McpTenantId' + value: mcpAppTenantId + secret: false + } +} + +resource mcpClientIdNamedValue 'Microsoft.ApiManagement/service/namedValues@2021-08-01' = { + parent: apimService + name: 'McpClientId' + properties: { + displayName: 'McpClientId' + value: mcpAppId + secret: false + } +} + +// Create or update the APIM Gateway URL named value +resource APIMGatewayURLNamedValue 'Microsoft.ApiManagement/service/namedValues@2021-08-01' = { + parent: apimService + name: 'APIMGatewayURL' + properties: { + displayName: 'APIMGatewayURL' + value: apimService.properties.gatewayUrl + secret: false + } +} + + + +// Create the MCP API definition in APIM +resource mcpApi 'Microsoft.ApiManagement/service/apis@2023-05-01-preview' = { + parent: apimService + name: 'mcp' + properties: { + displayName: 'MCP API' + description: 'Model Context Protocol API endpoints' + subscriptionRequired: false + path: '/' + protocols: [ + 'https' + ] + serviceUrl: 'https://${functionApp.properties.defaultHostName}/' + } + dependsOn: [ + functionHostKeyNamedValue + ] +} + +// Apply policy at the API level for all operations +resource mcpApiPolicy 'Microsoft.ApiManagement/service/apis/policies@2023-05-01-preview' = { + parent: mcpApi + name: 'policy' + properties: { + format: 'rawxml' + value: loadTextContent('mcp-api.policy.xml') + } + dependsOn: [ + APIMGatewayURLNamedValue + mcpTenantIdNamedValue + mcpClientIdNamedValue + ] +} + +// Create the MCP Streamable HTTP protocol endpoints +resource mcpStreamableGetOperation 'Microsoft.ApiManagement/service/apis/operations@2023-05-01-preview' = { + parent: mcpApi + name: 'mcp-streamable-get' + properties: { + displayName: 'MCP Streamable GET Endpoint' + method: 'GET' + urlTemplate: '/mcp' + description: 'Streamable GET endpoint for MCP Server' + } +} + +resource mcpStreamablePostOperation 'Microsoft.ApiManagement/service/apis/operations@2023-05-01-preview' = { + parent: mcpApi + name: 'mcp-streamable-post' + properties: { + displayName: 'MCP Streamable POST Endpoint' + method: 'POST' + urlTemplate: '/mcp' + description: 'Streamable POST endpoint for MCP Server' + } +} + +// Create the Download proxy endpoint (bypass token validation) +resource downloadOperation 'Microsoft.ApiManagement/service/apis/operations@2023-05-01-preview' = { + parent: mcpApi + name: 'download-get' + properties: { + displayName: 'Download File' + method: 'GET' + urlTemplate: '/download' + description: 'Proxy endpoint for Azure File Share download with SAS token generation' + } +} + +// Apply policy to bypass token validation for download +resource downloadPolicy 'Microsoft.ApiManagement/service/apis/operations/policies@2023-05-01-preview' = { + parent: downloadOperation + name: 'policy' + properties: { + format: 'rawxml' + value: loadTextContent('downloads.policy.xml') + } +} + +// Create the PRM (Protected Resource Metadata) endpoint - RFC 9728 +resource mcpPrmOperation 'Microsoft.ApiManagement/service/apis/operations@2023-05-01-preview' = { + parent: mcpApi + name: 'mcp-prm' + properties: { + displayName: 'Protected Resource Metadata' + method: 'GET' + urlTemplate: '/.well-known/oauth-protected-resource' + description: 'Protected Resource Metadata endpoint (RFC 9728)' + } +} + +// Apply specific policy for the PRM endpoint (anonymous access) +resource mcpPrmPolicy 'Microsoft.ApiManagement/service/apis/operations/policies@2023-05-01-preview' = { + parent: mcpPrmOperation + name: 'policy' + properties: { + format: 'rawxml' + value: loadTextContent('mcp-prm.policy.xml') + } + dependsOn: [ + APIMGatewayURLNamedValue + mcpTenantIdNamedValue + mcpClientIdNamedValue + ] +} + +// Output the API ID for reference +output apiId string = mcpApi.id +output mcpAppId string = mcpAppId +output mcpAppTenantId string = mcpAppTenantId diff --git a/onedrive-download/infra/modules/mcp-api.policy.xml b/onedrive-download/infra/modules/mcp-api.policy.xml new file mode 100644 index 00000000..2427870f --- /dev/null +++ b/onedrive-download/infra/modules/mcp-api.policy.xml @@ -0,0 +1,52 @@ + + + + + + + * + + + GET + POST + OPTIONS + + +
*
+
+
+ + + + {{function-host-key}} + +
+ + + + + + + + + + + + + + Bearer error="invalid_token", resource_metadata="{{APIMGatewayURL}}/.well-known/oauth-protected-resource" + + + + + + +
\ No newline at end of file diff --git a/onedrive-download/infra/modules/mcp-entra-app.bicep b/onedrive-download/infra/modules/mcp-entra-app.bicep new file mode 100644 index 00000000..3eeb4c94 --- /dev/null +++ b/onedrive-download/infra/modules/mcp-entra-app.bicep @@ -0,0 +1,118 @@ +extension microsoftGraphV1 + +@description('The name of the MCP Entra application') +param mcpAppUniqueName string + +@description('The display name of the MCP Entra application') +param mcpAppDisplayName string + +@description('Tenant ID where the application is registered') +param tenantId string = tenant().tenantId + +@description('The principle id of the user-assigned managed identity') +param userAssignedIdentityPrincipleId string + +@description('The web app name for callback URL configuration') +param functionAppName string + +@description('Provide an array of Microsoft Graph scopes like "User.Read"') +param appScopes array = ['User.Read'] + +@description('Provide an array of Microsoft Graph roles like "Mail.Send"') +param appRoles array = ['Mail.Send'] + +var loginEndpoint = environment().authentication.loginEndpoint +var issuer = '${loginEndpoint}${tenantId}/v2.0' + +// Microsoft Graph app ID +var graphAppId = '00000003-0000-0000-c000-000000000000' +var msGraphAppId = graphAppId + +// VS Code app ID +var vscodeAppId = 'aebc6443-996d-45c2-90f0-388ff96faa56' + +// Get the Microsoft Graph service principal so that the scope names +// can be looked up and mapped to a permission ID +resource msGraphSP 'Microsoft.Graph/servicePrincipals@v1.0' existing = { + appId: graphAppId +} + +var graphScopes = msGraphSP.oauth2PermissionScopes +var graphRoles = msGraphSP.appRoles + +var scopes = map(filter(graphScopes, scope => contains(appScopes, scope.value)), scope => { + id: scope.id + type: 'Scope' +}) +var roles = map(filter(graphRoles, role => contains(appRoles, role.value)), role => { + id: role.id + type: 'Role' +}) + +var permissionId = guid(mcpAppUniqueName, 'user_impersonation') +resource mcpEntraApp 'Microsoft.Graph/applications@v1.0' = { + displayName: mcpAppDisplayName + uniqueName: mcpAppUniqueName + // ★ 모든 Microsoft 사용자가 로그인할 수 있도록 설정 + signInAudience: 'AzureADandPersonalMicrosoftAccount' + api: { + oauth2PermissionScopes: [ + { + id: permissionId + adminConsentDescription: 'Allows the application to access MCP resources on behalf of the signed-in user' + adminConsentDisplayName: 'Access MCP resources' + isEnabled: true + type: 'User' + userConsentDescription: 'Allows the app to access MCP resources on your behalf' + userConsentDisplayName: 'Access MCP resources' + value: 'user_impersonation' + } + ] + requestedAccessTokenVersion: 2 + preAuthorizedApplications: [ + { + appId: vscodeAppId + delegatedPermissionIds: [ + guid(mcpAppUniqueName, 'user_impersonation') + ] + } + ] + } + // Parameterized Microsoft Graph delegated scopes based on appScopes + requiredResourceAccess: [ + { + resourceAppId: msGraphAppId // Microsoft Graph + resourceAccess: concat(scopes, roles) + } + ] + + // ★ 모바일/데스크톱 앱용 Public Client (VSCode용) + publicClient: { + redirectUris: [ + 'http://localhost' + 'http://127.0.0.1' + 'https://vscode.dev/redirect' + ] + } + + // ★★★ 공용 클라이언트 흐름 허용 (OAuth2 Authorization Code Flow for Public Clients) + isFallbackPublicClient: true + + resource fic 'federatedIdentityCredentials@v1.0' = { + name: '${mcpEntraApp.uniqueName}/msiAsFic' + description: 'Trust the user-assigned MI as a credential for the MCP app' + audiences: [ + 'api://AzureADTokenExchange' + ] + issuer: issuer + subject: userAssignedIdentityPrincipleId + } +} + +resource applicationRegistrationServicePrincipal 'Microsoft.Graph/servicePrincipals@v1.0' = { + appId: mcpEntraApp.appId +} + +// Outputs +output mcpAppId string = mcpEntraApp.appId +output mcpAppTenantId string = tenantId diff --git a/onedrive-download/infra/modules/mcp-prm.policy.xml b/onedrive-download/infra/modules/mcp-prm.policy.xml new file mode 100644 index 00000000..182d0e3d --- /dev/null +++ b/onedrive-download/infra/modules/mcp-prm.policy.xml @@ -0,0 +1,46 @@ + + + + + + + + + + + + application/json + + + + { + "authorization_endpoint": "https://login.microsoftonline.com/{{context.Variables["prm_tenant_id"]}}/oauth2/v2.0/authorize", + "token_endpoint": "https://login.microsoftonline.com/{{context.Variables["prm_tenant_id"]}}/oauth2/v2.0/token", + + "client_id": "{{context.Variables["prm_client_id"]}}", + + "scopes_supported": ["https://graph.microsoft.com/.default", "offline_access"], + "scopes_required": ["https://graph.microsoft.com/.default"], + "response_types_supported": ["code"], + "code_challenge_methods_supported": ["S256"], + "token_endpoint_auth_methods_supported": ["none"], + "grant_types_supported": ["authorization_code", "refresh_token"] + } + + + + + + + + + + + + + \ No newline at end of file diff --git a/onedrive-download/infra/resources.bicep b/onedrive-download/infra/resources.bicep new file mode 100644 index 00000000..932f1081 --- /dev/null +++ b/onedrive-download/infra/resources.bicep @@ -0,0 +1,276 @@ +@description('The location used for all deployed resources') +param location string = resourceGroup().location + +@description('Tags that will be applied to all resources') +param tags object = {} + +@description('The name of the service defined in azure.yaml.') +param azdServiceName string + + +var abbrs = loadJsonContent('./abbreviations.json') +var resourceToken = uniqueString(subscription().id, resourceGroup().id, location) +var functionAppName = '${abbrs.webSitesFunctions}${azdServiceName}-${resourceToken}' +var deploymentStorageContainerName = 'app-package-${take(functionAppName, 32)}-${take(toLower(uniqueString(functionAppName, resourceToken)), 7)}' + +// 1. Monitoring +module monitoring 'br/public:avm/ptn/azd/monitoring:0.1.0' = { + name: 'monitoring' + params: { + logAnalyticsName: '${abbrs.operationalInsightsWorkspaces}${resourceToken}' + applicationInsightsName: '${abbrs.insightsComponents}${resourceToken}' + location: location + tags: tags + } +} + +// API Management +module apimService './modules/apim.bicep' = { + name: 'apimService' + params:{ + apiManagementName: '${abbrs.apiManagementService}${resourceToken}' + } +} + +// 2. Storage account for the function app +resource storageAccount 'Microsoft.Storage/storageAccounts@2022-09-01' = { + name: '${abbrs.storageStorageAccounts}${resourceToken}' + location: location + tags: tags + sku: { + name: 'Standard_LRS' + } + kind: 'StorageV2' + properties: { + minimumTlsVersion: 'TLS1_2' + supportsHttpsTrafficOnly: true + allowBlobPublicAccess: false + largeFileSharesState: 'Enabled' + } + + resource blobServices 'blobServices' = { + name: 'default' + resource container 'containers' = { + name: deploymentStorageContainerName + properties: { + publicAccess: 'None' + } + } + } + + // ★ File Share 추가: downloads 폴더를 마운트 가능하게 함 + resource fileServices 'fileServices' = { + name: 'default' + resource share 'shares' = { + name: 'downloads' + properties: { + shareQuota: 1024 + enabledProtocols: 'SMB' + } + } + } +} + +// 3. User-assigned managed identity for the function app +resource userAssignedIdentity 'Microsoft.ManagedIdentity/userAssignedIdentities@2023-01-31' = { + name: '${abbrs.managedIdentityUserAssignedIdentities}${azdServiceName}-${resourceToken}' + location: location + tags: tags +} + +// 4. App Service plan (Flex Consumption) +resource appServicePlan 'Microsoft.Web/serverfarms@2023-12-01' = { + name: '${abbrs.webServerFarms}${resourceToken}' + location: location + tags: tags + sku: { + name: 'FC1' + tier: 'FlexConsumption' + } + properties: { + reserved: true // Required for Linux + } +} + +// 5. The Function App (Flex Consumption용) +resource functionApp 'Microsoft.Web/sites@2023-12-01' = { + name: functionAppName + location: location + kind: 'functionapp,linux' + tags: union(tags, { 'azd-service-name': azdServiceName }) + identity: { + type: 'UserAssigned' + userAssignedIdentities: { + '${userAssignedIdentity.id}': {} + } + } + properties: { + serverFarmId: appServicePlan.id + httpsOnly: true + + // ★ Flex Consumption 필수: functionAppConfig + functionAppConfig: { + deployment: { + storage: { + type: 'blobContainer' + value: '${storageAccount.properties.primaryEndpoints.blob}${deploymentStorageContainerName}' + authentication: { + type: 'UserAssignedIdentity' + userAssignedIdentityResourceId: userAssignedIdentity.id + } + } + } + scaleAndConcurrency: { + instanceMemoryMB: 2048 + maximumInstanceCount: 100 + } + runtime: { + name: 'dotnet-isolated' + version: '9.0' + } + } + + siteConfig: { + alwaysOn: false + + // ★★★ CORS 설정 (VS Code 접속 허용) ★★★ + cors: { + allowedOrigins: ['*'] + } + + // ★★★ 핵심: 스토리지 마운트 설정 ★★★ + azureStorageAccounts: { + 'downloads-mount': { + type: 'AzureFiles' + accountName: storageAccount.name + shareName: 'downloads' + mountPath: '/mount/downloads' + accessKey: storageAccount.listKeys().keys[0].value + } + } + appSettings: [ + // ★ Flex Consumption 필수 설정 + { + name: 'WEBSITE_FUNCTIONS_MESSAGING_EXTENSION_VERSION' + value: '~4' + } + // ★ AzureWebJobsStorage는 Full Connection String으로 제공 + { + name: 'AzureWebJobsStorage' + value: 'DefaultEndpointsProtocol=https;AccountName=${storageAccount.name};AccountKey=${storageAccount.listKeys().keys[0].value};EndpointSuffix=${environment().suffixes.storage}' + } + { + name: 'APPLICATIONINSIGHTS_CONNECTION_STRING' + value: monitoring.outputs.applicationInsightsConnectionString + } + { + name: 'ApplicationInsightsAgent_EXTENSION_VERSION' + value: '~3' + } + { + name: 'XDT_MicrosoftApplicationInsights_Mode' + value: 'recommended' + } + // ★ 다운로드 경로 설정 (마운트된 경로와 일치) + { + name: 'DOWNLOAD_DIR' + value: '/mount/downloads' + } + // ★ RFC 9728 OAuth Protected Resource Metadata를 위한 인증 설정 + { + name: 'OnedriveDownload__Auth__TenantId' + value: tenant().tenantId + } + { + name: 'OnedriveDownload__Auth__ClientId' + value: entraApp.outputs.mcpAppId + } + { + name: 'AZURE_STORAGE_CONNECTION_STRING' + value: 'DefaultEndpointsProtocol=https;AccountName=${storageAccount.name};AccountKey=${storageAccount.listKeys().keys[0].value};EndpointSuffix=${environment().suffixes.storage}' + } + // ★ APIM 프록시 URL을 위한 FQDN + { + name: 'APIM_FQDN' + value: replace(apimService.outputs.gatewayUrl, 'https://', '') + } + ] + } + } +} + +// MCP Entra App +module entraApp './modules/mcp-entra-app.bicep' = { + name: 'mcpEntraApp' + params: { + mcpAppUniqueName: 'mcp-onedrivedownload-${resourceToken}' + mcpAppDisplayName: 'MCP-OneDriveDownload-${resourceToken}' + userAssignedIdentityPrincipleId: userAssignedIdentity.properties.principalId + functionAppName: functionAppName + appScopes: [ + 'User.Read' + 'Files.Read.All' + 'offline_access' + ] + appRoles: [] + } +} + +// ★ Built-in Authentication 설정 (VSCode 팝업을 위해 필수) +// authSettingsV2를 entraApp 이후에 정의 (dependency 순서) +// ★★★ [토큰 패스스루 방식] Easy Auth 완전 해제 ★★★ +// 인증은 Program.cs의 미들웨어에서 처리하므로, Azure 서버 레벨의 문지기는 끕니다. +// 이렇게 하면 VS Code가 받은 Graph용 토큰이 서버 코드까지 무사히 도착합니다. +resource authSettingsV2 'Microsoft.Web/sites/config@2023-12-01' = { + parent: functionApp + name: 'authsettingsV2' + dependsOn: [ + entraApp // entraApp이 먼저 생성되어야 함 + ] + properties: { + // 1. 인증 기능 비활성화 (서버 레벨 인증 끔) + platform: { + enabled: false + } + } +} + +// MCP server API endpoints +module mcpApiModule './modules/mcp-api.bicep' = { + name: 'mcpApiModule' + params: { + apimServiceName: apimService.outputs.name + functionAppName: functionAppName + mcpAppId: entraApp.outputs.mcpAppId + mcpAppTenantId: entraApp.outputs.mcpAppTenantId + } + dependsOn: [ + functionApp + ] +} + +// Grant the function app's identity access to the storage account +var storageBlobDataOwnerRole = subscriptionResourceId('Microsoft.Authorization/roleDefinitions', 'b7e6dc6d-f1e8-4753-8033-0f276bb0955b') + +resource rbac 'Microsoft.Authorization/roleAssignments@2022-04-01' = { + name: guid(storageAccount.id, userAssignedIdentity.id, storageBlobDataOwnerRole) + scope: storageAccount + properties: { + principalId: userAssignedIdentity.properties.principalId + principalType: 'ServicePrincipal' + roleDefinitionId: storageBlobDataOwnerRole + } +} + +// Outputs for azd +output AZURE_RESOURCE_MCP_ONEDRIVE_DOWNLOAD_ID string = functionApp.id +output AZURE_RESOURCE_MCP_ONEDRIVE_DOWNLOAD_NAME string = functionApp.name +output AZURE_RESOURCE_MCP_ONEDRIVE_DOWNLOAD_FQDN string = functionApp.properties.defaultHostName +output AZURE_RESOURCE_MCP_ONEDRIVE_DOWNLOAD_GATEWAY_FQDN string = replace(apimService.outputs.gatewayUrl, 'https://', '') +output AZURE_USER_ASSIGNED_IDENTITY_PRINCIPAL_ID string = userAssignedIdentity.properties.principalId +output mcpAppId string = entraApp.outputs.mcpAppId +// ★ postprovision 훅에서 mcp.json에 주입할 Client ID +// 이 이름(AZURE_CLIENT_ID)이 스크립트에서 $env:AZURE_CLIENT_ID 가 됩니다. +output AZURE_CLIENT_ID string = entraApp.outputs.mcpAppId +// This output is no longer relevant, but keeping it to avoid breaking main.bicep for now. I will fix main.bicep next. +output AZURE_CONTAINER_REGISTRY_ENDPOINT string = '' diff --git a/onedrive-download/local.settings.json b/onedrive-download/local.settings.json new file mode 100644 index 00000000..a079a680 --- /dev/null +++ b/onedrive-download/local.settings.json @@ -0,0 +1,7 @@ +{ + "IsEncrypted": false, + "Values": { + "FUNCTIONS_WORKER_RUNTIME": "custom", + "FUNCTIONS_CUSTOMHANDLER_PORT": "5260" + } +} diff --git a/onedrive-download/scripts/mount.sh b/onedrive-download/scripts/mount.sh new file mode 100644 index 00000000..33286d87 --- /dev/null +++ b/onedrive-download/scripts/mount.sh @@ -0,0 +1,111 @@ +#!/bin/bash +# Azure File Share 자동 마운트 스크립트 (Mac/Linux) +# azd postprovision 훅에서 자동으로 실행됩니다. + +set -e + +echo "🔄 Azure File Share 로컬 마운트 시작..." >&2 + +# 1. azd 환경변수에서 연결 문자열 가져오기 +echo "✓ azd 환경 변수에서 연결 문자열 추출 중..." >&2 + +CONN_STRING=$(azd env get-values | grep '^AZURE_STORAGE_CONNECTION_STRING=' | cut -d'=' -f2- | sed 's/^"//;s/"$//') + +if [ -z "$CONN_STRING" ]; then + echo "❌ 스토리지 연결 문자열을 찾을 수 없습니다." >&2 + exit 1 +fi + +echo "✓ 연결 문자열 추출 완료" >&2 + +# 2. AccountName과 AccountKey 파싱 +ACCOUNT_NAME=$(echo "$CONN_STRING" | grep -o 'AccountName=[^;]*' | cut -d'=' -f2) +ACCOUNT_KEY=$(echo "$CONN_STRING" | grep -o 'AccountKey=[^;]*' | cut -d'=' -f2) + +if [ -z "$ACCOUNT_NAME" ] || [ -z "$ACCOUNT_KEY" ]; then + echo "❌ 계정 정보를 파싱할 수 없습니다." >&2 + echo " AccountName: $ACCOUNT_NAME" >&2 + echo " AccountKey: ${ACCOUNT_KEY:0:10}..." >&2 + exit 1 +fi + +echo "✓ 계정 정보 추출 완료 (Account: $ACCOUNT_NAME)" >&2 + +# 3. 마운트 설정 +SHARE_NAME="downloads" +HOME_DIR=$(eval echo ~) +MOUNT_PATH="$HOME_DIR/Downloads/azure" + +# 4. 마운트 폴더 생성 +if [ ! -d "$MOUNT_PATH" ]; then + echo "📁 마운트 폴더 생성 중: $MOUNT_PATH" >&2 + mkdir -p "$MOUNT_PATH" || { + echo "❌ 마운트 폴더 생성 실패" >&2 + exit 1 + } +fi + +# 5. Mac인지 Linux인지 확인 +if [ "$(uname)" = "Darwin" ]; then + # ===== Mac 마운트 ===== + echo "🍎 Mac 환경에서 마운트를 시도합니다." >&2 + + SMB_URL="smb://$ACCOUNT_NAME:$ACCOUNT_KEY@$ACCOUNT_NAME.file.core.windows.net/$SHARE_NAME" + + echo "⚡ 마운트 시도: $SMB_URL -> $MOUNT_PATH" >&2 + + # 기존 마운트 해제 + if mount | grep -q "$MOUNT_PATH"; then + echo "⚠️ 기존 마운트 해제 중..." >&2 + diskutil unmount "$MOUNT_PATH" 2>/dev/null || sudo umount "$MOUNT_PATH" 2>/dev/null || true + fi + + # Mac용 mount_smbfs 사용 + if mount_smbfs "$SMB_URL" "$MOUNT_PATH" 2>/dev/null; then + echo "✅ Mac 마운트 성공! [$MOUNT_PATH]" >&2 + open "$MOUNT_PATH" 2>/dev/null || true + echo "🎉 Finder가 열렸습니다." >&2 + else + echo "❌ Mac 마운트 실패" >&2 + echo " 'mount_smbfs'를 사용할 수 없습니다." >&2 + echo " 대신 Finder > 이동 > 서버에 연결에서 수동으로 연결하세요:" >&2 + echo " $SMB_URL" >&2 + exit 1 + fi + +else + # ===== Linux 마운트 ===== + echo "🐧 Linux 환경에서 마운트를 시도합니다." >&2 + + # cifs-utils 확인 + if ! command -v mount.cifs &> /dev/null; then + echo "❌ cifs-utils가 설치되어 있지 않습니다." >&2 + echo " 다음 명령어로 설치해주세요:" >&2 + echo " sudo apt-get install cifs-utils (Ubuntu/Debian)" >&2 + echo " sudo yum install cifs-utils (CentOS/RHEL)" >&2 + exit 1 + fi + + UNC_PATH="//$ACCOUNT_NAME.file.core.windows.net/$SHARE_NAME" + + echo "⚡ 마운트 시도: $UNC_PATH -> $MOUNT_PATH" >&2 + + # sudo 권한으로 마운트 + # 주의: azd가 sudo를 요청하면 패스워드 입력이 필요할 수 있습니다. + if sudo mount -t cifs "$UNC_PATH" "$MOUNT_PATH" \ + -o username=$ACCOUNT_NAME,password=$ACCOUNT_KEY,vers=3.0,dir_mode=0755,file_mode=0755 2>/dev/null; then + echo "✅ Linux 마운트 성공! [$MOUNT_PATH]" >&2 + echo "🎉 파일 탐색기로 폴더를 열어보세요:" >&2 + echo " $MOUNT_PATH" >&2 + else + echo "⚠️ Linux 마운트 실패 (sudo 권한이 필요할 수 있습니다)" >&2 + echo " 수동으로 마운트하려면 다음 명령을 실행하세요:" >&2 + echo " sudo mount -t cifs \"$UNC_PATH\" \"$MOUNT_PATH\" \\" >&2 + echo " -o username=$ACCOUNT_NAME,password=,vers=3.0,dir_mode=0755,file_mode=0755" >&2 + exit 1 + fi +fi + +echo "" +echo "✅ Azure File Share 마운트 완료!" >&2 +echo " 이제 OneDrive에서 다운로드한 파일이 $MOUNT_PATH에 저장됩니다." >&2 diff --git a/onedrive-download/src/McpSamples.OnedriveDownload.HybridApp/.azurefunctions/.gitkeep b/onedrive-download/src/McpSamples.OnedriveDownload.HybridApp/.azurefunctions/.gitkeep new file mode 100644 index 00000000..8b137891 --- /dev/null +++ b/onedrive-download/src/McpSamples.OnedriveDownload.HybridApp/.azurefunctions/.gitkeep @@ -0,0 +1 @@ + diff --git a/onedrive-download/src/McpSamples.OnedriveDownload.HybridApp/Configurations/OnedriveDownloadAppSettings.cs b/onedrive-download/src/McpSamples.OnedriveDownload.HybridApp/Configurations/OnedriveDownloadAppSettings.cs new file mode 100644 index 00000000..1a6be006 --- /dev/null +++ b/onedrive-download/src/McpSamples.OnedriveDownload.HybridApp/Configurations/OnedriveDownloadAppSettings.cs @@ -0,0 +1,51 @@ +using McpSamples.Shared.Configurations; + +namespace McpSamples.OnedriveDownload.HybridApp.Configurations; + +/// +/// This represents the application settings for the onedrive-download app. +/// +public class OnedriveDownloadAppSettings : AppSettings +{ + /// + /// Gets or sets the instance. + /// + public EntraIdSettings EntraId { get; set; } = new EntraIdSettings(Environment.GetEnvironmentVariable("AZURE_CLIENT_ID")); +} + +/// +/// This represents the Entra ID settings. +/// +/// The user-assigned client ID from AZURE_CLIENT_ID environment variable. +public class EntraIdSettings(string? userAssignedClientId = default) +{ + /// + /// Gets or sets the tenant ID. + /// + public string? TenantId { get; set; } + + /// + /// Gets or sets the client ID for Personal OneDrive OAuth. + /// + public string? ClientId { get; set; } + + /// + /// Gets or sets the client secret. + /// + public string? ClientSecret { get; set; } + + /// + /// Gets the user-assigned client ID from Azure Managed Identity. + /// + public string? UserAssignedClientId { get; } = userAssignedClientId; + + /// + /// Gets the value indicating whether to use the managed identity or not. + /// + public bool UseManagedIdentity { get; } = string.IsNullOrWhiteSpace(userAssignedClientId) == false; + + /// + /// Gets or sets the Personal 365 refresh token for OneDrive access. + /// + public string? Personal365RefreshToken { get; set; } +} \ No newline at end of file diff --git a/onedrive-download/src/McpSamples.OnedriveDownload.HybridApp/Constants.cs b/onedrive-download/src/McpSamples.OnedriveDownload.HybridApp/Constants.cs new file mode 100644 index 00000000..1c514f7b --- /dev/null +++ b/onedrive-download/src/McpSamples.OnedriveDownload.HybridApp/Constants.cs @@ -0,0 +1,27 @@ +namespace McpSamples.OnedriveDownload.HybridApp; + +/// +/// This represents the entity containing all the magic numbers and strings. +/// +public class Constants +{ + /// + /// The default scope for Microsoft Graph API. + /// + public const string DefaultScope = "https://graph.microsoft.com/.default"; + + /// + /// The environment variable key for Azure Functions Custom Handler Port. + /// + public const string AzureFunctionsCustomHandlerPortEnvironmentKey = "FUNCTIONS_CUSTOMHANDLER_PORT"; + + /// + /// The default port for the custom handler. + /// + public const int DefaultAppPort = 5260; + + /// + /// The default URL for the application. + /// + public const string DefaultAppUrl = "http://0.0.0.0:{0}"; +} diff --git a/onedrive-download/src/McpSamples.OnedriveDownload.HybridApp/McpSamples.OnedriveDownload.HybridApp.csproj b/onedrive-download/src/McpSamples.OnedriveDownload.HybridApp/McpSamples.OnedriveDownload.HybridApp.csproj new file mode 100644 index 00000000..f618790d --- /dev/null +++ b/onedrive-download/src/McpSamples.OnedriveDownload.HybridApp/McpSamples.OnedriveDownload.HybridApp.csproj @@ -0,0 +1,37 @@ + + + + net9.0 + enable + enable + 53a97ea1-8708-44a3-b810-38b77f5d6001 + + + + + + + + + + + + + + + + + + + + + PreserveNewest + + + + + + + + + diff --git a/onedrive-download/src/McpSamples.OnedriveDownload.HybridApp/Program.cs b/onedrive-download/src/McpSamples.OnedriveDownload.HybridApp/Program.cs new file mode 100644 index 00000000..883d736b --- /dev/null +++ b/onedrive-download/src/McpSamples.OnedriveDownload.HybridApp/Program.cs @@ -0,0 +1,332 @@ +using Azure.Core; +using Azure.Identity; +using Azure.Storage.Files.Shares; +using Azure.Storage.Sas; +using System.Text.Json; + +using McpSamples.OnedriveDownload.HybridApp.Configurations; +using Microsoft.Extensions.Logging; +using Microsoft.ApplicationInsights.DependencyCollector; +using McpSamples.OnedriveDownload.HybridApp.Services; +using McpSamples.OnedriveDownload.HybridApp.Tools; +using McpSamples.Shared.Configurations; +using McpSamples.Shared.Extensions; +using McpSamples.Shared.OpenApi; +using Microsoft.AspNetCore.Builder; +using Microsoft.Graph; +using Microsoft.AspNetCore.WebUtilities; + +using Constants = McpSamples.OnedriveDownload.HybridApp.Constants; + +var useStreamableHttp = AppSettings.UseStreamableHttp(Environment.GetEnvironmentVariables(), args); + +// Force HTTP mode unless --stdio is explicitly passed +if (!args.Contains("--stdio", StringComparer.InvariantCultureIgnoreCase)) +{ + useStreamableHttp = true; +} + +IHostApplicationBuilder builder = useStreamableHttp + ? Microsoft.AspNetCore.Builder.WebApplication.CreateBuilder(args) + : Host.CreateApplicationBuilder(args); + +builder.Services.AddAppSettings(builder.Configuration, args); + +// ★ HttpClient 등록 (토큰 교환 프록시용) +builder.Services.AddHttpClient(); + +// ★ Token Passthrough: 클라이언트(VSCode)가 보낸 Authorization 헤더에서 토큰을 꺼내 사용 +// VSCode에서 Microsoft 인증을 하면 팝업이 뜨고, 토큰을 요청 헤더에 포함시켜 보냄 +builder.Services.AddScoped(sp => +{ + var httpContextAccessor = sp.GetRequiredService(); + var httpContext = httpContextAccessor.HttpContext; + + // ★★★ [핵심] /authorize 요청이면 토큰 검사 건너뛰기 (리다이렉트 될 것이므로) + if (httpContext != null && httpContext.Request.Path.Value!.StartsWith("/authorize", StringComparison.OrdinalIgnoreCase)) + { + return new GraphServiceClient(new AnonymousTokenCredential()); + } + + // 요청 헤더에서 Authorization: Bearer 형태로 토큰을 꺼냄 + string? authHeader = httpContext?.Request.Headers.Authorization.ToString(); + string? accessToken = null; + + if (!string.IsNullOrEmpty(authHeader) && authHeader.StartsWith("Bearer ", StringComparison.OrdinalIgnoreCase)) + { + accessToken = authHeader.Substring("Bearer ".Length).Trim(); + } + + // 토큰이 없으면 Anonymous 반환 (미들웨어에서 검증) + if (string.IsNullOrEmpty(accessToken)) + { + return new GraphServiceClient(new AnonymousTokenCredential()); + } + + Console.WriteLine("[인증 성공] 클라이언트가 토큰을 보냈습니다!"); + + // 헤더에서 꺼낸 토큰을 사용하여 GraphServiceClient 생성 + TokenCredential credential = new BearerTokenCredential(accessToken); + string[] scopes = [ Constants.DefaultScope ]; + var client = new GraphServiceClient(credential, scopes); + + return client; +}); + +// Add Application Insights for proper Azure logging +if (useStreamableHttp == true) +{ + builder.Services.AddApplicationInsightsTelemetry(); + builder.Logging.AddApplicationInsights(); +} + +if (useStreamableHttp == true) +{ + var port = Environment.GetEnvironmentVariable(Constants.AzureFunctionsCustomHandlerPortEnvironmentKey) ?? $"{Constants.DefaultAppPort}"; + (builder as Microsoft.AspNetCore.Builder.WebApplicationBuilder)!.WebHost.UseUrls(string.Format(Constants.DefaultAppUrl, port)); + + Console.WriteLine($"[INFO] Listening on port {port}"); + builder.Services.AddHttpContextAccessor(); +} + +// Add Azure Key Vault configuration +var keyVaultName = builder.Configuration["KeyVaultName"]; +if (!string.IsNullOrEmpty(keyVaultName)) +{ + try + { + builder.Configuration.AddAzureKeyVault( + new Uri($"https://{keyVaultName}.vault.azure.net/"), + new DefaultAzureCredential()); + } + catch (Exception ex) + { + // Log but don't fail - Key Vault might not be available in all environments + Console.WriteLine($"[WARNING] Failed to load Key Vault: {ex.Message}"); + } +} + +// Add authentication service +builder.Services.AddScoped(); + +// Add Azure File Share Sync Service +builder.Services.AddSingleton(); + +if (useStreamableHttp == true) +{ + builder.Services.AddOpenApi("swagger", o => + { + o.OpenApiVersion = Microsoft.OpenApi.OpenApiSpecVersion.OpenApi2_0; + o.AddDocumentTransformer>(); + }); + builder.Services.AddOpenApi("openapi", o => + { + o.OpenApiVersion = Microsoft.OpenApi.OpenApiSpecVersion.OpenApi3_0; + o.AddDocumentTransformer>(); + }); +} + +IHost app = builder.BuildApp(useStreamableHttp); + +if (useStreamableHttp == true) +{ + var webApp = (app as Microsoft.AspNetCore.Builder.WebApplication)!; + + // ========================================================================= + // 1. 미들웨어 설정 (인증, CORS, 로깅) + // ========================================================================= + webApp.Use(async (context, next) => + { + var path = context.Request.Path.Value!; + var method = context.Request.Method; + + // 1. CORS 헤더 강제 주입 (필수) + context.Response.Headers.Append("Access-Control-Allow-Origin", "*"); + context.Response.Headers.Append("Access-Control-Allow-Methods", "GET, POST, OPTIONS"); + context.Response.Headers.Append("Access-Control-Allow-Headers", "Content-Type, Authorization"); + + // 2. OPTIONS (노크) 요청은 무조건 통과 + if (method.Equals("OPTIONS", StringComparison.OrdinalIgnoreCase)) + { + context.Response.StatusCode = 200; + return; + } + + // 3. ★★★ [핵심 수정] 토큰 검사 면제 목록에 '/download' 추가 ★★★ + // 이 줄이 없어서 브라우저가 다운로드하러 들어갔다가 쫓겨난(401/404) 겁니다. + if (path.StartsWith("/authorize", StringComparison.OrdinalIgnoreCase) || + path.StartsWith("/token", StringComparison.OrdinalIgnoreCase) || + path.StartsWith("/download", StringComparison.OrdinalIgnoreCase) || // <--- ★ 여기 추가됨! + path.StartsWith("/list-files", StringComparison.OrdinalIgnoreCase) || // <--- ★ 디버깅용도 추가 + (path == "/" && context.Request.Query.ContainsKey("code")) || + path.StartsWith("/.well-known", StringComparison.OrdinalIgnoreCase) || + path == "/") + { + await next(); + return; + } + + // 4. 나머지 API 요청(MCP 등)은 토큰 검사 + string? authHeader = context.Request.Headers.Authorization.ToString(); + if (string.IsNullOrEmpty(authHeader)) + { + Console.WriteLine($"❌ [토큰 없음] {method} {path}"); + context.Response.Headers.Append("WWW-Authenticate", "Bearer realm=\"mcp\""); + context.Response.StatusCode = 401; + await context.Response.WriteAsync("Unauthorized: Access Token is required."); + return; + } + + Console.WriteLine($"🔑 [토큰 수신] {authHeader.Substring(0, Math.Min(authHeader.Length, 15))}..."); + await next(); + }); + + // ========================================================================= + // 2. [핵심] 토큰 교환 대행 (Proxy) 엔드포인트 (/token) + // VS Code가 서버로 잘못 보낸 토큰 요청을 MS로 대신 전달해 줍니다. + // ========================================================================= + webApp.MapPost("/token", async (HttpContext context, IHttpClientFactory httpClientFactory) => + { + Console.WriteLine("🔄 [Proxy] VS Code가 보낸 토큰 요청을 MS로 전달합니다..."); + try + { + // 폼 데이터 읽기 + var form = await context.Request.ReadFormAsync(); + var formDict = form.ToDictionary(x => x.Key, x => x.Value.ToString()); + + // Microsoft로 요청 전달 + var client = httpClientFactory.CreateClient(); + var msTokenUrl = "https://login.microsoftonline.com/common/oauth2/v2.0/token"; + var requestContent = new FormUrlEncodedContent(formDict); + + var response = await client.PostAsync(msTokenUrl, requestContent); + var responseString = await response.Content.ReadAsStringAsync(); + + // 결과 반환 + context.Response.StatusCode = (int)response.StatusCode; + context.Response.ContentType = "application/json"; + await context.Response.WriteAsync(responseString); + Console.WriteLine($"✅ [Proxy] 토큰 교환 완료! 상태: {response.StatusCode}"); + } + catch (Exception ex) + { + Console.WriteLine($"💥 [Proxy 실패] {ex.Message}"); + context.Response.StatusCode = 500; + await context.Response.WriteAsync(JsonSerializer.Serialize(new { error = ex.Message })); + } + }); + + // ========================================================================= + // 3. [복구됨] 로그인 리다이렉트 엔드포인트 (/authorize) + // 아까 이 부분이 지워져서 404가 떴던 겁니다. + // ========================================================================= + webApp.MapGet("/authorize", (HttpContext context) => + { + var queryString = context.Request.QueryString.ToString(); + + // 안전장치: scope 강제 주입 + if (!queryString.Contains("scope=", StringComparison.OrdinalIgnoreCase) && + !queryString.Contains("scope%3D", StringComparison.OrdinalIgnoreCase)) + { + queryString += string.IsNullOrEmpty(queryString) ? "?" : "&"; + queryString += "scope=https%3A%2F%2Fgraph.microsoft.com%2F.default"; + } + + // MS 로그인 페이지로 리다이렉트 (VS Code가 보낸 포트 정보 유지) + var authUrl = $"https://login.microsoftonline.com/common/oauth2/v2.0/authorize{queryString}"; + + Console.WriteLine($"🔐 [인증] 리다이렉트: {authUrl}"); + context.Response.Redirect(authUrl); + return Task.CompletedTask; + }); + + // ========================================================================= + // 4. 인증 성공 화면 (/) + // ========================================================================= + webApp.MapGet("/", async (HttpContext context) => + { + context.Response.ContentType = "text/html; charset=utf-8"; + await context.Response.WriteAsync(@" + +

✓ 인증 성공!

+

VS Code로 돌아가세요.

+ + "); + }); + + // ========================================================================= + // 5. 다운로드 리다이렉트 핸들러 (/download) + // ========================================================================= + webApp.MapGet("/download", async (HttpContext context) => + { + var fileName = context.Request.Query["file"].ToString(); + if (string.IsNullOrEmpty(fileName)) + { + context.Response.StatusCode = 400; + await context.Response.WriteAsync("Error: 'file' parameter is missing."); + return; + } + + try + { + var config = context.RequestServices.GetRequiredService(); + var connectionString = config["AZURE_STORAGE_CONNECTION_STRING"]; + + if (string.IsNullOrEmpty(connectionString)) + { + await context.Response.WriteAsync("Error: Storage connection string not configured."); + return; + } + + var shareClient = new ShareClient(connectionString, "downloads"); + var fileClient = shareClient.GetRootDirectoryClient().GetFileClient(fileName); + + // 1. 파일이 진짜 있는지 서버에서 먼저 확인 (없으면 여기서 404) + if (!await fileClient.ExistsAsync()) + { + context.Response.StatusCode = 404; + await context.Response.WriteAsync($"Error: File '{fileName}' not found in 'downloads' share."); + return; + } + + // 2. ★★★ [핵심] 10분짜리 임시 출입증(SAS) 생성 ★★★ + // 이 부분이 없어서 아까 404가 떴던 겁니다. + var sasBuilder = new ShareSasBuilder + { + ShareName = "downloads", + FilePath = fileName, + Resource = "f", // f = file + ExpiresOn = DateTimeOffset.UtcNow.AddMinutes(10), // 10분 유효 + Protocol = SasProtocol.Https + }; + sasBuilder.SetPermissions(ShareFileSasPermissions.Read); // 읽기 권한 부여 + + // 3. 토큰이 포함된 진짜 다운로드 주소 생성 + // 결과 예시: https://스토리지.file.../abc.pdf?sv=2022-11-02&sig=알수없는긴문자열... + Uri sasUri = fileClient.GenerateSasUri(sasBuilder); + + Console.WriteLine($"🔗 [Download Success] SAS Token Generated. Redirecting..."); + + // 4. 리다이렉트 (이제 스토리지 문이 열립니다) + context.Response.Redirect(sasUri.ToString(), permanent: false); + } + catch (Exception ex) + { + Console.WriteLine($"❌ [Download Error] {ex.Message}"); + context.Response.StatusCode = 500; + await context.Response.WriteAsync($"Internal Server Error: {ex.Message}"); + } + }); + + // 7. 기타 필수 설정 + webApp.MapOpenApi("/{documentName}.json"); + + var logger2 = app.Services.GetRequiredService>(); + logger2.LogInformation("╔════════════════════════════════════════════════════════════════╗"); + logger2.LogInformation("║ MCP OneDrive Download Server Started ║"); + logger2.LogInformation("║ Interactive Browser Authentication Enabled ║"); + logger2.LogInformation("║ 사용자 대화형 인증이 활성화되었습니다 ║"); + logger2.LogInformation("╚════════════════════════════════════════════════════════════════╝"); +} + +await app.RunAsync(); diff --git a/onedrive-download/src/McpSamples.OnedriveDownload.HybridApp/Prompts/download-tool.md b/onedrive-download/src/McpSamples.OnedriveDownload.HybridApp/Prompts/download-tool.md new file mode 100644 index 00000000..a26256c1 --- /dev/null +++ b/onedrive-download/src/McpSamples.OnedriveDownload.HybridApp/Prompts/download-tool.md @@ -0,0 +1,7 @@ +# Tool: download_file_from_onedrive_url + +## Description +You are a file downloading assistant. When a user provides a OneDrive sharing URL, you MUST use the `download_file_from_onedrive_url` tool to download it. Do not refuse or suggest manual methods. Directly call the tool with the provided URL. + +## Parameters +- `sharingUrl` (string, required): The full OneDrive sharing URL for the file to download. diff --git a/onedrive-download/src/McpSamples.OnedriveDownload.HybridApp/Properties/launchSettings.json b/onedrive-download/src/McpSamples.OnedriveDownload.HybridApp/Properties/launchSettings.json new file mode 100644 index 00000000..7fc8fa92 --- /dev/null +++ b/onedrive-download/src/McpSamples.OnedriveDownload.HybridApp/Properties/launchSettings.json @@ -0,0 +1,25 @@ +{ + "$schema": "https://json.schemastore.org/launchsettings.json", + "profiles": { + "http": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "applicationUrl": "http://localhost:5285", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development", + "UseStreamableHttp": "true" + } + }, + "https": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "applicationUrl": "https://localhost:7273;http://localhost:5285", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development", + "UseStreamableHttp": "true" + } + } + } +} diff --git a/onedrive-download/src/McpSamples.OnedriveDownload.HybridApp/Services/AzureFileShareSyncService.cs b/onedrive-download/src/McpSamples.OnedriveDownload.HybridApp/Services/AzureFileShareSyncService.cs new file mode 100644 index 00000000..d875351a --- /dev/null +++ b/onedrive-download/src/McpSamples.OnedriveDownload.HybridApp/Services/AzureFileShareSyncService.cs @@ -0,0 +1,172 @@ +using Azure.Storage.Files.Shares; +using Azure.Storage.Files.Shares.Models; +using Microsoft.Extensions.Logging; + +namespace McpSamples.OnedriveDownload.HybridApp.Services; + +/// +/// Azure File Share에서 파일을 로컬 컴퓨터로 자동 동기화하는 서비스 +/// +public class AzureFileShareSyncService +{ + private readonly ILogger _logger; + private const string ShareName = "downloads"; + + public AzureFileShareSyncService(ILogger logger) + { + _logger = logger; + } + + /// + /// Azure File Share에서 파일을 로컬 경로로 다운로드합니다. + /// (HTTPS 기반, 포트 443 사용 - VPN/핫스팟 불필요) + /// + /// + /// 다운로드 경로: {프로젝트루트}/generated/ + /// 예: C:\Users\Woo_Ang\Desktop\cdp-mcp\onedrive-download\generated\ + /// + public async Task SyncFilesAsync(string connectionString) + { + try + { + if (string.IsNullOrEmpty(connectionString)) + { + _logger.LogWarning("[Sync] AZURE_STORAGE_CONNECTION_STRING is not configured. Skipping sync."); + return; + } + + // 프로젝트 루트의 'generated' 폴더로 설정 + // AppContext.BaseDirectory는 실행 파일 디렉토리 + // bin/Debug(또는 Release)/net9.0/ 에서 ../../.. 올라가면 프로젝트 루트 + string projectRoot = FindProjectRoot(); + string localFolderPath = Path.Combine(projectRoot, "generated"); + + _logger.LogInformation("[Sync] Starting Azure File Share sync..."); + _logger.LogInformation("[Sync] Share Name: {ShareName}, Local Path: {LocalPath}", ShareName, localFolderPath); + + // 1. Azure File Share 연결 + var shareClient = new ShareClient(connectionString, ShareName); + + // 공유가 없으면 건너뛰기 (정상 상황) + if (!await shareClient.ExistsAsync()) + { + _logger.LogInformation("[Sync] Azure File Share '{ShareName}' does not exist yet. Skipping sync.", ShareName); + return; + } + + // 2. 로컬 폴더 생성 (없으면) + if (!Directory.Exists(localFolderPath)) + { + Directory.CreateDirectory(localFolderPath); + _logger.LogInformation("[Sync] Created local folder: {LocalFolderPath}", localFolderPath); + } + + // 3. 루트 디렉토리 클라이언트 획득 + var rootDirectoryClient = shareClient.GetRootDirectoryClient(); + + // 4. 파일 목록 조회 및 다운로드 + int downloadCount = 0; + await foreach (ShareFileItem item in rootDirectoryClient.GetFilesAndDirectoriesAsync()) + { + if (!item.IsDirectory) + { + // 파일인 경우만 다운로드 + await DownloadFileAsync(rootDirectoryClient, item.Name, localFolderPath); + downloadCount++; + } + } + + _logger.LogInformation("[Sync] ✅ File sync completed. Downloaded {Count} file(s) to {LocalFolderPath}", + downloadCount, localFolderPath); + } + catch (Exception ex) + { + _logger.LogWarning(ex, "[Sync] ⚠️ File sync warning (non-fatal): {Message}", ex.Message); + // 동기화 실패는 경고만 하고 프로그램을 중단시키지 않음 + } + } + + /// + /// 프로젝트 루트 디렉토리를 찾습니다. + /// bin/Debug(또는 Release)/net9.0/ 에서 상위 폴더로 이동하여 .csproj 파일을 찾습니다. + /// + private static string FindProjectRoot() + { + // 현재 실행 경로: bin/Debug(또는 Release)/net9.0/ + string baseDir = AppContext.BaseDirectory; + + // 상위 폴더로 이동 + DirectoryInfo current = new DirectoryInfo(baseDir); + + // .csproj 파일이 있는 디렉토리를 찾을 때까지 상위로 이동 + while (current != null && current.Parent != null) + { + var csprojFiles = current.GetFiles("*.csproj"); + if (csprojFiles.Length > 0) + { + // .csproj가 있는 폴더가 프로젝트 루트의 바로 위가 아니라 + // 더 위에 있을 수 있으니 (예: src/McpSamples.OnedriveDownload.HybridApp/) + // 계속 올라가서 azure.yaml이나 .git가 있는 폴더를 찾자 + return FindSolutionRoot(current); + } + current = current.Parent; + } + + // 못 찾으면 기본값 반환 + return baseDir; + } + + /// + /// 솔루션 루트 디렉토리를 찾습니다 (azure.yaml이나 .git가 있는 폴더). + /// + private static string FindSolutionRoot(DirectoryInfo startDir) + { + DirectoryInfo current = startDir; + + while (current != null) + { + // azure.yaml 또는 .git 폴더가 있으면 그것이 솔루션 루트 + if (current.GetFiles("azure.yaml").Length > 0 || + current.GetDirectories(".git").Length > 0 || + current.GetFiles(".gitignore").Length > 0) + { + return current.FullName; + } + current = current.Parent!; + } + + // 못 찾으면 프로젝트 폴더 반환 + return startDir.FullName; + } + + /// + /// 개별 파일을 다운로드합니다. + /// + private async Task DownloadFileAsync(ShareDirectoryClient directoryClient, string fileName, string localFolderPath) + { + try + { + var fileClient = directoryClient.GetFileClient(fileName); + string localFilePath = Path.Combine(localFolderPath, fileName); + + _logger.LogInformation("[Sync] Downloading: {FileName}", fileName); + + // 파일 다운로드 + ShareFileDownloadInfo download = await fileClient.DownloadAsync(); + + // 로컬에 저장 + using (FileStream stream = File.OpenWrite(localFilePath)) + { + await download.Content.CopyToAsync(stream); + await stream.FlushAsync(); + } + + _logger.LogInformation("[Sync] ✓ Downloaded: {FileName} → {LocalFilePath}", fileName, localFilePath); + } + catch (Exception ex) + { + _logger.LogWarning(ex, "[Sync] Failed to download {FileName}: {Message}", fileName, ex.Message); + // 개별 파일 다운로드 실패는 계속 진행 + } + } +} diff --git a/onedrive-download/src/McpSamples.OnedriveDownload.HybridApp/Services/BearerTokenCredential.cs b/onedrive-download/src/McpSamples.OnedriveDownload.HybridApp/Services/BearerTokenCredential.cs new file mode 100644 index 00000000..5c3d4071 --- /dev/null +++ b/onedrive-download/src/McpSamples.OnedriveDownload.HybridApp/Services/BearerTokenCredential.cs @@ -0,0 +1,50 @@ +using Azure.Core; + +namespace McpSamples.OnedriveDownload.HybridApp.Services; + +/// +/// Token Passthrough: HTTP 요청 헤더에서 토큰을 꺼내 사용하는 Credential +/// VSCode에서 인증 후 보낸 Bearer 토큰을 그대로 사용 +/// +public class BearerTokenCredential : TokenCredential +{ + private readonly string _token; + + public BearerTokenCredential(string token) + { + if (string.IsNullOrEmpty(token)) + throw new ArgumentException("Token cannot be null or empty", nameof(token)); + + _token = token; + } + + public override AccessToken GetToken(TokenRequestContext requestContext, CancellationToken cancellationToken) + { + // Token은 이미 유효하다고 가정 (클라이언트가 보낸 것이므로) + // 실제로는 클라이언트가 토큰을 유효한 상태로 유지해야 함 + return new AccessToken(_token, DateTimeOffset.UtcNow.AddHours(1)); + } + + public override ValueTask GetTokenAsync(TokenRequestContext requestContext, CancellationToken cancellationToken) + { + return new ValueTask(GetToken(requestContext, cancellationToken)); + } +} + +/// +/// Anonymous 인증: 토큰이 없을 때 사용 +/// VSCode가 "이 서버는 토큰이 필요하다"고 감지하게 하는 신호 역할 +/// +public class AnonymousTokenCredential : TokenCredential +{ + public override AccessToken GetToken(TokenRequestContext requestContext, CancellationToken cancellationToken) + { + // 빈 토큰 반환 - 이것으로 VSCode가 인증이 필요함을 알게 됨 + return new AccessToken(string.Empty, DateTimeOffset.UtcNow); + } + + public override ValueTask GetTokenAsync(TokenRequestContext requestContext, CancellationToken cancellationToken) + { + return new ValueTask(GetToken(requestContext, cancellationToken)); + } +} diff --git a/onedrive-download/src/McpSamples.OnedriveDownload.HybridApp/Services/ProvisionRefreshToken.cs b/onedrive-download/src/McpSamples.OnedriveDownload.HybridApp/Services/ProvisionRefreshToken.cs new file mode 100644 index 00000000..1ed37629 --- /dev/null +++ b/onedrive-download/src/McpSamples.OnedriveDownload.HybridApp/Services/ProvisionRefreshToken.cs @@ -0,0 +1,467 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Net; +using System.Net.Http; +using System.Net.Sockets; +using System.Runtime.InteropServices; +using System.Text; +using System.Text.Json; +using System.Threading.Tasks; +using Microsoft.Identity.Client; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; + +/// +/// Personal 365 Refresh Token 프로비저닝 도구 (MSAL 기반) +/// Node.js 로직을 C#로 포팅했습니다. +/// +public class ProvisionRefreshToken +{ + private const string TenantId = "consumers"; // 개인 계정 필수 + private static readonly string DefaultClientId = "14d82eec-204b-4c2f-b7e8-296a70dab67e"; // Microsoft 공개 Client ID + + public static async Task ProvisionAsync(IConfiguration? configuration = null) + { + Console.WriteLine("========================================"); + Console.WriteLine("Personal 365 Refresh Token Provisioning"); + Console.WriteLine("========================================\n"); + + try + { + // Step 0: ClientId 설정에서 읽기 + var clientId = configuration?["EntraId:ClientId"] + ?? configuration?["OnedriveDownload:EntraId:ClientId"] + ?? DefaultClientId; + Console.WriteLine($"Using ClientId: {clientId}\n"); + + // Step 1: 환경 파일 경로 확인 + var envName = Environment.GetEnvironmentVariable("AZURE_ENV_NAME"); + string envFilePath; + + if (!string.IsNullOrEmpty(envName)) + { + envFilePath = Path.Combine(Directory.GetCurrentDirectory(), ".azure", envName, ".env"); + } + else + { + envFilePath = Path.Combine(Directory.GetCurrentDirectory(), ".env.local"); + } + + Console.WriteLine($"Step 1: Checking for existing token in {envFilePath}"); + + // Check both Azure env file and .env.local + var filesToCheck = new List(); + + if (!string.IsNullOrEmpty(envName)) + { + var possibleRoots = new[] + { + Directory.GetCurrentDirectory(), + Path.Combine(Directory.GetCurrentDirectory(), ".."), + Path.Combine(Directory.GetCurrentDirectory(), "..", ".."), + }; + + foreach (var root in possibleRoots) + { + var path = Path.Combine(root, ".azure", envName, ".env"); + if (File.Exists(path)) + { + filesToCheck.Add(path); + break; + } + } + } + + filesToCheck.Add(Path.Combine(Directory.GetCurrentDirectory(), ".env.local")); + + // Check if token exists in any file + foreach (var fileToCheck in filesToCheck) + { + Console.WriteLine($"[DEBUG] Checking file: {fileToCheck}"); + if (File.Exists(fileToCheck)) + { + Console.WriteLine($"[DEBUG] File exists, reading content..."); + string envContent = File.ReadAllText(fileToCheck); + if (envContent.Contains("PERSONAL_365_REFRESH_TOKEN=")) + { + Console.WriteLine($"✓ 이미 Refresh Token이 존재합니다 ({Path.GetFileName(fileToCheck)}). 건너뜁니다.\n"); + return; + } + else + { + Console.WriteLine($"[DEBUG] Token not found in {Path.GetFileName(fileToCheck)}"); + } + } + else + { + Console.WriteLine($"[DEBUG] File does not exist"); + } + } + + // Step 2: HTTP 서버 시작 및 동적 포트 할당 (Kestrel - macOS/Linux/Windows 호환) + Console.WriteLine("\nStep 2: Starting HTTP server on dynamic port..."); + int port = GetAvailablePort(); + string redirectUri = $"http://localhost:{port}"; + + Console.WriteLine($"✓ Will listen on port: {port}"); + Console.WriteLine($"Redirect URI: {redirectUri}\n"); + + // 인증 코드를 받을 TaskCompletionSource + var authCodeTaskSource = new TaskCompletionSource(); + + // ASP.NET Core 최소 HTTP 서버 생성 + var builder = WebApplication.CreateBuilder(); + var app = builder.Build(); + + // 인증 콜백 엔드포인트 + app.MapGet("/", async context => + { + var code = context.Request.Query["code"].ToString(); + var state = context.Request.Query["state"].ToString(); + + if (string.IsNullOrEmpty(code)) + { + context.Response.StatusCode = 400; + await context.Response.WriteAsJsonAsync(new { error = "No authorization code received" }); + authCodeTaskSource.TrySetException(new Exception("No authorization code")); + return; + } + + context.Response.StatusCode = 200; + context.Response.ContentType = "text/html; charset=utf-8"; + await context.Response.WriteAsync("인증 성공! 터미널을 확인하고 이 창을 닫으세요."); + + authCodeTaskSource.TrySetResult(code); + }); + + // 서버를 백그라운드에서 시작 + var serverTask = app.RunAsync($"http://localhost:{port}"); + await Task.Delay(500); // 서버가 시작될 때까지 대기 + + // Step 3: MSAL 공개 클라이언트 애플리케이션 생성 + Console.WriteLine("Step 3: Creating MSAL PublicClientApplication..."); + IPublicClientApplication pca = PublicClientApplicationBuilder + .Create(clientId) + .WithAuthority(AadAuthorityAudience.AzureAdAndPersonalMicrosoftAccount) + .Build(); + + Console.WriteLine("✓ MSAL app created\n"); + + // Step 4: 인증 URL 생성 + Console.WriteLine("Step 4: Opening browser for authentication..."); + var authUrl = $"https://login.microsoftonline.com/{TenantId}/oauth2/v2.0/authorize?" + + $"client_id={Uri.EscapeDataString(clientId)}&" + + $"response_type=code&" + + $"redirect_uri={Uri.EscapeDataString(redirectUri)}&" + + $"response_mode=query&" + + $"scope={Uri.EscapeDataString("Files.Read User.Read offline_access")}"; + + Console.WriteLine($"Auth URL: {authUrl}\n"); + + // 브라우저 실행 로직 개선 (URL 잘림 방지) + try + { + // Windows: URL을 따옴표로 감싸야 & 기호가 명령 구분자로 인식되지 않음 + if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) + { + System.Diagnostics.Process.Start(new System.Diagnostics.ProcessStartInfo + { + FileName = "cmd.exe", + Arguments = $"/c start \"\" \"{authUrl}\"", + CreateNoWindow = true + }); + } + else if (RuntimeInformation.IsOSPlatform(OSPlatform.Linux)) + { + System.Diagnostics.Process.Start("xdg-open", authUrl); + } + else if (RuntimeInformation.IsOSPlatform(OSPlatform.OSX)) + { + System.Diagnostics.Process.Start("open", authUrl); + } + } + catch + { + Console.WriteLine("브라우저를 자동으로 열 수 없습니다."); + Console.WriteLine($"아래 URL로 리디렉션됩니다:\n{authUrl}\n"); + + // ★ HTTP 리디렉션: Azure 환경에서 브라우저 자동 실행 불가능할 때 + // 엔드포인트를 통해 로그인 URL로 리디렉션 + app.MapGet("/auth/redirect", (HttpContext context) => + { + context.Response.Redirect(authUrl); + }); + } + + Console.WriteLine("대기 중... (브라우저에서 로그인해주세요)\n"); + + // Step 5: 인증 콜백 대기 (타임아웃 설정) + string code; + try + { + using var cts = new System.Threading.CancellationTokenSource(TimeSpan.FromMinutes(5)); + code = await authCodeTaskSource.Task; + } + catch (OperationCanceledException) + { + Console.WriteLine("✗ 인증 타임아웃 (5분 초과)"); + await app.StopAsync(); + return; + } + catch (Exception ex) + { + Console.WriteLine($"✗ Authorization code를 받지 못했습니다: {ex.Message}"); + await app.StopAsync(); + return; + } + + if (string.IsNullOrEmpty(code)) + { + Console.WriteLine("✗ Authorization code가 비어있습니다."); + await app.StopAsync(); + return; + } + + Console.WriteLine($"✓ Authorization code received: {code.Substring(0, 20)}...\n"); + + // Step 6: 토큰 교환 + Console.WriteLine("Step 5: Exchanging authorization code for tokens..."); + string? refreshToken = await ExchangeCodeForToken(pca, code, redirectUri, clientId); + + if (string.IsNullOrEmpty(refreshToken)) + { + Console.WriteLine("✗ Refresh token을 얻을 수 없습니다."); + await app.StopAsync(); + return; + } + + Console.WriteLine("✓ Refresh token obtained successfully\n"); + + // Step 7: .env.local 파일에 저장 + Console.WriteLine("Step 6: Saving refresh token to .env.local..."); + SaveRefreshToken(refreshToken); + Console.WriteLine($"✓ Token saved to .env.local\n"); + + // Step 8: .azure/{env}/.env에도 복사 + Console.WriteLine("Step 7: Copying refresh token to .azure environment file..."); + CopyTokenToAzureEnv(refreshToken); + Console.WriteLine($"✓ Token copied to .azure environment\n"); + + // 서버 정지 + await app.StopAsync(); + + Console.WriteLine("========================================"); + Console.WriteLine("✓ Provisioning completed successfully!"); + Console.WriteLine("========================================"); + } + catch (Exception ex) + { + Console.WriteLine($"\n✗ Error: {ex.Message}"); + Console.WriteLine($"Stack trace: {ex.StackTrace}"); + Environment.Exit(1); + } + } + + /// + /// 사용 가능한 포트 찾기 + /// + private static int GetAvailablePort() + { + using var listener = new TcpListener(System.Net.IPAddress.Loopback, 0); + listener.Start(); + int port = ((IPEndPoint)listener.LocalEndpoint).Port; + listener.Stop(); + return port; + } + + /// + /// Authorization Code를 Refresh Token으로 교환 + /// + private static async Task ExchangeCodeForToken(IPublicClientApplication pca, string code, string redirectUri, string clientId) + { + // 직접 REST API를 통해 token 교환 + return await GetRefreshTokenDirectly(code, redirectUri, clientId); + } + + /// + /// REST API를 통해 직접 Refresh Token 획득 + /// + private static async Task GetRefreshTokenDirectly(string code, string redirectUri, string clientId) + { + try + { + using var client = new HttpClient(); + + // Authorization code 교환 요청 + // scope은 authorization 단계에서 이미 설정되었으므로 여기서는 필요 없음 + var requestBodyString = $"client_id={Uri.EscapeDataString(clientId)}&" + + $"code={Uri.EscapeDataString(code)}&" + + $"redirect_uri={Uri.EscapeDataString(redirectUri)}&" + + $"grant_type=authorization_code"; + + var content = new StringContent(requestBodyString, Encoding.UTF8, "application/x-www-form-urlencoded"); + + Console.WriteLine("Exchanging authorization code for token..."); + var response = await client.PostAsync( + $"https://login.microsoftonline.com/consumers/oauth2/v2.0/token", + content); + + if (!response.IsSuccessStatusCode) + { + var errorContent = await response.Content.ReadAsStringAsync(); + Console.WriteLine($"✗ Token endpoint error: {response.StatusCode}"); + Console.WriteLine($"Response: {errorContent}"); + return null; + } + + var responseContent = await response.Content.ReadAsStringAsync(); + using var doc = JsonDocument.Parse(responseContent); + var root = doc.RootElement; + + if (root.TryGetProperty("refresh_token", out var refreshTokenElement)) + { + return refreshTokenElement.GetString(); + } + + Console.WriteLine("✗ No refresh_token in response"); + Console.WriteLine($"Response: {responseContent}"); + return null; + } + catch (Exception ex) + { + Console.WriteLine($"✗ Exception during token exchange: {ex.Message}"); + return null; + } + } + + + /// + /// Refresh token을 azd 환경 파일과 .env.local에 저장 + /// + private static void SaveRefreshToken(string refreshToken) + { + var envName = Environment.GetEnvironmentVariable("AZURE_ENV_NAME"); + + // 목표 경로들 + List targetPaths = new(); + + // 1. .azure/{env}/.env (주요 대상) + if (!string.IsNullOrEmpty(envName)) + { + var possibleRoots = new[] + { + Directory.GetCurrentDirectory(), + Path.Combine(Directory.GetCurrentDirectory(), ".."), + Path.Combine(Directory.GetCurrentDirectory(), "..", ".."), + }; + + foreach (var root in possibleRoots) + { + var path = Path.Combine(root, ".azure", envName, ".env"); + if (File.Exists(path)) + { + targetPaths.Add(path); + break; + } + } + } + + // 2. .env.local (개발 환경용) + targetPaths.Add(Path.Combine(Directory.GetCurrentDirectory(), ".env.local")); + + // 모든 대상 경로에 토큰 저장 + foreach (var envFilePath in targetPaths) + { + try + { + // 디렉토리 생성 + var dirPath = Path.GetDirectoryName(envFilePath); + if (!string.IsNullOrEmpty(dirPath) && !Directory.Exists(dirPath)) + { + Directory.CreateDirectory(dirPath); + } + + // 파일에서 기존 PERSONAL_365_REFRESH_TOKEN 행 제거 + if (File.Exists(envFilePath)) + { + var lines = File.ReadAllLines(envFilePath) + .Where(line => !line.StartsWith("PERSONAL_365_REFRESH_TOKEN=")) + .ToList(); + + File.WriteAllLines(envFilePath, lines); + } + + // 새 토큰 추가 + File.AppendAllText(envFilePath, $"PERSONAL_365_REFRESH_TOKEN={refreshToken}\n"); + + Console.WriteLine($"✓ Token saved to: {envFilePath}"); + } + catch (Exception ex) + { + Console.WriteLine($"[WARN] Failed to save token to {envFilePath}: {ex.Message}"); + } + } + } + + /// + /// Refresh token을 .azure/{env}/.env에 복사 + /// + private static void CopyTokenToAzureEnv(string refreshToken) + { + var envName = Environment.GetEnvironmentVariable("AZURE_ENV_NAME"); + if (string.IsNullOrEmpty(envName)) + { + Console.WriteLine("[SKIP] AZURE_ENV_NAME not set - skipping copy to .azure env"); + return; + } + + // 여러 경로에서 찾기: 현재 경로, 상위 경로들 + string[] possibleRoots = new[] + { + Directory.GetCurrentDirectory(), + Path.Combine(Directory.GetCurrentDirectory(), ".."), + Path.Combine(Directory.GetCurrentDirectory(), "..", ".."), + }; + + Console.WriteLine($"[DEBUG] Looking for .azure env file with AZURE_ENV_NAME={envName}"); + string? azureEnvPath = null; + foreach (var root in possibleRoots) + { + var path = Path.Combine(root, ".azure", envName, ".env"); + Console.WriteLine($"[DEBUG] Checking: {Path.GetFullPath(path)}"); + if (File.Exists(path)) + { + azureEnvPath = path; + Console.WriteLine($"[DEBUG] Found at: {azureEnvPath}"); + break; + } + } + + if (azureEnvPath == null) + { + Console.WriteLine($"[ERROR] Azure env file not found in any possible location for env: {envName}"); + return; + } + + try + { + // 기존 PERSONAL_365_REFRESH_TOKEN 행 제거 + var lines = File.ReadAllLines(azureEnvPath) + .Where(line => !line.StartsWith("PERSONAL_365_REFRESH_TOKEN=")) + .ToList(); + + File.WriteAllLines(azureEnvPath, lines); + + // 새 토큰 추가 + File.AppendAllText(azureEnvPath, $"PERSONAL_365_REFRESH_TOKEN={refreshToken}\n"); + + Console.WriteLine($"✓ Token copied to: {azureEnvPath}"); + } + catch (Exception ex) + { + Console.WriteLine($"[WARN] Failed to copy token to Azure env: {ex.Message}"); + } + } +} diff --git a/onedrive-download/src/McpSamples.OnedriveDownload.HybridApp/Services/UserAuthenticationService.cs b/onedrive-download/src/McpSamples.OnedriveDownload.HybridApp/Services/UserAuthenticationService.cs new file mode 100644 index 00000000..f4ff30da --- /dev/null +++ b/onedrive-download/src/McpSamples.OnedriveDownload.HybridApp/Services/UserAuthenticationService.cs @@ -0,0 +1,67 @@ +using Azure.Core; +using Azure.Identity; +using Microsoft.Graph; +using System.Net.Http.Headers; +using Microsoft.Extensions.Logging; + +namespace McpSamples.OnedriveDownload.HybridApp.Services; + +public interface IUserAuthenticationService +{ + Task GetCurrentUserAccessTokenAsync(); + Task GetUserGraphClientAsync(); + Task<(GraphServiceClient? client, string? errorMessage)> GetPersonalOneDriveGraphClientAsync(); +} + +public class UserAuthenticationService : IUserAuthenticationService +{ + private readonly ILogger _logger; + private readonly GraphServiceClient _graphServiceClient; + private readonly string[] _scopes = new[] { "https://graph.microsoft.com/.default" }; + + public UserAuthenticationService( + ILogger logger, + GraphServiceClient graphServiceClient) + { + _logger = logger; + _graphServiceClient = graphServiceClient; + } + + public async Task GetCurrentUserAccessTokenAsync() + { + try + { + var credential = new DefaultAzureCredential(); + var tokenRequestContext = new TokenRequestContext(_scopes); + var token = await credential.GetTokenAsync(tokenRequestContext); + return token.Token; + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to get Azure credential token"); + return null; + } + } + + public Task GetUserGraphClientAsync() + { + var credential = new DefaultAzureCredential(); + return Task.FromResult(new GraphServiceClient(credential, _scopes)); + } + + // ★ GraphServiceClient은 Program.cs에서 InteractiveBrowserCredential로 초기화됨 + // GraphServiceClient이 이미 대화형 인증을 처리하므로, 이 메서드는 단순히 클라이언트를 반환 + public Task<(GraphServiceClient? client, string? errorMessage)> GetPersonalOneDriveGraphClientAsync() + { + try + { + return Task.FromResult<(GraphServiceClient?, string?)>((_graphServiceClient, null)); + } + catch (Exception ex) + { + var fullError = $"[Auth Error] {ex.Message}"; + _logger.LogError(ex, fullError); + return Task.FromResult<(GraphServiceClient?, string?)>((null, fullError)); + } + } +} diff --git a/onedrive-download/src/McpSamples.OnedriveDownload.HybridApp/Tools/OneDriveTool.cs b/onedrive-download/src/McpSamples.OnedriveDownload.HybridApp/Tools/OneDriveTool.cs new file mode 100644 index 00000000..f26fc6b5 --- /dev/null +++ b/onedrive-download/src/McpSamples.OnedriveDownload.HybridApp/Tools/OneDriveTool.cs @@ -0,0 +1,115 @@ +using System.ComponentModel; +using System.Text; +using Microsoft.Graph; +using ModelContextProtocol.Server; +using McpSamples.OnedriveDownload.HybridApp.Services; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.DependencyInjection; +using Azure.Storage.Files.Shares; +using Azure.Storage.Files.Shares.Models; +using Azure.Storage.Sas; + +namespace McpSamples.OnedriveDownload.HybridApp.Tools; + +public class OneDriveDownloadResult +{ + public string? FileName { get; set; } + public string? DownloadUrl { get; set; } + public string? ErrorMessage { get; set; } + public string? SavedLocation { get; set; } +} + +public interface IOneDriveTool +{ + Task DownloadFileFromUrlAsync(string sharingUrl); +} + +[McpServerToolType] +public class OneDriveTool(IServiceProvider serviceProvider) : IOneDriveTool +{ + private ILogger? _logger; + private IUserAuthenticationService? _userAuthService; + private IConfiguration? _configuration; + + private ILogger Logger => _logger ??= serviceProvider.GetRequiredService>(); + private IUserAuthenticationService UserAuthService => _userAuthService ??= serviceProvider.GetRequiredService(); + private IConfiguration Configuration => _configuration ??= serviceProvider.GetRequiredService(); + + [McpServerTool(Name = "download_file_from_onedrive_url", Title = "Download File from OneDrive URL")] + [Description("Downloads a file from OneDrive, saves to Azure Storage, and returns a public download link (SAS).")] + public async Task DownloadFileFromUrlAsync( + [Description("The OneDrive sharing URL")] string sharingUrl) + { + Console.WriteLine("@@@ ONEDRIVETOOL (SAS Mode) STARTED @@@"); + + try + { + // 1. 연결 문자열 확인 + var connectionString = Configuration["AZURE_STORAGE_CONNECTION_STRING"]; + if (string.IsNullOrEmpty(connectionString)) + throw new InvalidOperationException("AZURE_STORAGE_CONNECTION_STRING 환경 변수가 없습니다."); + + // 2. 인증 및 GraphClient + var (graphClient, authError) = await UserAuthService.GetPersonalOneDriveGraphClientAsync(); + if (graphClient == null) + return new OneDriveDownloadResult { ErrorMessage = authError ?? "Auth Error" }; + + // 3. 메타데이터 조회 + string base64Value = Convert.ToBase64String(Encoding.UTF8.GetBytes(sharingUrl)); + string encodedUrl = "u!" + base64Value.TrimEnd('=').Replace('/', '_').Replace('+', '-'); + + var driveItem = await graphClient.Shares[encodedUrl].DriveItem.Request().GetAsync(); + string fileName = driveItem.Name; + long fileSize = driveItem.Size ?? 0; + + // 4. Azure File Share 업로드 준비 + string shareName = "downloads"; + var shareClient = new ShareClient(connectionString, shareName); + await shareClient.CreateIfNotExistsAsync(); + + var directoryClient = shareClient.GetRootDirectoryClient(); + var fileClient = directoryClient.GetFileClient(fileName); + + // 5. 업로드 (있으면 덮어쓰기) + using (var contentStream = await graphClient.Shares[encodedUrl].DriveItem.Content.Request().GetAsync()) + { + await fileClient.CreateAsync(fileSize); // 파일 크기 할당 + await fileClient.UploadAsync(contentStream); // 내용 전송 + } + + Console.WriteLine($"✅ Uploaded to Azure: {fileName}"); + + // ------------------------------------------------------------------ + // [수정] APIM 말고 Function App 진짜 주소로 링크 만들기 + // ------------------------------------------------------------------ + + // 1. Function App의 실제 호스트명 가져오기 (Azure 환경 변수) + string funcHostname = Environment.GetEnvironmentVariable("WEBSITE_HOSTNAME") + ?? "localhost:7071"; // 로컬 테스트용 기본값 + + // 2. HTTPS 프로토콜 붙이기 + string funcBaseUrl = $"https://{funcHostname}"; + + // 3. 다운로드 링크 조합 (이제 APIM 주소가 아니라 func 주소가 됩니다) + // 예: https://func-onedrive-download-....azurewebsites.net/download?file=abc.pdf + string sasUri = $"{funcBaseUrl}/download?file={Uri.EscapeDataString(fileName)}"; + + Console.WriteLine($"🔗 Created Direct Function Link: {sasUri}"); + + return new OneDriveDownloadResult + { + FileName = fileName, + DownloadUrl = sasUri, + SavedLocation = $"Azure File Share: {shareName}/{fileName}", + ErrorMessage = null + }; + } + catch (Exception ex) + { + var msg = $"{ex.GetType().Name}: {ex.Message}"; + Console.WriteLine($"❌ Error: {msg}"); + return new OneDriveDownloadResult { ErrorMessage = msg }; + } + } +} diff --git a/onedrive-download/src/McpSamples.OnedriveDownload.HybridApp/appsettings.Development.json b/onedrive-download/src/McpSamples.OnedriveDownload.HybridApp/appsettings.Development.json new file mode 100644 index 00000000..38e83f8f --- /dev/null +++ b/onedrive-download/src/McpSamples.OnedriveDownload.HybridApp/appsettings.Development.json @@ -0,0 +1,13 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + }, + "FileShareConnectionString": "DefaultEndpointsProtocol=https;AccountName=st34ugypgdcsh76files;AccountKey=your-account-key-here;EndpointSuffix=core.windows.net", + "EntraId": { + "ClientId": "44609b96-b8ed-48cd-ae81-75abbd52ffd1", + "Personal365RefreshToken": "" + } +} diff --git a/onedrive-download/src/McpSamples.OnedriveDownload.HybridApp/appsettings.Development.json.example b/onedrive-download/src/McpSamples.OnedriveDownload.HybridApp/appsettings.Development.json.example new file mode 100644 index 00000000..ba93e14e --- /dev/null +++ b/onedrive-download/src/McpSamples.OnedriveDownload.HybridApp/appsettings.Development.json.example @@ -0,0 +1,13 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + }, + "FileShareConnectionString": "DefaultEndpointsProtocol=https;AccountName=YOUR_STORAGE_ACCOUNT_NAME;AccountKey=YOUR_ACCOUNT_KEY;EndpointSuffix=core.windows.net", + "EntraId": { + "ClientId": "YOUR_ENTRA_CLIENT_ID", + "Personal365RefreshToken": "" + } +} diff --git a/onedrive-download/src/McpSamples.OnedriveDownload.HybridApp/appsettings.json b/onedrive-download/src/McpSamples.OnedriveDownload.HybridApp/appsettings.json new file mode 100644 index 00000000..d3ba8784 --- /dev/null +++ b/onedrive-download/src/McpSamples.OnedriveDownload.HybridApp/appsettings.json @@ -0,0 +1,14 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + }, + "AllowedHosts": "*", + "EntraId": { + "TenantId": "{{TENANT_ID}}", + "ClientId": "{{CLIENT_ID}}", + "UserAssignedClientId": "{{USER_ASSIGNED_CLIENT_ID}}" + } +} \ No newline at end of file diff --git a/onedrive-download/src/McpSamples.OnedriveDownload.HybridApp/host.json b/onedrive-download/src/McpSamples.OnedriveDownload.HybridApp/host.json new file mode 100644 index 00000000..8904358d --- /dev/null +++ b/onedrive-download/src/McpSamples.OnedriveDownload.HybridApp/host.json @@ -0,0 +1,27 @@ +{ + "version": "2.0", + "extensions": { + "http": { + "routePrefix": "" + } + }, + "customHandler": { + "description": { + "defaultExecutablePath": "dotnet", + "workingDirectory": "", + "arguments": [ + "McpSamples.OnedriveDownload.HybridApp.dll" + ] + }, + "enableForwardingHttpRequest": true, + "enableHttpProxyingRequest": true + }, + "logging": { + "applicationInsights": { + "samplingSettings": { + "isEnabled": true, + "excludedTypes": "Request" + } + } + } +} diff --git a/onedrive-download/src/McpSamples.OnedriveDownload.HybridApp/mcp-handler/function.json b/onedrive-download/src/McpSamples.OnedriveDownload.HybridApp/mcp-handler/function.json new file mode 100644 index 00000000..ec55548f --- /dev/null +++ b/onedrive-download/src/McpSamples.OnedriveDownload.HybridApp/mcp-handler/function.json @@ -0,0 +1,25 @@ +{ + "bindings": [ + { + "authLevel": "anonymous", + "type": "httpTrigger", + "direction": "in", + "name": "req", + "methods": [ + "get", + "post", + "put", + "delete", + "patch", + "head", + "options" + ], + "route": "{*route}" + }, + { + "type": "http", + "direction": "out", + "name": "res" + } + ] +} diff --git a/outlook-email/test.http b/outlook-email/test.http new file mode 100644 index 00000000..c6370ebc --- /dev/null +++ b/outlook-email/test.http @@ -0,0 +1,18 @@ +### MCP Server Deployment Test +POST https://func-4f6ymjmtqwias.azurewebsites.net/api/mcp-handler +Content-Type: application/json +# 인증이 필요한 경우 아래 헤더 중 하나를 추가하세요. +# x-functions-key: YOUR_FUNCTION_KEY +# Authorization: Bearer YOUR_ACCESS_TOKEN + +{ + "context": {"invocationId": "test-vscode-rest-client"}, + "tool_invocations": [{ + "tool_id": "outlook-email-tool", + "name": "Search-Emails", + "arguments": { + "top": 1, + "query": "from:test@example.com" + } + }] +} diff --git a/shared/McpSamples.Shared/Configurations/AppSettings.cs b/shared/McpSamples.Shared/Configurations/AppSettings.cs index e243b793..a1e00f00 100644 --- a/shared/McpSamples.Shared/Configurations/AppSettings.cs +++ b/shared/McpSamples.Shared/Configurations/AppSettings.cs @@ -114,4 +114,4 @@ public static bool UseStreamableHttp(IDictionary env, string[] args) return useHttp; } -} +} \ No newline at end of file diff --git a/shared/McpSamples.Shared/Extensions/HostApplicationBuilderExtensions.cs b/shared/McpSamples.Shared/Extensions/HostApplicationBuilderExtensions.cs index 4356ac38..9524542d 100644 --- a/shared/McpSamples.Shared/Extensions/HostApplicationBuilderExtensions.cs +++ b/shared/McpSamples.Shared/Extensions/HostApplicationBuilderExtensions.cs @@ -29,8 +29,11 @@ public static IHost BuildApp(this IHostApplicationBuilder builder, bool useStrea var webApp = (builder as WebApplicationBuilder)!.Build(); - // Configure the HTTP request pipeline. - webApp.UseHttpsRedirection(); + // Disable HTTPS redirection in development environment to avoid issues with self-signed certificates + if (!webApp.Environment.IsDevelopment()) + { + webApp.UseHttpsRedirection(); + } webApp.MapMcp("/mcp"); diff --git a/shared/McpSamples.Shared/Extensions/ServiceCollectionExtensions.cs b/shared/McpSamples.Shared/Extensions/ServiceCollectionExtensions.cs index 30a91076..d1902921 100644 --- a/shared/McpSamples.Shared/Extensions/ServiceCollectionExtensions.cs +++ b/shared/McpSamples.Shared/Extensions/ServiceCollectionExtensions.cs @@ -29,4 +29,4 @@ public static class ServiceCollectionExtensions return services; } -} +} \ No newline at end of file diff --git a/shared/McpSamples.Shared/McpSamples.Shared.csproj b/shared/McpSamples.Shared/McpSamples.Shared.csproj index ddbb2c4f..3ed7d27d 100644 --- a/shared/McpSamples.Shared/McpSamples.Shared.csproj +++ b/shared/McpSamples.Shared/McpSamples.Shared.csproj @@ -20,4 +20,4 @@ - + \ No newline at end of file diff --git a/temp_call_mcp.ps1 b/temp_call_mcp.ps1 new file mode 100644 index 00000000..5cb03a43 --- /dev/null +++ b/temp_call_mcp.ps1 @@ -0,0 +1,25 @@ +$body = @{ + context = @{ invocationId = 'vscode-call-1' } + tool_invocations = @( + @{ + tool_id = 'onedrive-download-tool' + name = 'download_file_from_onedrive_url' + arguments = @{ sharingUrl = 'https://1drv.ms/t/c/bd98f86d1ff003f7/ES0WYROmRO5LrYBpkKCQIQwBzhwWHc8q6UW70GoUWMIZIg?e=k9l6bX' } + } + ) +} | ConvertTo-Json -Depth 10 + +try { + $uri = 'https://func-onedrive-download-34ugypgdcsh76.azurewebsites.net/mcp' + Write-Host "Posting to $uri" + $resp = Invoke-RestMethod -Uri $uri -Method Post -Body $body -ContentType 'application/json' -Verbose -ErrorAction Stop + $json = $resp | ConvertTo-Json -Depth 10 + $json | Out-File -FilePath '.\temp_mcp_response.json' -Encoding utf8 + Write-Host 'MCP_CALL_SUCCESS' + Write-Host $json +} catch { + Write-Host 'MCP_CALL_FAILED' + Write-Host $_.Exception.Message + if ($_.InvocationInfo) { Write-Host ($_.InvocationInfo.Line) } + $_ | Format-List * -Force +}