diff --git a/.github/ISSUE_TEMPLATE/GENERAL.md b/.github/ISSUE_TEMPLATE/GENERAL.md
index 991da02..84cea1c 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/user-story---tasks.md b/.github/ISSUE_TEMPLATE/user-story---tasks.md
new file mode 100644
index 0000000..f8b8a07
--- /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/.gitignore b/.gitignore
index bbdc6d7..0b04811 100644
--- a/.gitignore
+++ b/.gitignore
@@ -400,3 +400,4 @@ FodyWeavers.xsd
*.sln.iml
.DS_Store
+.azure
diff --git a/Dockerfile.ppt-translator b/Dockerfile.ppt-translator
new file mode 100644
index 0000000..cb86ce2
--- /dev/null
+++ b/Dockerfile.ppt-translator
@@ -0,0 +1,54 @@
+# syntax=docker/dockerfile:1
+
+# ================================
+# 1) BUILD STAGE
+# ================================
+FROM --platform=$BUILDPLATFORM mcr.microsoft.com/dotnet/sdk:9.0 AS build
+
+# Copy shared library
+COPY ./shared/McpSamples.Shared /source/shared/McpSamples.Shared
+
+# Copy PPT Translator HybridApp
+COPY ./ppt-translator/src/McpSamples.PptTranslator.HybridApp \
+ /source/ppt-translator/src/McpSamples.PptTranslator.HybridApp
+
+WORKDIR /source/ppt-translator/src/McpSamples.PptTranslator.HybridApp
+
+# Publish
+RUN dotnet publish -c Release -o /app --self-contained false
+
+
+# ================================
+# 2) FINAL RUNTIME IMAGE (DEBIAN)
+# ================================
+FROM mcr.microsoft.com/dotnet/aspnet:9.0
+
+WORKDIR /app
+
+# ShapeCrawler + SkiaSharp dependencies
+RUN apt-get update && apt-get install -y \
+ libfontconfig1 \
+ libfreetype6 \
+ libpng16-16 \
+ libx11-6 \
+ libxext6 \
+ libxrender1 \
+ uuid-dev \
+ libuuid1 \
+ libgdiplus \
+ libharfbuzz0b \
+ libicu72 \
+ && rm -rf /var/lib/apt/lists/*
+
+# Copy published app
+COPY --from=build /app .
+
+# Folder for file mounting
+RUN mkdir -p /files && chmod -R 755 /files
+
+# HTTP MCP endpoint
+ENV ASPNETCORE_URLS=http://0.0.0.0:8080
+ENV MCP_HTTP_PATH=/mcp
+
+# STDI/O MCP + HTTP MCP 지원
+ENTRYPOINT ["dotnet", "McpSamples.PptTranslator.HybridApp.dll"]
diff --git a/Dockerfile.ppt-translator-azure b/Dockerfile.ppt-translator-azure
new file mode 100644
index 0000000..3edd21a
--- /dev/null
+++ b/Dockerfile.ppt-translator-azure
@@ -0,0 +1,44 @@
+# Azure Container Registry compatible Dockerfile
+
+###############################################
+# 1) BUILD STAGE
+###############################################
+FROM mcr.microsoft.com/dotnet/sdk:9.0 AS build
+
+COPY ./shared/McpSamples.Shared /source/shared/McpSamples.Shared
+COPY ./ppt-translator/src/McpSamples.PptTranslator.HybridApp \
+ /source/ppt-translator/src/McpSamples.PptTranslator.HybridApp
+
+WORKDIR /source/ppt-translator/src/McpSamples.PptTranslator.HybridApp
+
+RUN dotnet publish -c Release -o /app --self-contained false
+
+
+###############################################
+# 2) FINAL STAGE (DEBIAN, not Alpine)
+###############################################
+FROM mcr.microsoft.com/dotnet/aspnet:9.0
+
+WORKDIR /app
+
+# ShapeCrawler + SkiaSharp deps (Debian)
+RUN apt-get update && apt-get install -y \
+ libfontconfig1 \
+ libfreetype6 \
+ libpng16-16 \
+ libx11-6 \
+ libxext6 \
+ libxrender1 \
+ && rm -rf /var/lib/apt/lists/*
+
+# Create mount folder (Azure File Share - single /files mount)
+RUN mkdir -p /files && chmod -R 777 /files
+
+COPY --from=build /app .
+
+ENV ASPNETCORE_URLS=http://0.0.0.0:8080
+ENV MCP_HTTP_PATH=/mcp
+
+EXPOSE 8080
+
+ENTRYPOINT ["dotnet", "McpSamples.PptTranslator.HybridApp.dll"]
diff --git a/ppt-translator/.vscode/mcp.http.container.json b/ppt-translator/.vscode/mcp.http.container.json
new file mode 100644
index 0000000..876cd44
--- /dev/null
+++ b/ppt-translator/.vscode/mcp.http.container.json
@@ -0,0 +1,8 @@
+{
+ "servers": {
+ "ppt-translator-http-container": {
+ "type": "http",
+ "url": "http://localhost:8080/mcp"
+ }
+ }
+}
diff --git a/ppt-translator/.vscode/mcp.http.local.json b/ppt-translator/.vscode/mcp.http.local.json
new file mode 100644
index 0000000..f70c894
--- /dev/null
+++ b/ppt-translator/.vscode/mcp.http.local.json
@@ -0,0 +1,8 @@
+{
+ "servers": {
+ "ppt-translator": {
+ "type": "http",
+ "url": "http://localhost:5166/mcp"
+ }
+ }
+}
\ No newline at end of file
diff --git a/ppt-translator/.vscode/mcp.http.remote.json b/ppt-translator/.vscode/mcp.http.remote.json
new file mode 100644
index 0000000..93eaf21
--- /dev/null
+++ b/ppt-translator/.vscode/mcp.http.remote.json
@@ -0,0 +1,15 @@
+{
+ "inputs": [
+ {
+ "type": "promptString",
+ "id": "ppt-translator-azure-fqdn",
+ "description": "Azure Container App FQDN (from azd output: AZURE_RESOURCE_PPT_TRANSLATOR_FQDN)"
+ }
+ ],
+ "servers": {
+ "ppt-translator-azure": {
+ "type": "http",
+ "url": "https://${input:ppt-translator-azure-fqdn}/mcp"
+ }
+ }
+}
\ No newline at end of file
diff --git a/ppt-translator/.vscode/mcp.stdio.container.json b/ppt-translator/.vscode/mcp.stdio.container.json
new file mode 100644
index 0000000..76573d6
--- /dev/null
+++ b/ppt-translator/.vscode/mcp.stdio.container.json
@@ -0,0 +1,24 @@
+{
+ "inputs": [
+ {
+ "type": "promptString",
+ "id": "ppt-folder-path",
+ "description": "Absolute path of the folder that contains your PPT files"
+ }
+ ],
+ "servers": {
+ "ppt-translator-stdio-container": {
+ "type": "stdio",
+ "command": "docker",
+ "args": [
+ "run",
+ "-i",
+ "--rm",
+ "-e", "OPENAI_API_KEY=${env:OPENAI_API_KEY}",
+ "-e", "HOST_MOUNT_PATH=${input:ppt-folder-path}",
+ "-v", "${input:ppt-folder-path}:/files",
+ "ppt-translator:latest"
+ ]
+ }
+ }
+}
diff --git a/ppt-translator/.vscode/mcp.stdio.local.json b/ppt-translator/.vscode/mcp.stdio.local.json
new file mode 100644
index 0000000..5bd15e2
--- /dev/null
+++ b/ppt-translator/.vscode/mcp.stdio.local.json
@@ -0,0 +1,23 @@
+{
+ "inputs": [
+ {
+ "type": "promptString",
+ "id": "consoleapp-project-path",
+ "description": "The absolute path to the console app project Directory"
+ }
+ ],
+ "servers": {
+ "ppt-translator": {
+ "type": "stdio",
+ "command": "dotnet",
+ "args": [
+ "run",
+ "--project",
+ "${input:consoleapp-project-path}",
+ "--",
+ "-tc",
+ "-p"
+ ]
+ }
+ }
+}
\ No newline at end of file
diff --git a/ppt-translator/McpPptTranslator.sln b/ppt-translator/McpPptTranslator.sln
new file mode 100644
index 0000000..7ee0dbb
--- /dev/null
+++ b/ppt-translator/McpPptTranslator.sln
@@ -0,0 +1,52 @@
+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.PptTranslator.HybridApp", "src\McpSamples.PptTranslator.HybridApp\McpSamples.PptTranslator.HybridApp.csproj", "{4E3C401C-B16C-459D-8AFC-896CCF5DBACF}"
+EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "McpSamples.Shared", "..\shared\McpSamples.Shared\McpSamples.Shared.csproj", "{1F4DA2CA-2377-4800-A275-02F65E68CC2D}"
+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
+ {4E3C401C-B16C-459D-8AFC-896CCF5DBACF}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {4E3C401C-B16C-459D-8AFC-896CCF5DBACF}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {4E3C401C-B16C-459D-8AFC-896CCF5DBACF}.Debug|x64.ActiveCfg = Debug|Any CPU
+ {4E3C401C-B16C-459D-8AFC-896CCF5DBACF}.Debug|x64.Build.0 = Debug|Any CPU
+ {4E3C401C-B16C-459D-8AFC-896CCF5DBACF}.Debug|x86.ActiveCfg = Debug|Any CPU
+ {4E3C401C-B16C-459D-8AFC-896CCF5DBACF}.Debug|x86.Build.0 = Debug|Any CPU
+ {4E3C401C-B16C-459D-8AFC-896CCF5DBACF}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {4E3C401C-B16C-459D-8AFC-896CCF5DBACF}.Release|Any CPU.Build.0 = Release|Any CPU
+ {4E3C401C-B16C-459D-8AFC-896CCF5DBACF}.Release|x64.ActiveCfg = Release|Any CPU
+ {4E3C401C-B16C-459D-8AFC-896CCF5DBACF}.Release|x64.Build.0 = Release|Any CPU
+ {4E3C401C-B16C-459D-8AFC-896CCF5DBACF}.Release|x86.ActiveCfg = Release|Any CPU
+ {4E3C401C-B16C-459D-8AFC-896CCF5DBACF}.Release|x86.Build.0 = Release|Any CPU
+ {1F4DA2CA-2377-4800-A275-02F65E68CC2D}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {1F4DA2CA-2377-4800-A275-02F65E68CC2D}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {1F4DA2CA-2377-4800-A275-02F65E68CC2D}.Debug|x64.ActiveCfg = Debug|Any CPU
+ {1F4DA2CA-2377-4800-A275-02F65E68CC2D}.Debug|x64.Build.0 = Debug|Any CPU
+ {1F4DA2CA-2377-4800-A275-02F65E68CC2D}.Debug|x86.ActiveCfg = Debug|Any CPU
+ {1F4DA2CA-2377-4800-A275-02F65E68CC2D}.Debug|x86.Build.0 = Debug|Any CPU
+ {1F4DA2CA-2377-4800-A275-02F65E68CC2D}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {1F4DA2CA-2377-4800-A275-02F65E68CC2D}.Release|Any CPU.Build.0 = Release|Any CPU
+ {1F4DA2CA-2377-4800-A275-02F65E68CC2D}.Release|x64.ActiveCfg = Release|Any CPU
+ {1F4DA2CA-2377-4800-A275-02F65E68CC2D}.Release|x64.Build.0 = Release|Any CPU
+ {1F4DA2CA-2377-4800-A275-02F65E68CC2D}.Release|x86.ActiveCfg = Release|Any CPU
+ {1F4DA2CA-2377-4800-A275-02F65E68CC2D}.Release|x86.Build.0 = Release|Any CPU
+ EndGlobalSection
+ GlobalSection(SolutionProperties) = preSolution
+ HideSolutionNode = FALSE
+ EndGlobalSection
+ GlobalSection(NestedProjects) = preSolution
+ {4E3C401C-B16C-459D-8AFC-896CCF5DBACF} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B}
+ EndGlobalSection
+EndGlobal
\ No newline at end of file
diff --git a/ppt-translator/README.md b/ppt-translator/README.md
new file mode 100644
index 0000000..071a082
--- /dev/null
+++ b/ppt-translator/README.md
@@ -0,0 +1,385 @@
+# MCP Server: PPT Translator
+
+This is an MCP server that translates PowerPoint presentations to different languages using OpenAI API and ShapeCrawler. This MCP server has been redesigned with a modernized architecture for improved performance and maintainability.
+
+## Install
+
+[]() []()
+
+## 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)
+- [Docker Desktop](https://docs.docker.com/get-started/get-docker/)
+- [OpenAI API Key](https://platform.openai.com/api-keys)
+
+## What's Included
+
+PPT Translator MCP server includes:
+
+| Building Block | Name | Description | Usage |
+|----------------|----------------------------|-------------------------------------|-----------------------------|
+| Tools | `translate_ppt_file` | Translates a PowerPoint file to target language | `#translate_ppt_file` |
+| Prompts | `ppt_translator` | Structured workflow to guide translation process | `/mcp.ppt-translator.ppt_translator` |
+
+## Getting Started
+
+- [Getting repository root](#getting-repository-root)
+- [Running MCP server](#running-mcp-server)
+ - [On a local machine](#on-a-local-machine)
+ - [In a container](#in-a-container)
+ - [On Azure](#on-azure)
+- [Connect MCP server to an MCP host/client](#connect-mcp-server-to-an-mcp-hostclient)
+ - [VS Code + Agent Mode + Local MCP server](#vs-code--agent-mode--local-mcp-server)
+
+### 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 a local machine
+
+1. Set your OpenAI API key.
+
+ ```bash
+ export OPENAI_API_KEY="your-openai-api-key"
+ ```
+
+ ```powershell
+ $env:OPENAI_API_KEY="your-openai-api-key"
+ ```
+
+1. Run the MCP server app.
+
+ ```bash
+ cd $REPOSITORY_ROOT/ppt-translator
+ dotnet run --project ./src/McpSamples.PptTranslator.HybridApp
+ ```
+
+ > Make sure take note the absolute directory path of the `McpSamples.PptTranslator.HybridApp` project.
+
+ **Parameters**:
+
+ - `--http`: The switch that indicates to run this MCP server as a streamable HTTP type. When this switch is added, the MCP server URL is `http://localhost:5280`.
+
+ With these parameters, you can run the MCP server like:
+
+ ```bash
+ dotnet run --project ./src/McpSamples.PptTranslator.HybridApp -- --http
+ ```
+
+#### In a container
+
+1. Build the MCP server app as a container image.
+
+ ```bash
+ cd $REPOSITORY_ROOT
+ docker build -f Dockerfile.ppt-translator -t ppt-translator:latest .
+ ```
+ > Make sure take note the absolute directory path of the `ppt-translator` project.
+
+1. Run the MCP server app in a container.
+
+ ```bash
+ docker run -i --rm \
+ -e OPENAI_API_KEY=$OPENAI_API_KEY \
+ -e HOST_MOUNT_PATH=/Users/yourname/ppt-files \
+ -v /Users/yourname/ppt-files:/files \
+ ppt-translator:latest
+ ```
+
+ Alternatively, use the container image from the container registry.
+
+ ```bash
+ docker run -i --rm \
+ -e OPENAI_API_KEY=$OPENAI_API_KEY \
+ -e HOST_MOUNT_PATH=/Users/yourname/ppt-files \
+ -v /Users/yourname/ppt-files:/files \
+ ghcr.io/microsoft/mcp-dotnet-samples/ppt-translator:latest
+ ```
+
+ **Parameters**:
+
+ - `--http`: The switch that indicates to run this MCP server as a streamable HTTP type. When this switch is added, the MCP server URL is `http://localhost:8080`.
+
+ With these parameters, you can run the MCP server like:
+
+ ```bash
+ # use local container image
+ docker run -i --rm -p 8080:8080 \
+ -e OPENAI_API_KEY=$OPENAI_API_KEY \
+ -e HOST_MOUNT_PATH=/Users/yourname/ppt-files \
+ -v /Users/yourname/ppt-files:/files \
+ ppt-translator:latest -- --http
+ ```
+
+ ```bash
+ # use container image from the container registry
+ docker run -it --rm -p 8080:8080 \
+ -e OPENAI_API_KEY=$OPENAI_API_KEY \
+ -e HOST_MOUNT_PATH=/Users/yourname/ppt-files \
+ -v /Users/yourname/ppt-files:/files \
+ ghcr.io/microsoft/mcp-dotnet-samples/ppt-translator:latest -- --http
+ ```
+
+#### On Azure
+
+1. Navigate to the directory.
+
+ ```bash
+ cd $REPOSITORY_ROOT/ppt-translator
+ ```
+
+1. Login to Azure.
+
+ ```bash
+ # Login with Azure Developer CLI
+ azd auth login
+ ```
+
+1. Set OpenAI API Key.
+
+ ```bash
+ azd env set OPENAI_API_KEY "your-openai-api-key"
+ # 확인
+ azd env get-values
+ ```
+
+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 Container Apps FQDN:
+
+ ```bash
+ azd env get-value AZURE_RESOURCE_PPT_TRANSLATOR_FQDN
+ ```
+
+ If you want to use Azure, you must upload the file to the running server first:
+
+ ```bash
+ curl -F "file=@sample.pptx" https://{YOUR_FQDN}/upload
+ ```
+
+### Connect MCP server to an MCP host/client
+
+#### VS Code + Agent Mode + Local MCP server
+
+1. Copy `mcp.json` to the repository root.
+
+ **For locally running MCP server (STDIO):**
+
+ ```bash
+ mkdir -p $REPOSITORY_ROOT/.vscode
+ cp $REPOSITORY_ROOT/ppt-translator/.vscode/mcp.stdio.local.json \
+ $REPOSITORY_ROOT/.vscode/mcp.json
+ ```
+
+ ```powershell
+ New-Item -Type Directory -Path $REPOSITORY_ROOT/.vscode -Force
+ Copy-Item -Path $REPOSITORY_ROOT/ppt-translator/.vscode/mcp.stdio.local.json `
+ -Destination $REPOSITORY_ROOT/.vscode/mcp.json -Force
+ ```
+
+ **For locally running MCP server (HTTP):**
+
+ ```bash
+ mkdir -p $REPOSITORY_ROOT/.vscode
+ cp $REPOSITORY_ROOT/ppt-translator/.vscode/mcp.http.local.json \
+ $REPOSITORY_ROOT/.vscode/mcp.json
+ ```
+
+ ```powershell
+ New-Item -Type Directory -Path $REPOSITORY_ROOT/.vscode -Force
+ Copy-Item -Path $REPOSITORY_ROOT/ppt-translator/.vscode/mcp.http.local.json `
+ -Destination $REPOSITORY_ROOT/.vscode/mcp.json -Force
+ ```
+
+ **For locally running MCP server in a container (STDIO):**
+
+ ```bash
+ mkdir -p $REPOSITORY_ROOT/.vscode
+ cp $REPOSITORY_ROOT/ppt-translator/.vscode/mcp.stdio.container.json \
+ $REPOSITORY_ROOT/.vscode/mcp.json
+ ```
+
+ ```powershell
+ New-Item -Type Directory -Path $REPOSITORY_ROOT/.vscode -Force
+ Copy-Item -Path $REPOSITORY_ROOT/ppt-translator/.vscode/mcp.stdio.container.json `
+ -Destination $REPOSITORY_ROOT/.vscode/mcp.json -Force
+ ```
+
+ **For locally running MCP server in a container (HTTP):**
+
+ ```bash
+ mkdir -p $REPOSITORY_ROOT/.vscode
+ cp $REPOSITORY_ROOT/ppt-translator/.vscode/mcp.http.container.json \
+ $REPOSITORY_ROOT/.vscode/mcp.json
+ ```
+
+ ```powershell
+ New-Item -Type Directory -Path $REPOSITORY_ROOT/.vscode -Force
+ Copy-Item -Path $REPOSITORY_ROOT/ppt-translator/.vscode/mcp.http.container.json `
+ -Destination $REPOSITORY_ROOT/.vscode/mcp.json -Force
+ ```
+
+ **For remotely running MCP server in a container (HTTP):**
+
+ ```bash
+ mkdir -p $REPOSITORY_ROOT/.vscode
+ cp $REPOSITORY_ROOT/ppt-translator/.vscode/mcp.http.remote.json \
+ $REPOSITORY_ROOT/.vscode/mcp.json
+ ```
+
+ ```powershell
+ New-Item -Type Directory -Path $REPOSITORY_ROOT/.vscode -Force
+ Copy-Item -Path $REPOSITORY_ROOT/ppt-translator/.vscode/mcp.http.remote.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 `ppt-translator` then click `Start Server`.
+1. When prompted, enter one of the following values:
+ - The absolute directory path of the `McpSamples.PptTranslator.HybridApp` project
+ - The FQDN of Azure Container Apps.
+1. Enter prompt like:
+
+ ```text
+ Translate /path/to/presentation.pptx to Korean
+ ```
+
+1. Confirm the result.
+
+## Features
+
+- **Multi-language Support**: Translate to any language (ko, en, ja, etc.)
+- **Multiple File Format Support**: Enhanced to support various document formats
+- **Preserves Formatting**: Maintains original PPT structure and styling
+- **OpenAI Integration**: Uses GPT models for high-quality translation
+- **Integrated Prompt System**: Built-in MCP prompts for enhanced user guidance
+- **Streamlined Architecture**: Refactored services for better performance
+- **5 Execution Modes**: stdio.local, http.local, stdio.container, http.container, http.remote
+
+## Tool Reference
+
+### translate_ppt_file
+
+Translates a PowerPoint file to target language.
+
+**Parameters:**
+- `filePath` (required): Path to PPT file
+ - Local: absolute path (e.g., `/Users/name/file.pptx`)
+ - Container: filename only (e.g., `sample.pptx`)
+ - Azure: filename only (e.g., `sample.pptx`)
+- `targetLang` (required): Target language code (e.g., `ko`, `en`, `ja`)
+- `outputPath` (optional): Custom output directory (local modes only)
+
+## Architecture
+
+### Modernized Service Architecture
+
+This version features a completely refactored service architecture:
+
+- **Streamlined Services**: Removed legacy components and consolidated file processing
+- **Enhanced Tool System**: Improved translation tools with better error handling
+- **Integrated Prompt System**: Added structured prompts for better MCP integration
+- **Optimized File Processing**: Direct file handling for improved performance
+
+### File Storage
+
+| Mode | Input | Output | Download |
+|------|-------|--------|----------|
+| stdio.local | Absolute path | `wwwroot/generated` | File path |
+| http.local | Absolute path | `wwwroot/generated` | `/download/{filename}` |
+| stdio.container | `/files/{filename}` | `/files/{filename}` | File path (host) |
+| http.container | `/files/{filename}` | `/files/{filename}` | `/download/{filename}` |
+| http.remote | `/files/{filename}` | `/files/{filename}` | `/download/{filename}` |
+
+### Azure Infrastructure
+
+- **Container Registry**: Stores Docker image
+- **Storage Account**: Azure File Share (`ppt-files`, 5TB)
+- **Container Apps**: Runs server with auto-scaling (1-10 replicas)
+- **Volume Mount**: `/files` mapped to Azure File Share
+- **Monitoring**: Application Insights + Log Analytics
+
+## Troubleshooting
+
+### ARM64 Mac (M1/M2/M3)
+
+SkiaSharp requires x86_64 platform:
+
+```bash
+docker buildx build --platform linux/amd64 -t ppt-translator:latest -f Dockerfile.ppt-translator .
+```
+
+### Container File Not Found
+
+Ensure file is in mounted directory:
+
+```bash
+# 🍎/🐧 macOS & Linux
+cp "/path/to/file.pptx" "/path/to/mount/folder/file.pptx"
+
+# 💻 Windows Command Prompt
+copy "\path\to\file.pptx" "\path\to\mount\folder\file.pptx"
+
+# 💻 Windows PowerShell
+Copy-Item "/path/to/file.pptx" -Destination "/path/to/mount/folder/file.pptx"
+```
+
+## Development
+
+### Recent Changes (v2.0)
+
+This version includes major architectural improvements:
+
+- **Refactored Service Layer**: Removed legacy components (`TempFileResolver`, deprecated tools)
+- **Enhanced Prompt System**: Added `PptTranslatorPrompt` for better MCP integration
+- **Improved File Processing**: Streamlined file handling and processing pipeline
+- **Multi-format Foundation**: Architecture prepared for supporting multiple document formats
+
+### Build
+
+```bash
+dotnet build
+```
+
+### Test Locally
+
+```bash
+export OPENAI_API_KEY="your-key"
+dotnet run -- --http
+```
+
+### Build Docker Image
+
+```bash
+docker buildx build --platform linux/amd64 -t ppt-translator:latest -f Dockerfile.ppt-translator .
+```
+
+### Deploy to Azure
+
+```bash
+azd up
+```
diff --git a/ppt-translator/TestFiles/sample.pptx b/ppt-translator/TestFiles/sample.pptx
new file mode 100644
index 0000000..df20b64
Binary files /dev/null and b/ppt-translator/TestFiles/sample.pptx differ
diff --git a/ppt-translator/TestFiles/sample2.pptx b/ppt-translator/TestFiles/sample2.pptx
new file mode 100644
index 0000000..577a2ed
Binary files /dev/null and b/ppt-translator/TestFiles/sample2.pptx differ
diff --git a/ppt-translator/azure.yaml b/ppt-translator/azure.yaml
new file mode 100644
index 0000000..508a520
--- /dev/null
+++ b/ppt-translator/azure.yaml
@@ -0,0 +1,23 @@
+# yaml-language-server: $schema=https://raw.githubusercontent.com/Azure/azure-dev/main/schemas/v1.0/azure.yaml.json
+
+name: ppt-translator
+
+metadata:
+ template: azd-init@1.14.0
+
+services:
+ ppt-translator:
+ project: src/McpSamples.PptTranslator.HybridApp
+ language: dotnet
+ host: containerapp
+ docker:
+ path: ../../../Dockerfile.ppt-translator-azure
+ context: ../../../
+ remoteBuild: true
+
+infra:
+ path: infra
+ parameters:
+ openAiApiKey:
+ type: secret
+ value: ${OPENAI_API_KEY}
diff --git a/ppt-translator/infra/abbreviations.json b/ppt-translator/infra/abbreviations.json
new file mode 100644
index 0000000..1533dee
--- /dev/null
+++ b/ppt-translator/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/ppt-translator/infra/main.bicep b/ppt-translator/infra/main.bicep
new file mode 100644
index 0000000..ae8d8ed
--- /dev/null
+++ b/ppt-translator/infra/main.bicep
@@ -0,0 +1,55 @@
+targetScope = 'subscription'
+
+@description('Environment name used for naming resources')
+@minLength(1)
+@maxLength(64)
+param environmentName string
+
+@description('Primary deployment region')
+@minLength(1)
+param location string
+
+param pptTranslatorExists bool
+
+@description('Id of the user or app to assign application roles')
+param principalId string
+
+@secure()
+@description('OpenAI API Key')
+param openAiApiKey string
+
+var tags = {
+ 'azd-env-name': environmentName
+}
+
+resource rg 'Microsoft.Resources/resourceGroups@2021-04-01' = {
+ name: 'rg-${environmentName}'
+ location: location
+ tags: tags
+}
+
+module resources './resources.bicep' = {
+ name: 'ppt-translator-resources'
+ scope: rg
+ params: {
+ location: location
+ tags: tags
+ openAiApiKey: openAiApiKey
+ principalId: principalId
+ pptTranslatorExists: pptTranslatorExists
+ }
+}
+
+// Outputs following azd naming conventions
+output AZURE_CONTAINER_REGISTRY_ENDPOINT string = resources.outputs.AZURE_CONTAINER_REGISTRY_ENDPOINT
+output AZURE_CONTAINER_REGISTRY_NAME string = resources.outputs.AZURE_CONTAINER_REGISTRY_NAME
+output AZURE_RESOURCE_PPT_TRANSLATOR_ID string = resources.outputs.AZURE_RESOURCE_PPT_TRANSLATOR_ID
+output AZURE_RESOURCE_PPT_TRANSLATOR_NAME string = resources.outputs.AZURE_RESOURCE_PPT_TRANSLATOR_NAME
+output AZURE_RESOURCE_PPT_TRANSLATOR_FQDN string = resources.outputs.AZURE_RESOURCE_PPT_TRANSLATOR_FQDN
+output AZURE_STORAGE_ACCOUNT_NAME string = resources.outputs.AZURE_STORAGE_ACCOUNT_NAME
+output AZURE_STORAGE_FILE_SHARE_NAME string = resources.outputs.AZURE_STORAGE_FILE_SHARE_NAME
+
+// Service-specific outputs for azd
+output SERVICE_PPT_TRANSLATOR_NAME string = resources.outputs.SERVICE_PPT_TRANSLATOR_NAME
+output SERVICE_PPT_TRANSLATOR_IDENTITY_PRINCIPAL_ID string = resources.outputs.SERVICE_PPT_TRANSLATOR_IDENTITY_PRINCIPAL_ID
+output SERVICE_PPT_TRANSLATOR_IMAGE_NAME string = resources.outputs.SERVICE_PPT_TRANSLATOR_IMAGE_NAME
diff --git a/ppt-translator/infra/main.parameters.json b/ppt-translator/infra/main.parameters.json
new file mode 100644
index 0000000..a60deb2
--- /dev/null
+++ b/ppt-translator/infra/main.parameters.json
@@ -0,0 +1,18 @@
+{
+ "$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}"
+ },
+ "pptTranslatorExists": {
+ "value": "${SERVICE_PPT_TRANSLATOR_RESOURCE_EXISTS=false}"
+ },
+ "principalId": {
+ "value": "${AZURE_PRINCIPAL_ID=}"
+ }
+ }
+}
diff --git a/ppt-translator/infra/modules/fetch-container-image.bicep b/ppt-translator/infra/modules/fetch-container-image.bicep
new file mode 100644
index 0000000..78d1e7e
--- /dev/null
+++ b/ppt-translator/infra/modules/fetch-container-image.bicep
@@ -0,0 +1,8 @@
+param exists bool
+param name string
+
+resource existingApp 'Microsoft.App/containerApps@2023-05-02-preview' existing = if (exists) {
+ name: name
+}
+
+output containers array = exists ? existingApp.properties.template.containers : []
diff --git a/ppt-translator/infra/resources.bicep b/ppt-translator/infra/resources.bicep
new file mode 100644
index 0000000..f15e4a9
--- /dev/null
+++ b/ppt-translator/infra/resources.bicep
@@ -0,0 +1,218 @@
+@description('Azure region where resources will be deployed')
+param location string = resourceGroup().location
+
+@description('Tags applied to all resources')
+param tags object = {}
+
+param pptTranslatorExists bool
+
+@description('Id of the user or app to assign application roles')
+param principalId string
+
+@secure()
+param openAiApiKey string
+
+var abbrs = loadJsonContent('./abbreviations.json')
+var resourceToken = uniqueString(subscription().id, resourceGroup().id, location)
+var pptTranslatorAppName = 'ppt-translator'
+
+// Monitor application with Azure Monitor
+module monitoring 'br/public:avm/ptn/azd/monitoring:0.1.0' = {
+ name: 'monitoring'
+ params: {
+ logAnalyticsName: '${abbrs.operationalInsightsWorkspaces}${resourceToken}'
+ applicationInsightsName: '${abbrs.insightsComponents}${resourceToken}'
+ applicationInsightsDashboardName: '${abbrs.portalDashboards}${resourceToken}'
+ location: location
+ tags: tags
+ }
+}
+
+// Container registry
+module containerRegistry 'br/public:avm/res/container-registry/registry:0.1.1' = {
+ name: 'registry'
+ params: {
+ name: '${abbrs.containerRegistryRegistries}${resourceToken}'
+ location: location
+ tags: tags
+ publicNetworkAccess: 'Enabled'
+ roleAssignments: [
+ {
+ principalId: pptTranslatorIdentity.outputs.principalId
+ principalType: 'ServicePrincipal'
+ // ACR pull role
+ roleDefinitionIdOrName: subscriptionResourceId('Microsoft.Authorization/roleDefinitions', '7f951dda-4ed3-4680-a7ca-43fe172d538d')
+ }
+ ]
+ }
+}
+
+// User assigned identity
+module pptTranslatorIdentity 'br/public:avm/res/managed-identity/user-assigned-identity:0.2.1' = {
+ name: 'pptTranslatorIdentity'
+ params: {
+ name: '${abbrs.managedIdentityUserAssignedIdentities}ppttrans-${resourceToken}'
+ location: location
+ }
+}
+
+//
+// 1. Storage Account with Single File Share
+//
+resource storageAccount 'Microsoft.Storage/storageAccounts@2022-09-01' = {
+ name: 'st${resourceToken}'
+ location: location
+ tags: tags
+ sku: {
+ name: 'Standard_LRS'
+ }
+ kind: 'StorageV2'
+ properties: {
+ minimumTlsVersion: 'TLS1_2'
+ allowBlobPublicAccess: false
+ supportsHttpsTrafficOnly: true
+ }
+
+ resource fileServices 'fileServices' = {
+ name: 'default'
+
+ resource filesShare 'shares' = {
+ name: 'ppt-files'
+ properties: {
+ shareQuota: 100 // 100GB
+ enabledProtocols: 'SMB'
+ }
+ }
+ }
+}
+
+//
+// 2. Container App Environment
+//
+module containerAppsEnvironment 'br/public:avm/res/app/managed-environment:0.4.5' = {
+ name: 'container-apps-environment'
+ params: {
+ logAnalyticsWorkspaceResourceId: monitoring.outputs.logAnalyticsWorkspaceResourceId
+ name: '${abbrs.appManagedEnvironments}${resourceToken}'
+ location: location
+ zoneRedundant: false
+ }
+}
+
+// Register Azure Files storage with Container App Environment
+resource managedEnv 'Microsoft.App/managedEnvironments@2023-05-01' existing = {
+ name: '${abbrs.appManagedEnvironments}${resourceToken}'
+}
+
+resource filesStorage 'Microsoft.App/managedEnvironments/storages@2023-05-01' = {
+ name: 'files-storage'
+ parent: managedEnv
+ properties: {
+ azureFile: {
+ accountName: storageAccount.name
+ accountKey: storageAccount.listKeys().keys[0].value
+ shareName: storageAccount::fileServices::filesShare.name
+ accessMode: 'ReadWrite'
+ }
+ }
+}
+
+//
+// 3. Container App with Volume Mount
+//
+//
+// 3. Container App with Volume Mount (using AVM module)
+//
+module pptTranslatorFetchLatestImage './modules/fetch-container-image.bicep' = {
+ name: 'pptTranslator-fetch-image'
+ params: {
+ exists: pptTranslatorExists
+ name: 'ppt-translator'
+ }
+}
+
+module pptTranslator 'br/public:avm/res/app/container-app:0.8.0' = {
+ name: 'pptTranslator'
+ params: {
+ name: pptTranslatorAppName
+ location: location
+ tags: union(tags, { 'azd-service-name': 'ppt-translator' })
+ environmentResourceId: containerAppsEnvironment.outputs.resourceId
+ ingressTargetPort: 8080
+ scaleMinReplicas: 1
+ scaleMaxReplicas: 10
+ secrets: {
+ secureList: [
+ {
+ name: 'openai-api-key'
+ value: openAiApiKey
+ }
+ {
+ name: 'storage-connection'
+ value: 'DefaultEndpointsProtocol=https;AccountName=${storageAccount.name};AccountKey=${storageAccount.listKeys().keys[0].value};EndpointSuffix=${environment().suffixes.storage}'
+ }
+ ]
+ }
+ containers: [
+ {
+ image: pptTranslatorFetchLatestImage.outputs.?containers[?0].?image ?? 'mcr.microsoft.com/azuredocs/containerapps-helloworld:latest'
+ name: 'main'
+ resources: {
+ cpu: json('0.5')
+ memory: '1.0Gi'
+ }
+ args: [
+ '--http'
+ ]
+ env: [
+ {
+ name: 'OPENAI_API_KEY'
+ secretRef: 'openai-api-key'
+ }
+ {
+ name: 'AZURE_STORAGE_CONNECTION_STRING'
+ secretRef: 'storage-connection'
+ }
+ ]
+ volumeMounts: [
+ {
+ volumeName: 'files-volume'
+ mountPath: '/files'
+ }
+ ]
+ }
+ ]
+ volumes: [
+ {
+ name: 'files-volume'
+ storageType: 'AzureFile'
+ storageName: filesStorage.name
+ }
+ ]
+ managedIdentities: {
+ userAssignedResourceIds: [
+ pptTranslatorIdentity.outputs.resourceId
+ ]
+ }
+ registries: [
+ {
+ server: containerRegistry.outputs.loginServer
+ identity: pptTranslatorIdentity.outputs.resourceId
+ }
+ ]
+ }
+}
+
+output AZURE_CONTAINER_REGISTRY_ENDPOINT string = containerRegistry.outputs.loginServer
+output AZURE_CONTAINER_REGISTRY_NAME string = containerRegistry.outputs.name
+output AZURE_RESOURCE_PPT_TRANSLATOR_ID string = pptTranslator.outputs.resourceId
+output AZURE_RESOURCE_PPT_TRANSLATOR_NAME string = pptTranslator.outputs.name
+output AZURE_RESOURCE_PPT_TRANSLATOR_FQDN string = pptTranslator.outputs.fqdn
+output AZURE_STORAGE_ACCOUNT_NAME string = storageAccount.name
+output AZURE_STORAGE_FILE_SHARE_NAME string = storageAccount::fileServices::filesShare.name
+
+// azd expects these specific output names for container apps
+output SERVICE_PPT_TRANSLATOR_NAME string = pptTranslator.outputs.name
+output SERVICE_PPT_TRANSLATOR_IDENTITY_PRINCIPAL_ID string = pptTranslatorIdentity.outputs.principalId
+output SERVICE_PPT_TRANSLATOR_IMAGE_NAME string = pptTranslatorFetchLatestImage.outputs.?containers[?0].?image ?? 'mcr.microsoft.com/azuredocs/containerapps-helloworld:latest'
+
diff --git a/ppt-translator/src/McpSamples.PptTranslator.HybridApp/Configurations/PptTranslatorAppSettings.cs b/ppt-translator/src/McpSamples.PptTranslator.HybridApp/Configurations/PptTranslatorAppSettings.cs
new file mode 100644
index 0000000..5f5b3cf
--- /dev/null
+++ b/ppt-translator/src/McpSamples.PptTranslator.HybridApp/Configurations/PptTranslatorAppSettings.cs
@@ -0,0 +1,18 @@
+using McpSamples.Shared.Configurations;
+using Microsoft.OpenApi.Models;
+
+namespace McpSamples.PptTranslator.HybridApp.Configurations;
+
+///
+/// Application settings for the PPT Translator service.
+///
+public class PptTranslatorAppSettings : AppSettings
+{
+ ///
+ public override OpenApiInfo OpenApi { get; set; } = new()
+ {
+ Title = "MCP PPT Translator",
+ Version = "1.0.0",
+ Description = "MCP server for translating PowerPoint (.pptx) files."
+ };
+}
diff --git a/ppt-translator/src/McpSamples.PptTranslator.HybridApp/McpSamples.PptTranslator.HybridApp.csproj b/ppt-translator/src/McpSamples.PptTranslator.HybridApp/McpSamples.PptTranslator.HybridApp.csproj
new file mode 100644
index 0000000..723ebdd
--- /dev/null
+++ b/ppt-translator/src/McpSamples.PptTranslator.HybridApp/McpSamples.PptTranslator.HybridApp.csproj
@@ -0,0 +1,28 @@
+
+
+
+ Exe
+ net9.0
+ McpSamples.PptTranslator.HybridApp
+ enable
+ enable
+ 5e4ba7bc-008d-4689-8311-3be925ba73a8
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/ppt-translator/src/McpSamples.PptTranslator.HybridApp/Models/ExecutionMode.cs b/ppt-translator/src/McpSamples.PptTranslator.HybridApp/Models/ExecutionMode.cs
new file mode 100644
index 0000000..9ea16a8
--- /dev/null
+++ b/ppt-translator/src/McpSamples.PptTranslator.HybridApp/Models/ExecutionMode.cs
@@ -0,0 +1,94 @@
+using System;
+
+namespace McpSamples.PptTranslator.HybridApp.Models
+{
+ ///
+ /// Represents the execution mode of the MCP server.
+ ///
+ public enum ExecutionMode
+ {
+ ///
+ /// Running locally with STDIO communication.
+ ///
+ StdioLocal,
+
+ ///
+ /// Running locally with HTTP communication.
+ ///
+ HttpLocal,
+
+ ///
+ /// Running in a container with STDIO communication and volume mounts.
+ ///
+ StdioContainer,
+
+ ///
+ /// Running in a container with HTTP communication and volume mounts.
+ ///
+ HttpContainer,
+
+ ///
+ /// Running in Azure Container Apps with HTTP communication.
+ ///
+ HttpRemote
+ }
+
+ ///
+ /// Provides utilities for detecting and working with execution modes.
+ ///
+ public static class ExecutionModeDetector
+ {
+ ///
+ /// Detects the current execution mode based on environment variables.
+ ///
+ /// The detected execution mode.
+ public static ExecutionMode DetectExecutionMode()
+ {
+ bool inContainer = Environment.GetEnvironmentVariable("DOTNET_RUNNING_IN_CONTAINER") == "true";
+ bool isAzure = !string.IsNullOrEmpty(Environment.GetEnvironmentVariable("CONTAINER_APP_NAME"));
+ bool isHttp = Environment.GetEnvironmentVariable("MCP_HTTP_MODE") == "true";
+
+ if (isAzure)
+ return ExecutionMode.HttpRemote;
+
+ if (inContainer && isHttp)
+ return ExecutionMode.HttpContainer;
+
+ if (inContainer)
+ return ExecutionMode.StdioContainer;
+
+ if (isHttp)
+ return ExecutionMode.HttpLocal;
+
+ return ExecutionMode.StdioLocal;
+ }
+
+ ///
+ /// Gets the host mount path from environment variable (for container modes).
+ /// This is the single folder on the host that is mounted to /files in the container.
+ ///
+ public static string? GetHostMountPath()
+ {
+ return Environment.GetEnvironmentVariable("HOST_MOUNT_PATH");
+ }
+
+ ///
+ /// Checks if the current mode is a container mode.
+ ///
+ public static bool IsContainerMode(this ExecutionMode mode)
+ {
+ return mode == ExecutionMode.StdioContainer
+ || mode == ExecutionMode.HttpContainer
+ || mode == ExecutionMode.HttpRemote;
+ }
+
+ ///
+ /// Checks if the current mode is a local mode.
+ ///
+ public static bool IsLocalMode(this ExecutionMode mode)
+ {
+ return mode == ExecutionMode.StdioLocal
+ || mode == ExecutionMode.HttpLocal;
+ }
+ }
+}
diff --git a/ppt-translator/src/McpSamples.PptTranslator.HybridApp/Models/PptTextExtractResult.cs b/ppt-translator/src/McpSamples.PptTranslator.HybridApp/Models/PptTextExtractResult.cs
new file mode 100644
index 0000000..292b752
--- /dev/null
+++ b/ppt-translator/src/McpSamples.PptTranslator.HybridApp/Models/PptTextExtractResult.cs
@@ -0,0 +1,21 @@
+namespace McpSamples.PptTranslator.HybridApp.Models
+{
+ ///
+ /// Contains the text extraction result from a PPT file.
+ ///
+ public class PptTextExtractResult
+ {
+ public int TotalCount { get; set; }
+ public List Items { get; set; } = new();
+ }
+
+ ///
+ /// Represents a text item extracted from a slide.
+ ///
+ public class PptTextExtractItem
+ {
+ public int SlideIndex { get; set; }
+ public string ShapeId { get; set; } = string.Empty;
+ public string Text { get; set; } = string.Empty;
+ }
+}
diff --git a/ppt-translator/src/McpSamples.PptTranslator.HybridApp/Program.cs b/ppt-translator/src/McpSamples.PptTranslator.HybridApp/Program.cs
new file mode 100644
index 0000000..dda6d76
--- /dev/null
+++ b/ppt-translator/src/McpSamples.PptTranslator.HybridApp/Program.cs
@@ -0,0 +1,111 @@
+using McpSamples.PptTranslator.HybridApp.Configurations;
+using McpSamples.PptTranslator.HybridApp.Services;
+using McpSamples.PptTranslator.HybridApp.Tools;
+using McpSamples.PptTranslator.HybridApp.Models;
+using McpSamples.PptTranslator.HybridApp.Prompts;
+using McpSamples.Shared.Configurations;
+using McpSamples.Shared.Extensions;
+using ModelContextProtocol.Server;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Hosting;
+
+var useStreamableHttp = AppSettings.UseStreamableHttp(Environment.GetEnvironmentVariables(), args);
+
+// MCP_HTTP_MODE 환경변수 설정 (ExecutionMode 감지용)
+if (useStreamableHttp)
+{
+ Environment.SetEnvironmentVariable("MCP_HTTP_MODE", "true");
+}
+
+var executionMode = ExecutionModeDetector.DetectExecutionMode();
+
+bool isAzure = !string.IsNullOrEmpty(Environment.GetEnvironmentVariable("CONTAINER_APP_NAME"))
+ || !string.IsNullOrEmpty(Environment.GetEnvironmentVariable("WEBSITE_HOSTNAME"));
+
+const string FilesDir = "/files"; // 단일 마운트 폴더
+
+IHostApplicationBuilder builder = useStreamableHttp
+ ? WebApplication.CreateBuilder(args)
+ : Host.CreateApplicationBuilder(args);
+
+builder.Configuration.AddEnvironmentVariables();
+
+builder.Services.AddAppSettings(builder.Configuration, args);
+builder.Services.AddLogging();
+builder.Services.AddHttpContextAccessor();
+
+// Services
+builder.Services.AddScoped();
+builder.Services.AddScoped();
+builder.Services.AddScoped();
+builder.Services.AddScoped();
+builder.Services.AddScoped();
+builder.Services.AddSingleton();
+
+IHost appHost = builder.BuildApp(useStreamableHttp);
+
+// HTTP MODE
+if (useStreamableHttp)
+{
+ var app = (WebApplication)appHost;
+
+ // ---------------------------
+ // 1) Upload (Azure only - uploads to /files/input)
+ // ---------------------------
+ app.MapPost("/upload", async (HttpRequest req) =>
+ {
+ if (!req.HasFormContentType)
+ return Results.BadRequest("multipart/form-data required");
+
+ var file = (await req.ReadFormAsync()).Files["file"];
+ if (file == null)
+ return Results.BadRequest("file required");
+
+ if (isAzure)
+ {
+ // input 폴더에 저장하여 일관성 보장
+ string inputDir = Path.Combine(FilesDir, "input");
+ Directory.CreateDirectory(inputDir);
+
+ string fileName = file.FileName; // 원본 파일명 사용
+ string savePath = Path.Combine(inputDir, fileName);
+
+ using var fs = File.Create(savePath);
+ await file.CopyToAsync(fs);
+
+ return Results.Ok(new { id = fileName, path = fileName });
+ }
+ else
+ {
+ return Results.BadRequest("Upload endpoint is only available in Azure mode. For local mode, provide absolute file path directly.");
+ }
+ });
+
+
+
+
+
+ // ---------------------------
+ // 4) Download by filename (Primary endpoint for all modes)
+ // ---------------------------
+ app.MapGet("/download/{filename}", (string filename) =>
+ {
+ string filePath = executionMode.IsContainerMode()
+ ? Path.Combine(FilesDir, "output", filename) // Container/Azure 모드: /files/output 폴더 사용
+ : Path.Combine(Directory.GetCurrentDirectory(), "wwwroot", "generated", filename); // 로컬 모드
+
+ return File.Exists(filePath)
+ ? Results.File(filePath,
+ "application/vnd.openxmlformats-officedocument.presentationml.presentation",
+ filename)
+ : Results.NotFound();
+ });
+
+
+
+ await app.RunAsync();
+}
+else
+{
+ await appHost.RunAsync();
+}
diff --git a/ppt-translator/src/McpSamples.PptTranslator.HybridApp/Prompts/PptTranslatorPrompt.cs b/ppt-translator/src/McpSamples.PptTranslator.HybridApp/Prompts/PptTranslatorPrompt.cs
new file mode 100644
index 0000000..9d399a1
--- /dev/null
+++ b/ppt-translator/src/McpSamples.PptTranslator.HybridApp/Prompts/PptTranslatorPrompt.cs
@@ -0,0 +1,89 @@
+using System.ComponentModel;
+using ModelContextProtocol.Server;
+using System.Linq;
+
+namespace McpSamples.PptTranslator.HybridApp.Prompts;
+
+///
+/// This provides interfaces for PPT Translator prompts.
+///
+public interface IPptTranslatorPrompt
+{
+ ///
+ /// Gets a prompt to start the PPT translation workflow.
+ ///
+ /// The full path to the PPTX file on the host machine or a public URL.
+ /// The target language code.
+ /// The optional download path for the translated file.
+ string GetTranslationPrompt(string hostFilePath, string targetLang, string? downloadPath = null);
+}
+
+///
+/// This represents the prompts entity for the PptTranslator system.
+///
+[McpServerPromptType]
+public class PptTranslatorPrompt : IPptTranslatorPrompt
+{
+ ///
+ [McpServerPrompt(Name = "translate_ppt", Title = "Start PPT Translation Workflow")]
+ [Description("Generate a structured workflow prompt for analyzing and translating PPT files.")]
+ public string GetTranslationPrompt(
+ [Description("The full path to the PPTX file or a public URL.")] string hostFilePath,
+ [Description("The target language code (e.g., 'ko', 'en', 'ja')")] string targetLang,
+ [Description("The optional download path for the translated file. Press Enter to use default locations.")] string? downloadPath = null)
+ {
+ string downloadPathInfo = string.IsNullOrWhiteSpace(downloadPath)
+ ? "If not specified (press Enter to use default):"
+ : $"Specified save path: `{downloadPath}`";
+
+ return $"""
+ The user wants to translate the PPT file located at **{hostFilePath}** to **{targetLang}**.
+
+ ### 🎯 Translation Request
+
+ * **Source File:** `{hostFilePath}`
+ * **Target Language:** `{targetLang}`
+ * **Save Path:** {downloadPathInfo}
+
+ ### 🚀 How to Execute
+
+ Simply call the **translate_ppt_file** tool with the following parameters:
+ - **filePath:** `{hostFilePath}`
+ - **targetLang:** `{targetLang}`{(string.IsNullOrWhiteSpace(downloadPath) ? "" : $"\n - **outputPath:** `{downloadPath}`")}
+
+ The tool will automatically handle:
+ ✅ File access and validation
+ ✅ Content extraction
+ ✅ Translation to target language
+ ✅ File generation and saving
+ ✅ Result delivery
+
+ ### 🚧 Automated Error Handling Instructions
+
+ **If the tool returns a file access error:**
+
+ 1. **For Container Volume Mount Errors:**
+ - **Automatically execute** the appropriate copy command based on the user's operating system:
+ - **macOS/Linux:** Run `cp "{hostFilePath}" [VOLUME_MOUNT_PATH]` in terminal
+ - **Windows CMD:** Run `copy "{hostFilePath}" [VOLUME_MOUNT_PATH]` in terminal
+ - **Windows PowerShell:** Run `Copy-Item "{hostFilePath}" -Destination [VOLUME_MOUNT_PATH]` in terminal
+ - Replace `[VOLUME_MOUNT_PATH]` with the actual path from the error message
+ - **Then retry** the `translate_ppt_file` tool with the corrected file path
+
+ 2. **For URL Upload Scenarios:**
+ - **Automatically execute** the upload command based on the user's operating system:
+ - **macOS/Linux:** Run `curl -F "file=@{hostFilePath}" [UPLOAD_URL]` in terminal
+ - **Windows:** Run `curl.exe -F "file=@{hostFilePath}" [UPLOAD_URL]` in terminal
+ - Replace `[UPLOAD_URL]` with the actual URL from the error message
+ - **Then retry** the `translate_ppt_file` tool using the uploaded file reference
+
+ **Important:** You must automatically execute these commands using the terminal tool, not instruct the user to copy and paste them manually.
+
+ ### 📍 Expected Output Location
+
+ {(string.IsNullOrWhiteSpace(downloadPath) ?
+ "- **Local Environment:** `wwwroot/generated/`\n - **Container Environment:** Mount path (e.g., `/files/`)\n - **Azure Environment:** Download link will be provided" :
+ $"- **Specified Path:** `{downloadPath}`")}
+ """;
+ }
+}
\ No newline at end of file
diff --git a/ppt-translator/src/McpSamples.PptTranslator.HybridApp/Prompts/translation_prompt.txt b/ppt-translator/src/McpSamples.PptTranslator.HybridApp/Prompts/translation_prompt.txt
new file mode 100644
index 0000000..6d82eb4
--- /dev/null
+++ b/ppt-translator/src/McpSamples.PptTranslator.HybridApp/Prompts/translation_prompt.txt
@@ -0,0 +1,87 @@
+You are a professional translator specialized in PowerPoint presentation content.
+
+The input is a JSON structure that contains extracted text from slides.
+Each object in the JSON has the following fields:
+- SlideIndex: The slide number
+- ShapeId: The unique identifier of the text shape
+- Text: The original extracted text
+
+Your goal is to translate ONLY the `Text` values into **{{TARGET_LANG}}**,
+while keeping the overall JSON structure (including SlideIndex and ShapeId) unchanged.
+
+===============================
+TRANSLATION RULES
+===============================
+1. JSON FORMAT
+- Do NOT modify keys, structure, or ordering.
+- Output must be valid JSON only.
+- Translate only the value in "Text".
+
+2. DO NOT TRANSLATE:
+- Brand names: Microsoft, PowerPoint, Azure
+- Acronyms: AI, API, GPU, HTTP, JSON
+- Protocol names: Model Context Protocol, OAuth, WebSocket
+- Model names: GPT-5, GPT-4o, Llama 3
+- Code, paths, URLs, formulas
+
+3. ACADEMIC TONE
+- Use clear, formal, precise language.
+- Maintain semantic meaning.
+- Preserve sentence length and structure.
+
+4. MIXED LANGUAGE HANDLING (UPDATED)
+- If the text contains both a main language and a secondary language in parentheses,
+ translate ONLY the main natural-language part.
+- Completely REMOVE the secondary language part inside parentheses.
+- Example:
+ Input: "데이터 분석 (Data Analysis)"
+ Target=en → "Data Analysis"
+ Target=ko → "데이터 분석"
+- Never swap languages.
+- Never keep the secondary language.
+- Only translate the main part and delete the secondary one.
+
+5. STRUCTURE PRESERVATION
+- Preserve line breaks (\n)
+- Preserve lists
+- Preserve formatting markers like **bold**
+
+6. DO NOT ADD ANY CONTENT
+
+
+===============================
+EXAMPLE
+===============================
+Input:
+{
+ "TotalCount": 2,
+ "Items": [
+ {
+ "SlideIndex": 1,
+ "ShapeId": "TextBox 5",
+ "Text": "Project Overview"
+ },
+ {
+ "SlideIndex": 2,
+ "ShapeId": "TextBox 7",
+ "Text": "Q&A and Discussion"
+ }
+ ]
+}
+
+Output:
+{
+ "TotalCount": 2,
+ "Items": [
+ {
+ "SlideIndex": 1,
+ "ShapeId": "TextBox 5",
+ "Text": "프로젝트 개요"
+ },
+ {
+ "SlideIndex": 2,
+ "ShapeId": "TextBox 7",
+ "Text": "Q&A 및 토론"
+ }
+ ]
+}
diff --git a/ppt-translator/src/McpSamples.PptTranslator.HybridApp/Properties/launchSettings.json b/ppt-translator/src/McpSamples.PptTranslator.HybridApp/Properties/launchSettings.json
new file mode 100644
index 0000000..95e4386
--- /dev/null
+++ b/ppt-translator/src/McpSamples.PptTranslator.HybridApp/Properties/launchSettings.json
@@ -0,0 +1,23 @@
+{
+ "$schema": "https://json.schemastore.org/launchsettings.json",
+ "profiles": {
+ "http": {
+ "commandName": "Project",
+ "dotnetRunMessages": true,
+ "launchBrowser": false,
+ "applicationUrl": "http://localhost:5166",
+ "environmentVariables": {
+ "ASPNETCORE_ENVIRONMENT": "Development"
+ }
+ },
+ "https": {
+ "commandName": "Project",
+ "dotnetRunMessages": true,
+ "launchBrowser": false,
+ "applicationUrl": "https://localhost:7013;http://localhost:5166",
+ "environmentVariables": {
+ "ASPNETCORE_ENVIRONMENT": "Development",
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/ppt-translator/src/McpSamples.PptTranslator.HybridApp/Services/FileRebuildService.cs b/ppt-translator/src/McpSamples.PptTranslator.HybridApp/Services/FileRebuildService.cs
new file mode 100644
index 0000000..d5f9b6b
--- /dev/null
+++ b/ppt-translator/src/McpSamples.PptTranslator.HybridApp/Services/FileRebuildService.cs
@@ -0,0 +1,281 @@
+using System;
+using System.Collections.Generic;
+using System.IO;
+using System.Text.Json;
+using System.Threading.Tasks;
+using Microsoft.Extensions.Logging;
+using ShapeCrawler;
+using ShapeCrawler.Presentations;
+using Microsoft.Extensions.Configuration;
+
+namespace McpSamples.PptTranslator.HybridApp.Services
+{
+ ///
+ /// Service for rebuilding PowerPoint files with translated text.
+ ///
+ public interface IFileRebuildService
+ {
+ ///
+ /// Creates a new PPT file by applying translated text to the original presentation.
+ ///
+ /// Path to the original PowerPoint file
+ /// Path to JSON file with translated text
+ /// Target language code for output filename
+ /// Output directory for translated PPT file
+ /// Path to the newly created translated PPT file
+ Task RebuildPptFromJsonAsync(string pptFilePath, string translatedJsonPath, string targetLang, string outputPath);
+ }
+
+ ///
+ /// Default implementation that preserves slide structure while replacing text content.
+ /// Uses ShapeCrawler to manipulate PowerPoint files programmatically.
+ ///
+ ///
+ /// 슬라이드 구조를 유지하면서 텍스트 내용만 교체하는 기본 구현.
+ /// ShapeCrawler를 사용하여 PowerPoint 파일을 프로그래밍 방식으로 조작합니다.
+ ///
+ public class FileRebuildService : IFileRebuildService
+ {
+ private readonly ILogger _logger;
+ private readonly bool _isAzure;
+
+ private static readonly string UploadRoot =
+ Path.Combine(Path.GetTempPath(), "mcp-uploads");
+
+ public FileRebuildService(
+ ILogger logger,
+ IConfiguration config)
+ {
+ _logger = logger;
+ _isAzure = !string.IsNullOrEmpty(Environment.GetEnvironmentVariable("WEBSITE_HOSTNAME"));
+
+ if (!_isAzure && !Directory.Exists(UploadRoot))
+ Directory.CreateDirectory(UploadRoot);
+ }
+
+ public async Task RebuildPptFromJsonAsync(string pptFilePath, string translatedJsonPath, string targetLang, string outputPath)
+ {
+ _logger.LogInformation("[Rebuild] START - Original paths: PPT={0}, JSON={1}", pptFilePath, translatedJsonPath);
+
+ string resolvedPptPath = ResolvePath(pptFilePath);
+ _logger.LogInformation("[Rebuild] Resolved PPT path: {0}", resolvedPptPath);
+
+ string resolvedJsonPath = ResolvePath(translatedJsonPath);
+ _logger.LogInformation("[Rebuild] Resolved JSON path: {0}", resolvedJsonPath);
+
+ if (!File.Exists(resolvedPptPath))
+ {
+ _logger.LogError("[Rebuild] PPT file NOT FOUND: {0}", resolvedPptPath);
+ throw new FileNotFoundException("PPT file not found.", resolvedPptPath);
+ }
+ _logger.LogInformation("[Rebuild] PPT file EXISTS, size: {0} bytes", new FileInfo(resolvedPptPath).Length);
+
+ if (!File.Exists(resolvedJsonPath))
+ {
+ _logger.LogError("[Rebuild] JSON file NOT FOUND: {0}", resolvedJsonPath);
+ throw new FileNotFoundException("Translated JSON file not found.", resolvedJsonPath);
+ }
+ _logger.LogInformation("[Rebuild] JSON file EXISTS, size: {0} bytes", new FileInfo(resolvedJsonPath).Length);
+
+ _logger.LogInformation("[Rebuild] Input PPT: {0}", resolvedPptPath);
+ _logger.LogInformation("[Rebuild] Translated JSON: {0}", resolvedJsonPath);
+
+ var originalStream = File.OpenRead(resolvedPptPath);
+ string jsonContent = await File.ReadAllTextAsync(resolvedJsonPath);
+
+ var translated = JsonSerializer.Deserialize(jsonContent)
+ ?? throw new Exception("Failed to parse translated JSON.");
+
+ ValidateTranslatedJson(translated);
+
+ _logger.LogInformation("[Rebuild] Creating working stream copy...");
+ var workingStream = new MemoryStream();
+ await originalStream.CopyToAsync(workingStream);
+ workingStream.Position = 0;
+ _logger.LogInformation("[Rebuild] Working stream size: {0} bytes", workingStream.Length);
+
+ _logger.LogInformation("[Rebuild] Loading PPT with ShapeCrawler.Presentation...");
+ Presentation pres;
+ try
+ {
+ pres = new Presentation(workingStream);
+ _logger.LogInformation("[Rebuild] ShapeCrawler Presentation loaded successfully! Slide count: {0}", pres.Slides.Count);
+ }
+ catch (Exception ex)
+ {
+ _logger.LogError(ex, "[Rebuild] FAILED to load Presentation with ShapeCrawler!");
+ throw;
+ }
+
+ foreach (var item in translated.Items)
+ {
+ try
+ {
+ var slide = pres.Slides[item.SlideIndex - 1];
+ var shape = FindShapeById(slide, item.ShapeId);
+
+ if (shape?.TextBox != null)
+ ApplyTranslatedText(shape.TextBox, item.Text);
+ }
+ catch (Exception ex)
+ {
+ _logger.LogError(ex,
+ "[Rebuild] Failed apply text: Slide={Slide}, ShapeId={ShapeId}",
+ item.SlideIndex, item.ShapeId);
+ }
+ }
+
+ // 전달받은 outputPath 사용 (이미 디렉터리가 생성되어 있어야 함)
+ _logger.LogInformation("[Rebuild] Saving to output path: {0}", outputPath);
+
+ string outputDir = Path.GetDirectoryName(outputPath) ?? "";
+ if (!string.IsNullOrEmpty(outputDir) && !Directory.Exists(outputDir))
+ {
+ _logger.LogInformation("[Rebuild] Creating output directory: {0}", outputDir);
+ Directory.CreateDirectory(outputDir);
+ }
+
+ using (var fs = new FileStream(outputPath, FileMode.Create))
+ {
+ _logger.LogInformation("[Rebuild] Calling pres.Save()...");
+ var ms = new MemoryStream();
+ pres.Save(ms);
+ _logger.LogInformation("[Rebuild] pres.Save() completed, stream size: {0} bytes", ms.Length);
+ ms.Position = 0;
+ await ms.CopyToAsync(fs);
+ _logger.LogInformation("[Rebuild] File written to disk: {0} bytes", fs.Length);
+ }
+
+ _logger.LogInformation("[Rebuild] Output saved successfully: {0}", outputPath);
+ return outputPath;
+ }
+
+
+ // ==========================================================
+ // 경로 해석: Azure와 로컬 temp 방식을 분리
+ // ==========================================================
+ private string ResolvePath(string path)
+ {
+ if (_isAzure)
+ {
+ // Azure에서는 마운트된 경로 그대로 사용해야 한다.
+ return path;
+ }
+
+ // 로컬에서는 기존 temp 시스템 유지
+ Directory.CreateDirectory(UploadRoot);
+
+ if (path.StartsWith("temp:", StringComparison.OrdinalIgnoreCase))
+ return Path.Combine(UploadRoot, path.Substring(5));
+
+ if (File.Exists(path))
+ {
+ string id = Guid.NewGuid().ToString("N");
+ string dest = Path.Combine(UploadRoot, id);
+ File.Copy(path, dest, overwrite: true);
+ return dest;
+ }
+
+ throw new InvalidOperationException($"Invalid path '{path}'.");
+ }
+
+ // ==========================================================
+ // Azure → /output/{GUID}_translated_lang.pptx
+ // Local → 기존 temp 폴더
+ // ==========================================================
+ private string GetOutputPath(string lang)
+ {
+ string fileName = $"{Guid.NewGuid()}_translated_{lang}.pptx";
+
+ if (_isAzure)
+ {
+ // Azure File Share 마운트 경로
+ return Path.Combine("/output", fileName);
+ }
+
+ return Path.Combine(Path.GetTempPath(), fileName);
+ }
+
+
+ private void ApplyTranslatedText(ITextBox textBox, string translatedText)
+ {
+ var paragraphs = textBox.Paragraphs;
+ if (paragraphs.Count == 0)
+ paragraphs.Add();
+
+ var first = paragraphs[0];
+ var baseFont = first.Portions.Count > 0 ? first.Portions[0].Font : null;
+
+ first.Text = "";
+ var lines = translatedText.Split('\n');
+
+ first.Portions.AddText(lines[0]);
+ ApplyStyle(first, baseFont);
+
+ for (int i = 1; i < paragraphs.Count; i++)
+ paragraphs[i].Text = "";
+
+ for (int i = 1; i < lines.Length; i++)
+ {
+ IParagraph p;
+ if (i < paragraphs.Count)
+ p = paragraphs[i];
+ else
+ {
+ paragraphs.Add();
+ p = paragraphs[i];
+ }
+
+ p.Text = lines[i];
+ ApplyStyle(p, baseFont);
+ }
+ }
+
+ private void ApplyStyle(IParagraph p, ITextPortionFont? baseFont)
+ {
+ if (baseFont == null || p.Portions.Count == 0) return;
+
+ var f = p.Portions[0].Font;
+ if (f != null)
+ {
+ f.Size = baseFont.Size;
+ f.IsBold = baseFont.IsBold;
+ f.IsItalic = baseFont.IsItalic;
+ f.Underline = baseFont.Underline;
+ f.Color.Set(baseFont.Color.Hex);
+ f.LatinName = baseFont.LatinName;
+ f.EastAsianName = baseFont.EastAsianName;
+ }
+ }
+
+ private void ValidateTranslatedJson(TranslatedResult json)
+ {
+ if (json.Items == null)
+ throw new Exception("Translated JSON has no items.");
+
+ if (json.TotalCount != json.Items.Count)
+ throw new Exception("JSON TotalCount does not match Items count.");
+ }
+
+ private IShape? FindShapeById(ISlide slide, string id)
+ {
+ foreach (var s in slide.Shapes)
+ if (s.Id.ToString() == id)
+ return s;
+ return null;
+ }
+
+ private class TranslatedResult
+ {
+ public int TotalCount { get; set; }
+ public List Items { get; set; } = new();
+ }
+
+ private class TranslatedItem
+ {
+ public int SlideIndex { get; set; }
+ public string ShapeId { get; set; } = "";
+ public string Text { get; set; } = "";
+ }
+ }
+}
diff --git a/ppt-translator/src/McpSamples.PptTranslator.HybridApp/Services/TextExtractService.cs b/ppt-translator/src/McpSamples.PptTranslator.HybridApp/Services/TextExtractService.cs
new file mode 100644
index 0000000..a71400d
--- /dev/null
+++ b/ppt-translator/src/McpSamples.PptTranslator.HybridApp/Services/TextExtractService.cs
@@ -0,0 +1,183 @@
+using System;
+using System.IO;
+using System.Text.Encodings.Web;
+using System.Text.Json;
+using System.Threading.Tasks;
+using System.Collections.Generic;
+using Microsoft.Extensions.Logging;
+using ShapeCrawler;
+using ShapeCrawler.Presentations;
+using Microsoft.Extensions.Configuration;
+using McpSamples.PptTranslator.HybridApp.Models;
+
+namespace McpSamples.PptTranslator.HybridApp.Services;
+
+///
+/// Service for extracting text content from PowerPoint files.
+///
+public interface ITextExtractService
+{
+ ///
+ /// Opens a PowerPoint file for text extraction.
+ ///
+ /// Path to the PPT file
+ Task OpenPptFileAsync(string filePath);
+
+ ///
+ /// Extracts all text content from the opened presentation.
+ ///
+ /// Structured extraction result containing slide and shape text
+ Task TextExtractAsync();
+
+ ///
+ /// Serializes extracted text to JSON format.
+ ///
+ /// Extracted text data
+ /// Output directory for JSON file
+ /// Path to the generated JSON file
+ Task ExtractToJsonAsync(PptTextExtractResult extracted, string outputPath);
+}
+
+///
+/// Default implementation of text extraction service using ShapeCrawler library.
+/// Handles both local and Azure environments with appropriate path resolution.
+///
+///
+/// ShapeCrawler 라이브러리를 사용한 텍스트 추출 서비스 기본 구현.
+/// 로컬 및 Azure 환경에서 적절한 경로 해석을 처리합니다.
+///
+public class TextExtractService : ITextExtractService
+{
+ private readonly ILogger _logger;
+ private readonly ExecutionMode _executionMode;
+ private Presentation? _presentation;
+
+ public TextExtractService(
+ ILogger logger,
+ IConfiguration config)
+ {
+ _logger = logger;
+ _executionMode = ExecutionModeDetector.DetectExecutionMode();
+ }
+
+ public Task OpenPptFileAsync(string filePath)
+ {
+ string resolved = filePath;
+
+ if (_executionMode.IsContainerMode())
+ {
+ // Container/Azure 모드: 통합된 /files/input 사용
+ string fileName = Path.GetFileName(filePath);
+ resolved = Path.Combine("/files/input", fileName);
+
+ if (!File.Exists(resolved))
+ throw new FileNotFoundException("PPT file not found in /files/input folder.", resolved);
+
+ _presentation = new Presentation(resolved);
+ _logger.LogInformation("[Container] PPT opened from /files/input: {Path}", resolved);
+ return Task.CompletedTask;
+ }
+ else
+ {
+ // 로컬 모드: 파일 복사
+ if (File.Exists(filePath))
+ {
+ string tempDir = Path.Combine(Path.GetTempPath(), "mcp-uploads");
+ Directory.CreateDirectory(tempDir);
+ string id = Guid.NewGuid().ToString("N");
+ resolved = Path.Combine(tempDir, id);
+ File.Copy(filePath, resolved, overwrite: true);
+ }
+ else
+ {
+ throw new FileNotFoundException("PPT file not found.", filePath);
+ }
+
+ if (!File.Exists(resolved))
+ throw new FileNotFoundException("Resolved PPT file not found.", resolved);
+
+ try
+ {
+ _presentation = new Presentation(resolved);
+ _logger.LogInformation("[Local] PPT opened: {Resolved}", resolved);
+ }
+ catch (Exception ex)
+ {
+ _logger.LogError(ex, "Failed to open PPT file.");
+ throw;
+ }
+
+ return Task.CompletedTask;
+ }
+ }
+
+ public Task TextExtractAsync()
+ {
+ if (_presentation == null)
+ throw new InvalidOperationException("PPT must be opened before extraction.");
+
+ var result = new PptTextExtractResult();
+ var items = new List();
+
+ foreach (var slide in _presentation.Slides)
+ {
+ foreach (var shape in slide.Shapes)
+ {
+ if (shape.TextBox == null)
+ continue;
+
+ string text = shape.TextBox.Text?.Trim() ?? "";
+ if (string.IsNullOrWhiteSpace(text))
+ continue;
+
+ items.Add(new PptTextExtractItem
+ {
+ SlideIndex = slide.Number,
+ ShapeId = shape.Id.ToString(),
+ Text = text
+ });
+ }
+ }
+
+ result.Items = items;
+ result.TotalCount = items.Count;
+
+ _logger.LogInformation("Extracted {Count} items", result.TotalCount);
+ return Task.FromResult(result);
+ }
+
+ public async Task ExtractToJsonAsync(PptTextExtractResult extracted, string outputPath)
+ {
+ var jsonOptions = new JsonSerializerOptions
+ {
+ WriteIndented = true,
+ Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping
+ };
+
+ var json = JsonSerializer.Serialize(extracted, jsonOptions);
+
+ if (_executionMode.IsContainerMode())
+ {
+ // Container/Azure 모드: 통합된 /files/tmp 사용
+ string fileName = Path.GetFileName(outputPath);
+ string savePath = Path.Combine("/files/tmp", fileName);
+
+ Directory.CreateDirectory("/files/tmp");
+ await File.WriteAllTextAsync(savePath, json);
+
+ _logger.LogInformation("[Container] JSON extracted → {Path}", savePath);
+ return savePath;
+ }
+ else
+ {
+ // 로컬 모드: 기존 로직 유지
+ var dir = Path.GetDirectoryName(outputPath);
+ if (!string.IsNullOrEmpty(dir))
+ Directory.CreateDirectory(dir);
+
+ await File.WriteAllTextAsync(outputPath, json);
+ _logger.LogInformation("[Local] JSON extracted → {Path}", outputPath);
+ return outputPath;
+ }
+ }
+}
diff --git a/ppt-translator/src/McpSamples.PptTranslator.HybridApp/Services/TranslationService.cs b/ppt-translator/src/McpSamples.PptTranslator.HybridApp/Services/TranslationService.cs
new file mode 100644
index 0000000..e6b6ebf
--- /dev/null
+++ b/ppt-translator/src/McpSamples.PptTranslator.HybridApp/Services/TranslationService.cs
@@ -0,0 +1,188 @@
+using System;
+using System.IO;
+using System.Net.Http;
+using System.Text;
+using System.Text.Json;
+using System.Threading.Tasks;
+using Microsoft.Extensions.Logging;
+using Microsoft.Extensions.Configuration;
+using McpSamples.PptTranslator.HybridApp.Models;
+
+namespace McpSamples.PptTranslator.HybridApp.Services
+{
+ ///
+ /// Service for translating extracted text using OpenAI API.
+ ///
+ public interface ITranslationService
+ {
+ ///
+ /// Translates text content from JSON format using OpenAI's language model.
+ ///
+ /// Path to JSON file containing extracted text
+ /// Target language code (e.g., 'ko', 'en', 'ja')
+ /// Path to translated JSON file
+ Task TranslateJsonFileAsync(string extractedJsonPath, string targetLang);
+ }
+
+ ///
+ /// Default implementation using OpenAI Chat Completions API for translation.
+ /// Supports both local and Azure environments with configurable endpoints.
+ ///
+ ///
+ /// OpenAI Chat Completions API를 사용한 번역 서비스 기본 구현.
+ /// 로컬 및 Azure 환경에서 설정 가능한 엔드포인트를 지원합니다.
+ ///
+ public class TranslationService : ITranslationService
+ {
+ private readonly ILogger _logger;
+ private readonly IConfiguration _config;
+ private readonly HttpClient _http = new();
+ private readonly ExecutionMode _executionMode;
+
+ public TranslationService(
+ ILogger logger,
+ IConfiguration config)
+ {
+ _logger = logger;
+ _config = config;
+ _executionMode = ExecutionModeDetector.DetectExecutionMode();
+ _http.Timeout = TimeSpan.FromSeconds(300);
+ }
+
+ public async Task TranslateJsonFileAsync(string extractedJsonPath, string targetLang)
+ {
+ _logger.LogInformation("[STEP 2] Sending translation request.");
+
+ // =======================================================
+ // Container/Azure 모드 → /files 구조 사용
+ // =======================================================
+ if (_executionMode.IsContainerMode())
+ {
+ string jsonFileName = Path.GetFileName(extractedJsonPath);
+ string fullPath = Path.Combine("/files/tmp", jsonFileName);
+
+ if (!File.Exists(fullPath))
+ throw new FileNotFoundException("JSON not found in /files/tmp.", fullPath);
+
+ string extractedJson = await File.ReadAllTextAsync(fullPath);
+
+ // Prompt 불러오기
+ string promptPath = Path.Combine(AppContext.BaseDirectory, "Prompts", "translation_prompt.txt");
+ string promptText = await File.ReadAllTextAsync(promptPath);
+
+ string prompt =
+ promptText +
+ $"\n\nTARGET_LANG={targetLang}\n\n" +
+ extractedJson;
+
+ string apiKey = Environment.GetEnvironmentVariable("OPENAI_API_KEY")
+ ?? throw new Exception("OPENAI_API_KEY not set.");
+
+ string model = Environment.GetEnvironmentVariable("OPENAI_MODEL") ?? "gpt-5-nano";
+ string endpoint = Environment.GetEnvironmentVariable("OPENAI_ENDPOINT")
+ ?? "https://api.openai.com/v1/chat/completions";
+
+ var body = new
+ {
+ model,
+ messages = new[] { new { role = "user", content = prompt } }
+ };
+
+ var content = new StringContent(JsonSerializer.Serialize(body), Encoding.UTF8, "application/json");
+
+ _http.DefaultRequestHeaders.Clear();
+ _http.DefaultRequestHeaders.Add("Authorization", $"Bearer {apiKey}");
+
+ HttpResponseMessage res = await _http.PostAsync(endpoint, content);
+ string raw = await res.Content.ReadAsStringAsync();
+
+ if (!res.IsSuccessStatusCode)
+ throw new Exception($"Translation failed: {res.StatusCode}");
+
+ string translated =
+ JsonDocument.Parse(raw)
+ .RootElement.GetProperty("choices")[0]
+ .GetProperty("message")
+ .GetProperty("content")
+ .GetString()
+ ?? throw new Exception("LLM returned empty content.");
+
+ // 저장 경로: /files/tmp
+ string outputFile = $"{Guid.NewGuid():N}_translated_{targetLang}.json";
+ string outputPath = Path.Combine("/files/tmp", outputFile);
+
+ await File.WriteAllTextAsync(outputPath, translated);
+
+ _logger.LogInformation("[Container] 번역 JSON 저장: {Path}", outputPath);
+
+ // 반환: 전체 경로 (다음 단계에서 사용)
+ return outputPath;
+ }
+
+
+ // =======================================================
+ // Local 모드 (STDIO / HTTP / DOCKER LOCAL) → 파일 처리
+ // =======================================================
+
+ if (!File.Exists(extractedJsonPath))
+ throw new FileNotFoundException("Extracted JSON not found.", extractedJsonPath);
+
+ string localJson = await File.ReadAllTextAsync(extractedJsonPath);
+
+ string localPromptPath = Path.Combine(AppContext.BaseDirectory, "Prompts", "translation_prompt.txt");
+ string localPromptText = await File.ReadAllTextAsync(localPromptPath);
+
+ string merged =
+ localPromptText +
+ $"\n\nTARGET_LANG={targetLang}\n\n" +
+ localJson;
+
+ string localApiKey = Environment.GetEnvironmentVariable("OPENAI_API_KEY")
+ ?? throw new Exception("OPENAI_API_KEY not set.");
+
+ string localModel = Environment.GetEnvironmentVariable("OPENAI_MODEL") ?? "gpt-5-nano";
+ string localEndpoint = Environment.GetEnvironmentVariable("OPENAI_ENDPOINT")
+ ?? "https://api.openai.com/v1/chat/completions";
+
+ var localBody = new
+ {
+ model = localModel,
+ messages = new[] { new { role = "user", content = merged } }
+ };
+
+ var localContent = new StringContent(JsonSerializer.Serialize(localBody), Encoding.UTF8, "application/json");
+
+ _http.DefaultRequestHeaders.Clear();
+ _http.DefaultRequestHeaders.Add("Authorization", $"Bearer {localApiKey}");
+
+ HttpResponseMessage localRes = await _http.PostAsync(localEndpoint, localContent);
+ string localRaw = await localRes.Content.ReadAsStringAsync();
+
+ _logger.LogInformation("[OpenAI Response] Status: {Status}, Body: {Body}",
+ localRes.StatusCode, localRaw);
+
+ if (!localRes.IsSuccessStatusCode)
+ {
+ throw new Exception($"OpenAI API failed: {localRes.StatusCode}, Body: {localRaw}");
+ }
+
+ string localTranslated =
+ JsonDocument.Parse(localRaw)
+ .RootElement.GetProperty("choices")[0]
+ .GetProperty("message")
+ .GetProperty("content")
+ .GetString()
+ ?? throw new Exception("LLM returned empty content.");
+
+ string tempDir = Path.Combine(Path.GetTempPath(), "mcp-uploads");
+ Directory.CreateDirectory(tempDir);
+ string id = Guid.NewGuid().ToString("N");
+ string localOutput = Path.Combine(tempDir, id);
+ await File.WriteAllTextAsync(localOutput, localTranslated);
+
+ _logger.LogInformation("[Local] 번역 JSON 저장(temp): {Path}", localOutput);
+
+ return localOutput;
+ }
+ }
+}
diff --git a/ppt-translator/src/McpSamples.PptTranslator.HybridApp/Services/UploadService.cs b/ppt-translator/src/McpSamples.PptTranslator.HybridApp/Services/UploadService.cs
new file mode 100644
index 0000000..a691c4c
--- /dev/null
+++ b/ppt-translator/src/McpSamples.PptTranslator.HybridApp/Services/UploadService.cs
@@ -0,0 +1,64 @@
+using Microsoft.Extensions.Logging;
+using McpSamples.PptTranslator.HybridApp.Models;
+
+namespace McpSamples.PptTranslator.HybridApp.Services
+{
+ ///
+ /// Service for handling file uploads in different execution modes.
+ ///
+ public interface IUploadService
+ {
+ ///
+ /// Saves an uploaded file stream to the appropriate storage location.
+ ///
+ /// File content stream
+ /// Original filename
+ /// Path where the file was saved
+ Task SaveUploadedFileAsync(Stream stream, string fileName);
+ }
+
+ ///
+ /// Default implementation supporting local, container, and Azure storage modes.
+ /// Automatically detects execution mode and saves files to appropriate locations.
+ ///
+ ///
+ /// 로컬, 컨테이너, Azure 스토리지 모드를 지원하는 기본 구현.
+ /// 실행 모드를 자동으로 감지하고 적절한 위치에 파일을 저장합니다.
+ ///
+ public class UploadService : IUploadService
+ {
+ private readonly ILogger _logger;
+ private readonly ExecutionMode _executionMode;
+
+
+
+ private const string InputMountPath = "/files/input";
+
+ public UploadService(ILogger logger)
+ {
+ _logger = logger;
+ _executionMode = ExecutionModeDetector.DetectExecutionMode();
+
+ _logger.LogInformation("[UploadService] ExecutionMode: {Mode}", _executionMode);
+ }
+
+ public async Task SaveUploadedFileAsync(Stream stream, string fileName)
+ {
+ if (_executionMode.IsContainerMode())
+ {
+ // Container/Azure 모드: 통합된 /files/input 사용
+ Directory.CreateDirectory(InputMountPath);
+ string savePath = Path.Combine(InputMountPath, fileName); // 원본 파일명 사용
+ using (var fs = File.Create(savePath))
+ await stream.CopyToAsync(fs);
+ _logger.LogInformation("[UPLOAD] Saved {FileName} → {Path}", fileName, savePath);
+ return fileName; // 원본 파일명 반환
+ }
+ else
+ {
+ // 로컬 모드: 업로드 기능 미지원
+ throw new InvalidOperationException("Local mode does not support file upload. Please provide absolute file path directly.");
+ }
+ }
+ }
+}
diff --git a/ppt-translator/src/McpSamples.PptTranslator.HybridApp/Tools/PptTranslateTool.cs b/ppt-translator/src/McpSamples.PptTranslator.HybridApp/Tools/PptTranslateTool.cs
new file mode 100644
index 0000000..cb02285
--- /dev/null
+++ b/ppt-translator/src/McpSamples.PptTranslator.HybridApp/Tools/PptTranslateTool.cs
@@ -0,0 +1,510 @@
+using System;
+using System.ComponentModel;
+using System.IO;
+using System.Threading.Tasks;
+using Microsoft.Extensions.Logging;
+using McpSamples.PptTranslator.HybridApp.Services;
+using McpSamples.PptTranslator.HybridApp.Models;
+using ModelContextProtocol.Server;
+
+namespace McpSamples.PptTranslator.HybridApp.Tools
+{
+ ///
+ /// Provides a tool for translating PPT files into another language.
+ ///
+ public interface IPptTranslateTool
+ {
+ Task TranslateAsync(string filePath, string targetLang, string? outputPath = null);
+ }
+
+ ///
+ /// Default implementation of PPT translation workflow tool.
+ /// Supports: local file, container volume, Azure Blob URL
+ ///
+ [McpServerToolType]
+ public class PptTranslateTool : IPptTranslateTool
+ {
+ private readonly ILogger _logger;
+ private readonly ITextExtractService _extractService;
+ private readonly ITranslationService _translationService;
+ private readonly IFileRebuildService _rebuildService;
+ private readonly IUploadService _uploadService;
+ private readonly ExecutionMode _executionMode;
+
+
+ public PptTranslateTool(
+ ILogger logger,
+ ITextExtractService extractService,
+ ITranslationService translationService,
+ IFileRebuildService rebuildService,
+ IUploadService uploadService)
+ {
+ _logger = logger;
+ _extractService = extractService;
+ _translationService = translationService;
+ _rebuildService = rebuildService;
+ _uploadService = uploadService;
+ _executionMode = ExecutionModeDetector.DetectExecutionMode();
+
+ _logger.LogInformation("[ExecutionMode] Detected: {Mode}", _executionMode);
+ }
+
+
+ [McpServerTool(Name = "translate_ppt_file")]
+ [Description("Translates a PPT file into the specified target language.")]
+ public async Task TranslateAsync(
+ [Description("Path to the PPT file to translate")] string filePath,
+ [Description("Target language code (e.g., 'ko', 'en', 'ja')")] string targetLang,
+ [Description("(Optional) Absolute path to directory where translated file should be saved. If provided in container mode, a copy command will be returned.")] string? outputPath = null)
+ {
+ string step = "INITIAL";
+
+ try
+ {
+ if (string.IsNullOrWhiteSpace(targetLang))
+ targetLang = "ko";
+
+ // -----------------------------
+ // STEP 0: 입력 경로 처리 (모드별)
+ // -----------------------------
+ string resolvedInputPath = await ResolveInputPathAsync(filePath);
+ string originalFileName = Path.GetFileName(filePath);
+
+ // -----------------------------
+ // STEP 1: Extract
+ // -----------------------------
+ step = "extract";
+ _logger.LogInformation("[STEP 1] Extracting text from: {Path}", resolvedInputPath);
+
+ await _extractService.OpenPptFileAsync(resolvedInputPath);
+ var extracted = await _extractService.TextExtractAsync();
+
+ // 작업 디렉토리 결정 (모드별)
+ string workDir = _executionMode.IsContainerMode()
+ ? "/files/tmp" // Container/Azure 모드: 통합된 /files/tmp 사용
+ : Path.GetDirectoryName(resolvedInputPath) ?? Path.Combine(Path.GetTempPath(), "ppt-translator");
+
+ Directory.CreateDirectory(workDir);
+
+ string extractedJsonPath = Path.Combine(workDir, "extracted.json");
+ await _extractService.ExtractToJsonAsync(extracted, extractedJsonPath);
+
+ // -----------------------------
+ // STEP 2: Translate
+ // -----------------------------
+ step = "translate";
+ string translatedJsonPath =
+ await _translationService.TranslateJsonFileAsync(extractedJsonPath, targetLang);
+
+ // -----------------------------
+ // STEP 3: Rebuild PPT
+ // -----------------------------
+ step = "rebuild";
+
+ // 출력 경로 결정 (모드별)
+ string finalOutputPath = DetermineOutputPath(originalFileName, targetLang, outputPath);
+
+ string output =
+ await _rebuildService.RebuildPptFromJsonAsync(resolvedInputPath, translatedJsonPath, targetLang, finalOutputPath);
+
+ return BuildSuccessMessage(output, originalFileName, targetLang, outputPath);
+ }
+ catch (InvalidOperationException ex) when (ex.Message.StartsWith("AGENT_ACTION_REQUIRED"))
+ {
+ // 에이전트가 수행해야 할 작업이 있는 경우 (예: 파일 복사)
+ _logger.LogInformation("[Container] Agent action required: {Message}", ex.Message);
+ return ex.Message.Replace("AGENT_ACTION_REQUIRED: ", "");
+ }
+ catch (Exception ex)
+ {
+ _logger.LogError(ex, "[ERROR] STEP={Step}: {Message}", step, ex.Message);
+ return $"Error at step '{step}': {ex.Message}";
+ }
+ }
+
+ ///
+ /// Resolves the input file path based on current execution mode.
+ /// Handles path translation between local, container, and Azure environments.
+ ///
+ /// User-provided file path
+ /// Resolved absolute path accessible in current environment
+ /// When file cannot be found in expected location
+ /// When file requires upload or copy action
+ ///
+ /// 현재 실행 모드에 따라 입력 파일 경로를 해석합니다.
+ /// 로컬, 컨테이너, Azure 환경 간 경로 변환을 처리합니다.
+ ///
+ private async Task ResolveInputPathAsync(string filePath)
+ {
+ // 로컬 모드 vs 컨테이너 모드로 단순화
+ if (_executionMode.IsLocalMode())
+ {
+ return ResolveLocalFilePath(filePath);
+ }
+ else if (_executionMode.IsContainerMode())
+ {
+ return await ResolveContainerFilePath(filePath);
+ }
+ else
+ {
+ throw new InvalidOperationException($"Unknown execution mode: {_executionMode}");
+ }
+ }
+
+ ///
+ /// Determines the output path for translated file based on execution mode.
+ /// Respects user-provided output path when applicable.
+ ///
+ /// Original input filename
+ /// Target language code for filename suffix
+ /// Optional user-specified output directory
+ /// Full path where translated file should be saved
+ ///
+ /// 실행 모드에 따라 번역된 파일의 출력 경로를 결정합니다.
+ /// 사용자가 제공한 출력 경로가 있는 경우 이를 우선합니다.
+ ///
+ private string DetermineOutputPath(string originalFileName, string targetLang, string? userOutputPath)
+ {
+ string outputFileName = $"{Path.GetFileNameWithoutExtension(originalFileName)}_{targetLang}.pptx";
+
+ if (_executionMode.IsLocalMode())
+ {
+ return DetermineLocalOutputPath(outputFileName, userOutputPath);
+ }
+ else if (_executionMode.IsContainerMode())
+ {
+ // Container/Azure 모드: 통합된 /files/output 사용
+ string outputDir = "/files/output";
+ Directory.CreateDirectory(outputDir);
+ return Path.Combine(outputDir, outputFileName);
+ }
+ else
+ {
+ throw new InvalidOperationException($"Unknown execution mode: {_executionMode}");
+ }
+ }
+
+ #region Helper Methods for Path Resolution
+
+ ///
+ /// 로컬 모드에서 파일 경로
+ ///
+ private string ResolveLocalFilePath(string filePath)
+ {
+ if (Path.IsPathRooted(filePath) && File.Exists(filePath))
+ {
+ return filePath;
+ }
+ throw new FileNotFoundException($"File not found: {filePath}");
+ }
+
+ ///
+ /// 컨테이너/Azure 모드에서 파일 경로
+ ///
+ private async Task ResolveContainerFilePath(string filePath)
+ {
+ string fileName = Path.GetFileName(filePath);
+ string inputDir = "/files/input";
+ string inputPath = Path.Combine(inputDir, fileName);
+
+ Directory.CreateDirectory(inputDir);
+
+ // 1. 먼저 /files/input에서 파일 찾기
+ if (File.Exists(inputPath))
+ {
+ _logger.LogInformation("[Container] File found in input folder: {Path}", inputPath);
+ return inputPath;
+ }
+
+ // 2. /files에서 직접 업로드된 파일 찾기 (Azure 업로드 케이스)
+ string directFilePath = Path.Combine("/files", fileName);
+ if (File.Exists(directFilePath))
+ {
+ _logger.LogInformation("[Container] File found in files root: {Path}", directFilePath);
+ // 파일을 input 폴더로 이동하여 일관성 유지
+ try
+ {
+ File.Move(directFilePath, inputPath);
+ _logger.LogInformation("[Container] File moved from {Source} to {Target}", directFilePath, inputPath);
+ return inputPath;
+ }
+ catch (Exception moveEx)
+ {
+ _logger.LogWarning(moveEx, "[Container] Failed to move file, using original location");
+ return directFilePath;
+ }
+ }
+
+ // 3. 파일을 찾을 수 없는 경우 업로드/복사 처리
+ if (_executionMode == ExecutionMode.HttpRemote)
+ {
+ return await HandleAzureFileUpload(filePath, fileName, inputPath);
+ }
+ else
+ {
+ return HandleContainerFileCopy(filePath, fileName, inputPath);
+ }
+ }
+
+ ///
+ /// Azure 모드에서 파일 업로드를 처리합니다.
+ ///
+ private async Task HandleAzureFileUpload(string filePath, string fileName, string inputPath)
+ {
+ _logger.LogWarning("[Azure] File not found in Azure File Share. Attempting automatic upload.");
+
+ try
+ {
+ using var fileStream = File.OpenRead(filePath);
+ string uploadResult = await _uploadService.SaveUploadedFileAsync(fileStream, fileName);
+ _logger.LogInformation("[Azure] File automatically uploaded: {Result}", uploadResult);
+
+ if (File.Exists(inputPath))
+ {
+ return inputPath;
+ }
+ else
+ {
+ _logger.LogWarning("[Azure] File uploaded but not found in expected location");
+ throw new InvalidOperationException($"File uploaded but not accessible at {inputPath}");
+ }
+ }
+ catch (Exception uploadEx)
+ {
+ _logger.LogError(uploadEx, "[Azure] Failed to auto-upload file. Providing manual instructions.");
+
+ string? hostname = Environment.GetEnvironmentVariable("CONTAINER_APP_HOSTNAME")
+ ?? "ppt-translator.braveriver-9b702e70.southeastasia.azurecontainerapps.io";
+
+ string uploadCommands = $"""
+ Automatic upload failed. Please upload the file manually:
+
+ 🍎/🐧 macOS & Linux:
+ curl -F "file=@{filePath}" https://{hostname}/upload
+
+ 💻 Windows (Command Prompt/PowerShell):
+ curl.exe -F "file=@{filePath}" https://{hostname}/upload
+ """;
+
+ throw new InvalidOperationException(
+ $"AGENT_ACTION_REQUIRED: {uploadCommands}\n\n" +
+ $"After successful upload, call translate_ppt_file again with filePath=\"{fileName}\" (filename only).\n\n" +
+ $"Expected location in Azure: /files/{fileName}");
+ }
+ }
+
+ ///
+ /// 컨테이너 모드에서 파일 복사를 처리합니다.
+ ///
+ private string HandleContainerFileCopy(string filePath, string fileName, string inputPath)
+ {
+ string? hostMountPath = ExecutionModeDetector.GetHostMountPath();
+ if (string.IsNullOrEmpty(hostMountPath))
+ {
+ _logger.LogError("[Container] HOST_MOUNT_PATH environment variable not set");
+ throw new InvalidOperationException(
+ "AGENT_ACTION_REQUIRED: HOST_MOUNT_PATH environment variable is not configured.\n\n" +
+ "Please ensure the MCP configuration includes:\n" +
+ "-e HOST_MOUNT_PATH=${input:ppt-folder-path}\n\n" +
+ $"Then copy the file to the mounted folder and call translate_ppt_file with filePath=\"{fileName}\"");
+ }
+
+ _logger.LogWarning("[Container] File not in input folder. Auto-copying file to mounted input folder.");
+ string hostInputDir = Path.Combine(hostMountPath, "input");
+ string targetPath = Path.Combine(hostInputDir, fileName);
+
+ try
+ {
+ Directory.CreateDirectory(hostInputDir);
+ File.Copy(filePath, targetPath, overwrite: true);
+ _logger.LogInformation("[Container] File automatically copied from {Source} to {Target}", filePath, targetPath);
+ return inputPath;
+ }
+ catch (Exception copyEx)
+ {
+ _logger.LogError(copyEx, "[Container] Failed to auto-copy file. Providing manual instructions.");
+
+ string copyCommands = $"""
+ Automatic file copy failed. Please copy the file to the input folder manually:
+
+ 🍎/🐧 macOS & Linux:
+ cp "{filePath}" "{targetPath}"
+
+ 💻 Windows Command Prompt:
+ copy "{filePath}" "{targetPath}"
+
+ 💻 Windows PowerShell:
+ Copy-Item "{filePath}" -Destination "{targetPath}"
+ """;
+
+ throw new InvalidOperationException(
+ $"AGENT_ACTION_REQUIRED: {copyCommands}\n\n" +
+ $"Then call translate_ppt_file again with filePath=\"{fileName}\"");
+ }
+ }
+
+ ///
+ /// 로컬 모드에서 출력 경로를 결정합니다.
+ ///
+ private string DetermineLocalOutputPath(string outputFileName, string? userOutputPath)
+ {
+ if (!string.IsNullOrWhiteSpace(userOutputPath))
+ {
+ if (!Path.IsPathRooted(userOutputPath))
+ {
+ throw new ArgumentException("outputPath must be an absolute path");
+ }
+ Directory.CreateDirectory(userOutputPath);
+ return Path.Combine(userOutputPath, outputFileName);
+ }
+
+ string projectRoot = Directory.GetCurrentDirectory();
+ string defaultOutputDir = Path.Combine(projectRoot, "wwwroot", "generated");
+ Directory.CreateDirectory(defaultOutputDir);
+ return Path.Combine(defaultOutputDir, outputFileName);
+ }
+
+ #endregion
+
+ ///
+ /// Builds a user-friendly success message with file access instructions.
+ /// Message format varies by execution mode to provide appropriate file retrieval steps.
+ ///
+ /// Path where translated file was saved
+ /// Original input filename
+ /// Target language code
+ /// User-provided output path if any
+ /// Formatted success message with file location and access instructions
+ ///
+ /// 파일 접근 방법을 포함한 사용자 친화적인 성공 메시지를 생성합니다.
+ /// 메시지 형식은 실행 모드에 따라 달라지며 적절한 파일 다운로드 방법을 제공합니다.
+ ///
+ private string BuildSuccessMessage(string outputPath, string originalFileName, string targetLang, string? userOutputPath)
+ {
+ string outputFileName = $"{Path.GetFileNameWithoutExtension(originalFileName)}_{targetLang}.pptx";
+
+ if (_executionMode == ExecutionMode.StdioLocal)
+ {
+ return $"Translation complete!\nOutput file: {outputPath}";
+ }
+ else if (_executionMode == ExecutionMode.HttpLocal)
+ {
+ string downloadUrl = $"http://localhost:5166/download/{outputFileName}";
+ return $"""
+ Translation complete!
+ 📂 Local file: {outputPath}
+ 🔗 Download URL: {downloadUrl}
+
+ 💡 Access via browser or curl:
+ curl -o "{outputFileName}" {downloadUrl}
+ """;
+ }
+ else if (_executionMode.IsContainerMode())
+ {
+ return BuildContainerSuccessMessage(outputFileName);
+ }
+ else
+ {
+ return $"Translation complete!\nOutput: {outputPath}";
+ }
+ }
+
+ ///
+ /// Container/Azure 모드에서 성공 메시지를 생성합니다.
+ ///
+ private string BuildContainerSuccessMessage(string outputFileName)
+ {
+ string? hostMountPath = ExecutionModeDetector.GetHostMountPath();
+
+ // HTTP 모드인지 확인
+ bool isHttpMode = _executionMode == ExecutionMode.HttpContainer || _executionMode == ExecutionMode.HttpRemote;
+
+ if (isHttpMode)
+ {
+ return BuildHttpContainerMessage(outputFileName, hostMountPath);
+ }
+ else
+ {
+ return BuildStdioContainerMessage(outputFileName, hostMountPath);
+ }
+ }
+
+ ///
+ /// HTTP 컨테이너 모드에서 성공 메시지를 생성합니다.
+ ///
+ private string BuildHttpContainerMessage(string outputFileName, string? hostMountPath)
+ {
+ if (_executionMode == ExecutionMode.HttpRemote)
+ {
+ // Azure 모드: Container App FQDN 사용
+ string? containerAppHostname = Environment.GetEnvironmentVariable("CONTAINER_APP_HOSTNAME");
+ string downloadUrl = !string.IsNullOrEmpty(containerAppHostname)
+ ? $"https://{containerAppHostname}/download/{outputFileName}"
+ : $"http://localhost:8080/download/{outputFileName}";
+
+ return $@"Translation complete!
+
+Download your file:
+{downloadUrl}
+
+Or use curl:
+curl -o ""{outputFileName}"" {downloadUrl}";
+ }
+ else
+ {
+ // HTTP Container 모드
+ if (string.IsNullOrEmpty(hostMountPath))
+ {
+ return $@"Translation complete!
+
+Download your file:
+http://localhost:8080/download/{outputFileName}
+
+Or use curl:
+curl -o ""{outputFileName}"" http://localhost:8080/download/{outputFileName}";
+ }
+
+ string hostOutputFile = Path.Combine(hostMountPath, "output", outputFileName);
+
+ return $@"Translation complete!
+
+Download your file:
+http://localhost:8080/download/{outputFileName}
+
+Or use curl:
+curl -o ""{outputFileName}"" http://localhost:8080/download/{outputFileName}
+
+File is also available at: {hostOutputFile}";
+ }
+ }
+
+ ///
+ /// STDIO 컨테이너 모드에서 성공 메시지를 생성합니다.
+ ///
+ private string BuildStdioContainerMessage(string outputFileName, string? hostMountPath)
+ {
+ if (string.IsNullOrEmpty(hostMountPath))
+ {
+ return $"Translation complete!\nOutput file: /files/output/{outputFileName}\n\nNote: The file is in the container's /files/output folder.";
+ }
+
+ string hostOutputFile = Path.Combine(hostMountPath, "output", outputFileName);
+
+ return $@"Translation complete!
+
+Output file is ready at:
+{hostOutputFile}
+
+If you want to copy the file to a different location, you can use:
+
+🍎/🐧 macOS & Linux:
+ cp ""{hostOutputFile}"" ""/path/to/destination/{outputFileName}""
+
+💻 Windows Command Prompt:
+ copy ""{hostOutputFile}"" ""\\path\\to\\destination\\{outputFileName}""
+
+💻 Windows PowerShell:
+ Copy-Item ""{hostOutputFile}"" -Destination ""/path/to/destination/{outputFileName}""";
+ }
+ }
+}
diff --git a/ppt-translator/src/McpSamples.PptTranslator.HybridApp/appsettings.Development.json b/ppt-translator/src/McpSamples.PptTranslator.HybridApp/appsettings.Development.json
new file mode 100644
index 0000000..0c208ae
--- /dev/null
+++ b/ppt-translator/src/McpSamples.PptTranslator.HybridApp/appsettings.Development.json
@@ -0,0 +1,8 @@
+{
+ "Logging": {
+ "LogLevel": {
+ "Default": "Information",
+ "Microsoft.AspNetCore": "Warning"
+ }
+ }
+}
diff --git a/ppt-translator/src/McpSamples.PptTranslator.HybridApp/appsettings.json b/ppt-translator/src/McpSamples.PptTranslator.HybridApp/appsettings.json
new file mode 100644
index 0000000..643ec90
--- /dev/null
+++ b/ppt-translator/src/McpSamples.PptTranslator.HybridApp/appsettings.json
@@ -0,0 +1,9 @@
+{
+ "Logging": {
+ "LogLevel": {
+ "Default": "Information",
+ "Microsoft": "Warning"
+ }
+ },
+ "AllowedHosts": "*"
+}
diff --git a/ppt-translator/src/translation_prompt.txt b/ppt-translator/src/translation_prompt.txt
new file mode 100644
index 0000000..6d82eb4
--- /dev/null
+++ b/ppt-translator/src/translation_prompt.txt
@@ -0,0 +1,87 @@
+You are a professional translator specialized in PowerPoint presentation content.
+
+The input is a JSON structure that contains extracted text from slides.
+Each object in the JSON has the following fields:
+- SlideIndex: The slide number
+- ShapeId: The unique identifier of the text shape
+- Text: The original extracted text
+
+Your goal is to translate ONLY the `Text` values into **{{TARGET_LANG}}**,
+while keeping the overall JSON structure (including SlideIndex and ShapeId) unchanged.
+
+===============================
+TRANSLATION RULES
+===============================
+1. JSON FORMAT
+- Do NOT modify keys, structure, or ordering.
+- Output must be valid JSON only.
+- Translate only the value in "Text".
+
+2. DO NOT TRANSLATE:
+- Brand names: Microsoft, PowerPoint, Azure
+- Acronyms: AI, API, GPU, HTTP, JSON
+- Protocol names: Model Context Protocol, OAuth, WebSocket
+- Model names: GPT-5, GPT-4o, Llama 3
+- Code, paths, URLs, formulas
+
+3. ACADEMIC TONE
+- Use clear, formal, precise language.
+- Maintain semantic meaning.
+- Preserve sentence length and structure.
+
+4. MIXED LANGUAGE HANDLING (UPDATED)
+- If the text contains both a main language and a secondary language in parentheses,
+ translate ONLY the main natural-language part.
+- Completely REMOVE the secondary language part inside parentheses.
+- Example:
+ Input: "데이터 분석 (Data Analysis)"
+ Target=en → "Data Analysis"
+ Target=ko → "데이터 분석"
+- Never swap languages.
+- Never keep the secondary language.
+- Only translate the main part and delete the secondary one.
+
+5. STRUCTURE PRESERVATION
+- Preserve line breaks (\n)
+- Preserve lists
+- Preserve formatting markers like **bold**
+
+6. DO NOT ADD ANY CONTENT
+
+
+===============================
+EXAMPLE
+===============================
+Input:
+{
+ "TotalCount": 2,
+ "Items": [
+ {
+ "SlideIndex": 1,
+ "ShapeId": "TextBox 5",
+ "Text": "Project Overview"
+ },
+ {
+ "SlideIndex": 2,
+ "ShapeId": "TextBox 7",
+ "Text": "Q&A and Discussion"
+ }
+ ]
+}
+
+Output:
+{
+ "TotalCount": 2,
+ "Items": [
+ {
+ "SlideIndex": 1,
+ "ShapeId": "TextBox 5",
+ "Text": "프로젝트 개요"
+ },
+ {
+ "SlideIndex": 2,
+ "ShapeId": "TextBox 7",
+ "Text": "Q&A 및 토론"
+ }
+ ]
+}