diff --git a/.gitignore b/.gitignore index bbdc6d7..e7fa168 100644 --- a/.gitignore +++ b/.gitignore @@ -34,8 +34,6 @@ bld/ # Visual Studio 2015/2017 cache/options directory .vs/ -# Uncomment if you have tasks that create the project's static files in wwwroot -#wwwroot/ # Visual Studio 2017 auto generated files Generated\ Files/ diff --git a/Dockerfile.openapi-to-sdk b/Dockerfile.openapi-to-sdk new file mode 100644 index 0000000..f901449 --- /dev/null +++ b/Dockerfile.openapi-to-sdk @@ -0,0 +1,39 @@ +# syntax=docker/dockerfile:1 + +FROM --platform=$BUILDPLATFORM mcr.microsoft.com/dotnet/sdk:9.0-alpine AS build + +COPY ./shared/McpSamples.Shared /source/shared/McpSamples.Shared +COPY ./openapi-to-sdk/src/McpSamples.OpenApiToSdk.HybridApp /source/openapi-to-sdk/src/McpSamples.OpenApiToSdk.HybridApp + +WORKDIR /source/openapi-to-sdk/src/McpSamples.OpenApiToSdk.HybridApp + +RUN dotnet tool install --global Microsoft.OpenApi.Kiota + +ARG TARGETARCH +RUN case "$TARGETARCH" in \ + "amd64") RID="linux-musl-x64" ;; \ + "arm64") RID="linux-musl-arm64" ;; \ + *) RID="linux-musl-x64" ;; \ + esac && \ + dotnet publish -c Release -o /app -r $RID --self-contained false + +FROM mcr.microsoft.com/dotnet/aspnet:9.0-alpine AS final + +WORKDIR /app + +COPY --from=build /app . + +COPY --from=build /root/.dotnet/tools /opt/kiota-tools + +RUN chmod -R 755 /opt/kiota-tools && \ + ln -s /opt/kiota-tools/kiota /usr/local/bin/kiota + +RUN mkdir -p /app/workspace/generated && \ + mkdir -p /app/workspace/specs && \ + chown -R $APP_UID:$APP_UID /app/workspace + +ENV PATH="/opt/kiota-tools:${PATH}" + +USER $APP_UID + +ENTRYPOINT ["dotnet", "McpSamples.OpenApiToSdk.HybridApp.dll"] \ No newline at end of file diff --git a/Dockerfile.openapi-to-sdk-azure b/Dockerfile.openapi-to-sdk-azure new file mode 100644 index 0000000..9d817b7 --- /dev/null +++ b/Dockerfile.openapi-to-sdk-azure @@ -0,0 +1,31 @@ +# syntax=docker/dockerfile:1 + +FROM mcr.microsoft.com/dotnet/sdk:9.0-alpine AS build + +RUN dotnet tool install --global Microsoft.OpenApi.Kiota + +COPY ./shared/McpSamples.Shared /source/shared/McpSamples.Shared +COPY ./openapi-to-sdk/src/McpSamples.OpenApiToSdk.HybridApp /source/openapi-to-sdk/src/McpSamples.OpenApiToSdk.HybridApp + +WORKDIR /source/openapi-to-sdk/src/McpSamples.OpenApiToSdk.HybridApp + +RUN dotnet publish -c Release -o /app --self-contained false + +FROM mcr.microsoft.com/dotnet/aspnet:9.0-alpine AS final + +WORKDIR /app + +COPY --from=build /app . +COPY --from=build /root/.dotnet/tools /opt/kiota-tools + +RUN mkdir -p /app/workspace/generated && \ + mkdir -p /app/workspace/specs && \ + chown -R $APP_UID:$APP_UID /app/workspace && \ + chmod -R 755 /opt/kiota-tools && \ + ln -s /opt/kiota-tools/kiota /usr/local/bin/kiota + +ENV PATH="/opt/kiota-tools:${PATH}" + +USER $APP_UID + +ENTRYPOINT ["dotnet", "McpSamples.OpenApiToSdk.HybridApp.dll"] \ No newline at end of file diff --git a/openapi-to-sdk/.dockerignore b/openapi-to-sdk/.dockerignore new file mode 100644 index 0000000..9e03c48 --- /dev/null +++ b/openapi-to-sdk/.dockerignore @@ -0,0 +1,32 @@ +# Include any files or directories that you don't want to be copied to your +# container here (e.g., local build artifacts, temporary files, etc.). +# +# For more help, visit the .dockerignore file reference guide at +# https://docs.docker.com/go/build-context-dockerignore/ + +**/.DS_Store +**/.classpath +**/.dockerignore +**/.env +**/.git +**/.gitignore +**/.project +**/.settings +**/.toolstarget +**/.vs +**/.vscode +**/*.*proj.user +**/*.dbmdl +**/*.jfm +**/bin +**/charts +**/docker-compose* +**/compose.y*ml +**/Dockerfile* +**/node_modules +**/npm-debug.log +**/obj +**/secrets.dev.yaml +**/values.dev.yaml +LICENSE +README.md diff --git a/openapi-to-sdk/.gitignore b/openapi-to-sdk/.gitignore new file mode 100644 index 0000000..78b461c --- /dev/null +++ b/openapi-to-sdk/.gitignore @@ -0,0 +1,5 @@ +!.vscode/mcp.json +.azure + +image.png +test/ \ No newline at end of file diff --git a/openapi-to-sdk/.vscode/mcp.http.container.json b/openapi-to-sdk/.vscode/mcp.http.container.json new file mode 100644 index 0000000..c52e811 --- /dev/null +++ b/openapi-to-sdk/.vscode/mcp.http.container.json @@ -0,0 +1,8 @@ +{ + "servers": { + "openapi-to-sdk": { + "type": "http", + "url": "http://localhost:8080/mcp" + } + } +} \ No newline at end of file diff --git a/openapi-to-sdk/.vscode/mcp.http.local.json b/openapi-to-sdk/.vscode/mcp.http.local.json new file mode 100644 index 0000000..8e5caa3 --- /dev/null +++ b/openapi-to-sdk/.vscode/mcp.http.local.json @@ -0,0 +1,8 @@ +{ + "servers": { + "openapi-to-sdk": { + "type": "http", + "url": "http://localhost:5222/mcp" + } + } +} \ No newline at end of file diff --git a/openapi-to-sdk/.vscode/mcp.http.remote.json b/openapi-to-sdk/.vscode/mcp.http.remote.json new file mode 100644 index 0000000..368f825 --- /dev/null +++ b/openapi-to-sdk/.vscode/mcp.http.remote.json @@ -0,0 +1,15 @@ +{ + "inputs": [ + { + "type": "promptString", + "id": "acaapp-server-fqdn", + "description": "Azure Container Apps FQDN" + } + ], + "servers": { + "openapi-to-sdk": { + "type": "http", + "url": "https://${input:acaapp-server-fqdn}/mcp" + } + } +} diff --git a/openapi-to-sdk/.vscode/mcp.stdio.container.json b/openapi-to-sdk/.vscode/mcp.stdio.container.json new file mode 100644 index 0000000..e1671c4 --- /dev/null +++ b/openapi-to-sdk/.vscode/mcp.stdio.container.json @@ -0,0 +1,26 @@ +{ + "inputs": [ + { + "type": "promptString", + "id": "consoleapp-project-path", + "description": "The absolute path to the console app project Directory" + } + ], + "servers": { + "openapi-to-sdk": { + "type": "stdio", + "command": "docker", + "args": [ + "run", + "-i", + "--rm", + "-v", + "${input:consoleapp-project-path}/../../workspace:/app/workspace", + "-e", + "HOST_ROOT_PATH=${input:consoleapp-project-path}/../../", + "openapi-to-sdk:latest", + "-c" + ] + } + } +} \ No newline at end of file diff --git a/openapi-to-sdk/.vscode/mcp.stdio.local.json b/openapi-to-sdk/.vscode/mcp.stdio.local.json new file mode 100644 index 0000000..b2f49e9 --- /dev/null +++ b/openapi-to-sdk/.vscode/mcp.stdio.local.json @@ -0,0 +1,20 @@ +{ + "inputs": [ + { + "type": "promptString", + "id": "consoleapp-project-path", + "description": "The absolute path to the console app project Directory" + } + ], + "servers": { + "openapi-to-sdk": { + "type": "stdio", + "command": "dotnet", + "args": [ + "run", + "--project", + "${input:consoleapp-project-path}" + ] + } + } +} \ No newline at end of file diff --git a/openapi-to-sdk/McpOpenApiToSdk.sln b/openapi-to-sdk/McpOpenApiToSdk.sln new file mode 100644 index 0000000..adb0e79 --- /dev/null +++ b/openapi-to-sdk/McpOpenApiToSdk.sln @@ -0,0 +1,53 @@ +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.Shared", "..\shared\McpSamples.Shared\McpSamples.Shared.csproj", "{D0715E17-D5D1-4062-A2FF-19B696DF623D}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "McpSamples.OpenApiToSdk.HybridApp", "src\McpSamples.OpenApiToSdk.HybridApp\McpSamples.OpenApiToSdk.HybridApp.csproj", "{F760C2D9-C4DE-45B5-9A04-221958D959E7}" +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 + {D0715E17-D5D1-4062-A2FF-19B696DF623D}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {D0715E17-D5D1-4062-A2FF-19B696DF623D}.Debug|Any CPU.Build.0 = Debug|Any CPU + {D0715E17-D5D1-4062-A2FF-19B696DF623D}.Debug|x64.ActiveCfg = Debug|Any CPU + {D0715E17-D5D1-4062-A2FF-19B696DF623D}.Debug|x64.Build.0 = Debug|Any CPU + {D0715E17-D5D1-4062-A2FF-19B696DF623D}.Debug|x86.ActiveCfg = Debug|Any CPU + {D0715E17-D5D1-4062-A2FF-19B696DF623D}.Debug|x86.Build.0 = Debug|Any CPU + {D0715E17-D5D1-4062-A2FF-19B696DF623D}.Release|Any CPU.ActiveCfg = Release|Any CPU + {D0715E17-D5D1-4062-A2FF-19B696DF623D}.Release|Any CPU.Build.0 = Release|Any CPU + {D0715E17-D5D1-4062-A2FF-19B696DF623D}.Release|x64.ActiveCfg = Release|Any CPU + {D0715E17-D5D1-4062-A2FF-19B696DF623D}.Release|x64.Build.0 = Release|Any CPU + {D0715E17-D5D1-4062-A2FF-19B696DF623D}.Release|x86.ActiveCfg = Release|Any CPU + {D0715E17-D5D1-4062-A2FF-19B696DF623D}.Release|x86.Build.0 = Release|Any CPU + {F760C2D9-C4DE-45B5-9A04-221958D959E7}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {F760C2D9-C4DE-45B5-9A04-221958D959E7}.Debug|Any CPU.Build.0 = Debug|Any CPU + {F760C2D9-C4DE-45B5-9A04-221958D959E7}.Debug|x64.ActiveCfg = Debug|Any CPU + {F760C2D9-C4DE-45B5-9A04-221958D959E7}.Debug|x64.Build.0 = Debug|Any CPU + {F760C2D9-C4DE-45B5-9A04-221958D959E7}.Debug|x86.ActiveCfg = Debug|Any CPU + {F760C2D9-C4DE-45B5-9A04-221958D959E7}.Debug|x86.Build.0 = Debug|Any CPU + {F760C2D9-C4DE-45B5-9A04-221958D959E7}.Release|Any CPU.ActiveCfg = Release|Any CPU + {F760C2D9-C4DE-45B5-9A04-221958D959E7}.Release|Any CPU.Build.0 = Release|Any CPU + {F760C2D9-C4DE-45B5-9A04-221958D959E7}.Release|x64.ActiveCfg = Release|Any CPU + {F760C2D9-C4DE-45B5-9A04-221958D959E7}.Release|x64.Build.0 = Release|Any CPU + {F760C2D9-C4DE-45B5-9A04-221958D959E7}.Release|x86.ActiveCfg = Release|Any CPU + {F760C2D9-C4DE-45B5-9A04-221958D959E7}.Release|x86.Build.0 = Release|Any CPU + EndGlobalSection + GlobalSection(SolutionProperties) = preSolution + HideSolutionNode = FALSE + EndGlobalSection + GlobalSection(NestedProjects) = preSolution + {D0715E17-D5D1-4062-A2FF-19B696DF623D} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B} + {F760C2D9-C4DE-45B5-9A04-221958D959E7} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B} + EndGlobalSection +EndGlobal diff --git a/openapi-to-sdk/README.md b/openapi-to-sdk/README.md new file mode 100644 index 0000000..bd76a06 --- /dev/null +++ b/openapi-to-sdk/README.md @@ -0,0 +1,275 @@ +# MCP Server: OpenAPI to SDK Generator + +This is an MCP server that generates client SDKs from OpenAPI specifications using Microsoft Kiota. + +## Install + +[![Install in VS Code](https://img.shields.io/badge/VS_Code-Install-0098FF?style=flat-square&logo=visualstudiocode&logoColor=white)]() [![Install in VS Code Insiders](https://img.shields.io/badge/VS_Code_Insiders-Install-24bfa5?style=flat-square&logo=visualstudiocode&logoColor=white)]() [![Install in Visual Studio](https://img.shields.io/badge/Visual_Studio-Install-C16FDE?logo=visualstudio&logoColor=white)]() + +## 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/) +- [Kiota](https://learn.microsoft.com/en-us/openapi/kiota/install?tabs=bash) + +## What's Included + +OpenAPI to SDK MCP server includes: + +| Building Block | Name | Description | Usage | +|----------------|----------------|---------------------------------------------------------------------------------------------------------|-----------------------| +| Tools | `generate_sdk` | Generates a client SDK from an OpenAPI specification (URL or raw content) and returns a download link. | `#generate_sdk` | +| Prompts | `generate_sdk_prompt` | A structured prompt that guides the LLM to generate an SDK, handling language normalization and inputs. | `/mcp.openapi-to-sdk.generate_sdk_prompt` | + +## 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) + +### 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. Run the MCP server app. + + ```bash + cd $REPOSITORY_ROOT/openapi-to-sdk + dotnet run --project ./src/McpSamples.OpenApiToSdk.HybridApp + ``` + + > Make sure take note the absolute directory path of the `McpSamples.OpenApiToSdk.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:5220`. + + Example running in HTTP mode: + + ```bash + dotnet run --project ./src/McpSamples.OpenApiToSdk.HybridApp -- --http + ``` + +#### In a container + +1. Build the MCP server app as a container image. + + ```bash + cd $REPOSITORY_ROOT + docker build -f Dockerfile.openapi-to-sdk -t openapi-to-sdk:latest . + ``` + + > Make sure take note the absolute directory path of the `openapi-to-sdk` project. + +1. Run the MCP server app in a container. + + ```bash + docker run -i --rm -p 8080:8080 -v "$REPOSITORY_ROOT/openapi-to-sdk/workspace:/app/workspace" -e HOST_ROOT_PATH="$REPOSITORY_ROOT/openapi-to-sdk" openapi-to-sdk: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 this parameter, you can run the MCP server like: + + ```bash + # use local container image + docker run -i --rm -p 8080:8080 -v "$REPOSITORY_ROOT/openapi-to-sdk/workspace:/app/workspace" -e HOST_ROOT_PATH="$REPOSITORY_ROOT/openapi-to-sdk" openapi-to-sdk:latest --http -c + ``` + + + +#### On Azure + +1. Navigate to the directory. + + ```bash + cd $REPOSITORY_ROOT/openapi-to-sdk + ``` + +1. Login to Azure. + + ```bash + # Login with Azure Developer CLI + azd auth login + ``` + +1. Deploy the MCP server app to Azure. + + ```bash + azd up + ``` + + While provisioning and deploying, you'll be asked to provide subscription ID, location, environment name. + +1. After the deployment is complete, get the information by running the following commands: + + - Azure Container Apps FQDN: + + ```bash + azd env get-value AZURE_RESOURCE_MCP_OPENAPI_TO_SDK_FQDN + ``` + +### 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/openapi-to-sdk/.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/openapi-to-sdk/.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/openapi-to-sdk/.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/openapi-to-sdk/.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/openapi-to-sdk/.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/openapi-to-sdk/.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/openapi-to-sdk/.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/openapi-to-sdk/.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/openapi-to-sdk/.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/openapi-to-sdk/.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 `openapi-to-sdk` then click `Start Server`. +1. When prompted, enter one of the following values: + - The absolute directory path of the `McpSamples.OpenApiToSdk.HybridApp` project + - The FQDN of Azure Container Apps. +1. Use a prompt by typing `/mcp.openapi-to-sdk.generate_sdk` and enter keywords to search. You'll get a prompt like: + + ```text + You are an expert SDK generator using Microsoft Kiota. + + Your task is to generate a client SDK based on the following inputs: + - OpenAPI Source: `{specSource}` + - Target Language: `{language}` + - Configuration: + - Class Name: {clientClassName} + - Namespace: {namespaceName} + - Additional Options: {additionalOptions} + + --- + ### Execution Rules (Follow Strictly) + + 1. **Smart Language Normalization**: + The `generate_sdk` tool ONLY accepts the following language identifiers: + [ CSharp, Java, TypeScript, PHP, Python, Go, Ruby, Dart, HTTP ] + + You MUST intelligently map the user's input to one of these valid identifiers. + + - **Handle Aliases & Variations**: + - "C#", "c#", ".NET", "dotnet", "chsarp" (typo) -> Use CSharp + - "TS", "Ts", "ts", "node", "typoscript" (typo) -> Use TypeScript + - "Golang", "Goo" (typo) -> Use Go + - "py", "pyton" (typo), "python3" -> Use Python + - "jav", "Jave" (typo) -> Use Java + + - **Auto-Correction**: + - If the user makes a minor typo or uses a common abbreviation, automatically correct it to the nearest valid identifier from the list above. + + - **Validation**: + - If the input refers to a completely unsupported language (e.g., "Rust", "C++", "Assembly"), STOP and politely inform the user that it is not currently supported by Kiota. + + 2. **Handle Output Path**: + - The `generate_sdk` tool manages the output path internally to create a ZIP file. + - NEVER pass `-o` or `--output` in the `additionalOptions` argument, even if the user asks to save it to a specific location (e.g., "Generate to D:/Work"). + - Instead, follow this workflow: + 1. Call `generate_sdk` WITHOUT the output path option. + 2. Once the tool returns the ZIP file path (or download link), tell the user: "I have generated the SDK. Would you like me to move/extract it to [User's Requested Path]?" + 3. If the user agrees, use your filesystem tools to move the file. + + 3. **Call the Tool**: + Use the `generate_sdk` tool with the normalized language and filtered options (excluding -o). + + 4. **Report**: + Provide the download link or file path returned by the tool. + ``` + +1. Confirm the result. \ No newline at end of file diff --git a/openapi-to-sdk/azure.yaml b/openapi-to-sdk/azure.yaml new file mode 100644 index 0000000..b0e9b4b --- /dev/null +++ b/openapi-to-sdk/azure.yaml @@ -0,0 +1,16 @@ +# yaml-language-server: $schema=https://raw.githubusercontent.com/Azure/azure-dev/main/schemas/v1.0/azure.yaml.json + +name: openapi-to-sdk + +metadata: + template: azd-init@1.14.0 + +services: + openapi-to-sdk: + project: src/McpSamples.OpenApiToSdk.HybridApp + host: containerapp + language: dotnet + docker: + path: ../../../Dockerfile.openapi-to-sdk-azure + context: ../../../ + remoteBuild: true diff --git a/openapi-to-sdk/infra/abbreviations.json b/openapi-to-sdk/infra/abbreviations.json new file mode 100644 index 0000000..893310d --- /dev/null +++ b/openapi-to-sdk/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-" +} \ No newline at end of file diff --git a/openapi-to-sdk/infra/main.bicep b/openapi-to-sdk/infra/main.bicep new file mode 100644 index 0000000..0aa17db --- /dev/null +++ b/openapi-to-sdk/infra/main.bicep @@ -0,0 +1,47 @@ +targetScope = 'subscription' + +@minLength(1) +@maxLength(64) +@description('Name of the environment that can be used as part of naming resource convention') +param environmentName string + +@minLength(1) +@description('Primary location for all resources') +param location string + +param mcpOpenApiToSdkExists bool + +@description('Id of the user or app to assign application roles') +param principalId string + +// Tags that should be applied to all resources. +// +// Note that 'azd-service-name' tags should be applied separately to service host resources. +// Example usage: +// tags: union(tags, { 'azd-service-name': }) +var tags = { + 'azd-env-name': environmentName +} + +// Organize resources in a resource group +resource rg 'Microsoft.Resources/resourceGroups@2021-04-01' = { + name: 'rg-${environmentName}' + location: location + tags: tags +} + +module resources 'resources.bicep' = { + scope: rg + name: 'resources' + params: { + location: location + tags: tags + principalId: principalId + mcpOpenApiToSdkExists: mcpOpenApiToSdkExists + } +} + +output AZURE_CONTAINER_REGISTRY_ENDPOINT string = resources.outputs.AZURE_CONTAINER_REGISTRY_ENDPOINT +output AZURE_RESOURCE_MCP_OPENAPI_TO_SDK_ID string = resources.outputs.AZURE_RESOURCE_MCP_OPENAPI_TO_SDK_ID +output AZURE_RESOURCE_MCP_OPENAPI_TO_SDK_NAME string = resources.outputs.AZURE_RESOURCE_MCP_OPENAPI_TO_SDK_NAME +output AZURE_RESOURCE_MCP_OPENAPI_TO_SDK_FQDN string = resources.outputs.AZURE_RESOURCE_MCP_OPENAPI_TO_SDK_FQDN diff --git a/openapi-to-sdk/infra/main.parameters.json b/openapi-to-sdk/infra/main.parameters.json new file mode 100644 index 0000000..9fafcc0 --- /dev/null +++ b/openapi-to-sdk/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}" + }, + "mcpOpenApiToSdkExists": { + "value": "${SERVICE_OPENAPI_TO_SDK_RESOURCE_EXISTS=false}" + }, + "principalId": { + "value": "${AZURE_PRINCIPAL_ID}" + } + } +} \ No newline at end of file diff --git a/openapi-to-sdk/infra/modules/fetch-container-image.bicep b/openapi-to-sdk/infra/modules/fetch-container-image.bicep new file mode 100644 index 0000000..d510834 --- /dev/null +++ b/openapi-to-sdk/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 : [] \ No newline at end of file diff --git a/openapi-to-sdk/infra/resources.bicep b/openapi-to-sdk/infra/resources.bicep new file mode 100644 index 0000000..3c4dc83 --- /dev/null +++ b/openapi-to-sdk/infra/resources.bicep @@ -0,0 +1,218 @@ +@description('The location used for all deployed resources') +param location string = resourceGroup().location + +@description('Tags that will be applied to all resources') +param tags object = {} + +param mcpOpenApiToSdkExists bool + +@description('Id of the user or app to assign application roles') +param principalId string + +var abbrs = loadJsonContent('./abbreviations.json') +var resourceToken = uniqueString(subscription().id, resourceGroup().id, location) + +// Storage account and file share for the workspace +resource storage 'Microsoft.Storage/storageAccounts@2025-01-01' = { + name: '${abbrs.storageStorageAccounts}${resourceToken}' + location: location + tags: tags + kind: 'StorageV2' + sku: { + name: 'Standard_LRS' + } + properties: { + minimumTlsVersion: 'TLS1_2' + allowBlobPublicAccess: false + publicNetworkAccess: 'Enabled' + } +} + +resource storageFileService 'Microsoft.Storage/storageAccounts/fileServices@2025-01-01' = { + parent: storage + name: 'default' +} + +resource storageFileShare 'Microsoft.Storage/storageAccounts/fileServices/shares@2025-01-01' = { + parent: storageFileService + name: 'workspace' + properties: { + accessTier: 'TransactionOptimized' + shareQuota: 1024 + enabledProtocols: 'SMB' + } +} + +// 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: mcpOpenApiToSdkIdentity.outputs.principalId + principalType: 'ServicePrincipal' + // ACR pull role + roleDefinitionIdOrName: subscriptionResourceId('Microsoft.Authorization/roleDefinitions', '7f951dda-4ed3-4680-a7ca-43fe172d538d') + } + ] + } +} + +// Container apps 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 + } +} + +// Link storage to the container apps environment +resource env 'Microsoft.App/managedEnvironments@2025-01-01' existing = { + name: '${abbrs.appManagedEnvironments}${resourceToken}' +} + +resource envStorage 'Microsoft.App/managedEnvironments/storages@2025-01-01' = { + parent: env + name: 'workspace' + properties: { + azureFile: { + accountName: storage.name + accountKey: storage.listKeys().keys[0].value + shareName: storageFileShare.name + accessMode: 'ReadWrite' + } + } + dependsOn: [ + containerAppsEnvironment + ] +} + +// User assigned identity +module mcpOpenApiToSdkIdentity 'br/public:avm/res/managed-identity/user-assigned-identity:0.2.1' = { + name: 'mcpOpenApiToSdkIdentity' + params: { + name: '${abbrs.managedIdentityUserAssignedIdentities}mcp-openapi-to-sdk-${resourceToken}' + location: location + } +} + +// Azure Container Apps - Image Fetching +module mcpOpenApiToSdkFetchLatestImage './modules/fetch-container-image.bicep' = { + name: 'mcpOpenApiToSdkFetchLatestImage' + params: { + exists: mcpOpenApiToSdkExists + name: 'openapi-to-sdk' + } +} + +// Azure Container Apps - Main App +module mcpOpenApiToSdk 'br/public:avm/res/app/container-app:0.8.0' = { + name: 'mcpOpenApiToSdk' + params: { + name: 'openapi-to-sdk' + ingressTargetPort: 8080 + scaleMinReplicas: 1 + scaleMaxReplicas: 10 + secrets: { + secureList: [ + ] + } + + // Define the volume using the envStorage created above + volumes: [ + { + name: 'workspace-vol' + storageType: 'AzureFile' + storageName: 'workspace' + } + ] + + containers: [ + { + image: mcpOpenApiToSdkFetchLatestImage.outputs.?containers[?0].?image ?? 'mcr.microsoft.com/azuredocs/containerapps-helloworld:latest' + name: 'main' + resources: { + cpu: json('0.5') + memory: '1.0Gi' + } + env: [ + { + name: 'APPLICATIONINSIGHTS_CONNECTION_STRING' + value: monitoring.outputs.applicationInsightsConnectionString + } + { + name: 'AZURE_CLIENT_ID' + value: mcpOpenApiToSdkIdentity.outputs.clientId + } + { + name: 'PORT' + value: '8080' + } + ] + args: [ + '--http', + '--azure' + ] + + // Mount the volume to a path inside the container + volumeMounts: [ + { + volumeName: 'workspace-vol' + mountPath: '/app/workspace' + } + ] + } + ] + managedIdentities: { + systemAssigned: false + userAssignedResourceIds: [ + mcpOpenApiToSdkIdentity.outputs.resourceId + ] + } + registries: [ + { + server: containerRegistry.outputs.loginServer + identity: mcpOpenApiToSdkIdentity.outputs.resourceId + } + ] + environmentResourceId: containerAppsEnvironment.outputs.resourceId + corsPolicy: { + allowedOrigins: [ + 'https://make.preview.powerapps.com' + 'https://make.powerapps.com' + 'https://make.preview.powerautomate.com' + 'https://make.powerautomate.com' + 'https://copilotstudio.preview.microsoft.com' + 'https://copilotstudio.microsoft.com' + ] + } + location: location + tags: union(tags, { 'azd-service-name': 'openapi-to-sdk' }) + } +} + +output AZURE_CONTAINER_REGISTRY_ENDPOINT string = containerRegistry.outputs.loginServer +output AZURE_RESOURCE_MCP_OPENAPI_TO_SDK_ID string = mcpOpenApiToSdk.outputs.resourceId +output AZURE_RESOURCE_MCP_OPENAPI_TO_SDK_NAME string = mcpOpenApiToSdk.outputs.name +output AZURE_RESOURCE_MCP_OPENAPI_TO_SDK_FQDN string = mcpOpenApiToSdk.outputs.fqdn +output AZURE_STORAGE_ACCOUNT_NAME string = storage.name +output AZURE_FILE_SHARE_NAME string = storageFileShare.name \ No newline at end of file diff --git a/openapi-to-sdk/src/McpSamples.OpenApiToSdk.HybridApp/Configurations/OpenApiToSdkAppSettings.cs b/openapi-to-sdk/src/McpSamples.OpenApiToSdk.HybridApp/Configurations/OpenApiToSdkAppSettings.cs new file mode 100644 index 0000000..f86d6a3 --- /dev/null +++ b/openapi-to-sdk/src/McpSamples.OpenApiToSdk.HybridApp/Configurations/OpenApiToSdkAppSettings.cs @@ -0,0 +1,82 @@ +using McpSamples.Shared.Configurations; +using Microsoft.OpenApi.Models; + +namespace McpSamples.OpenApiToSdk.HybridApp.Configurations; + +/// +/// This represents the application settings for the OpenApiToSdk app. +/// +public class OpenApiToSdkAppSettings : AppSettings +{ + /// + public override OpenApiInfo OpenApi { get; set; } = new() + { + Title = "MCP OpenAPI to SDK", + Version = "1.0.0", + Description = "An MCP server that generates client SDKs from OpenAPI specifications using Kiota." + }; + + /// + /// Gets or sets the instance. + /// + public RuntimeSettings Runtime { get; set; } = new RuntimeSettings(); + + /// + /// The root path for the workspace (shared volume or local folder). + /// + public string WorkspacePath { get; set; } = string.Empty; + + /// + /// The path where generated SDKs (zip files) will be stored. + /// + public string GeneratedPath { get; set; } = string.Empty; + + /// + /// The path where spec files are stored (or mounted). + /// + public string SpecsPath { get; set; } = string.Empty; + + /// + /// Indicates if the app is running in HTTP mode (vs Stdio). + /// + public bool IsHttpMode { get; set; } + + /// + protected override T ParseMore(IConfiguration config, string[] args) + { + var settings = base.ParseMore(config, args); + + for (var i = 0; i < args.Length; i++) + { + var arg = args[i]; + switch (arg) + { + case "--azure": + case "-a": + (settings as OpenApiToSdkAppSettings)!.Runtime.Mode = "Azure"; + break; + + case "--container": + case "-c": + (settings as OpenApiToSdkAppSettings)!.Runtime.Mode = "Container"; + break; + + default: + break; + } + } + + return settings; + } +} + +/// +/// This represents the runtime settings for the OpenApiToSdk app. +/// +public class RuntimeSettings +{ + /// + /// Gets or sets the runtime mode (Local, Container, Azure). + /// + public string Mode { get; set; } = "Local"; +} \ No newline at end of file diff --git a/openapi-to-sdk/src/McpSamples.OpenApiToSdk.HybridApp/McpSamples.OpenApiToSdk.HybridApp.csproj b/openapi-to-sdk/src/McpSamples.OpenApiToSdk.HybridApp/McpSamples.OpenApiToSdk.HybridApp.csproj new file mode 100644 index 0000000..eceed2b --- /dev/null +++ b/openapi-to-sdk/src/McpSamples.OpenApiToSdk.HybridApp/McpSamples.OpenApiToSdk.HybridApp.csproj @@ -0,0 +1,20 @@ + + + + net9.0 + latest + + enable + enable + + McpSamples.OpenApiToSdk.HybridApp + McpSamples.OpenApiToSdk.HybridApp + + 8b94c6d9-feae-4416-8f70-4b50ea51170b + + + + + + + diff --git a/openapi-to-sdk/src/McpSamples.OpenApiToSdk.HybridApp/Models/OpenApiToSdkResult.cs b/openapi-to-sdk/src/McpSamples.OpenApiToSdk.HybridApp/Models/OpenApiToSdkResult.cs new file mode 100644 index 0000000..e69e26a --- /dev/null +++ b/openapi-to-sdk/src/McpSamples.OpenApiToSdk.HybridApp/Models/OpenApiToSdkResult.cs @@ -0,0 +1,27 @@ +using System.Text.Json.Serialization; + +namespace McpSamples.OpenApiToSdk.HybridApp.Models; + +/// +/// Represents the result of an SDK generation operation. +/// +public class OpenApiToSdkResult +{ + /// + /// Gets or sets a value indicating whether the generation was successful. + /// + [JsonPropertyName("isSuccess")] + public bool IsSuccess { get; set; } + + /// + /// Gets or sets the message or output path. + /// + [JsonPropertyName("message")] + public string Message { get; set; } = string.Empty; + + /// + /// Gets or sets the download URL (if applicable). + /// + [JsonPropertyName("downloadUrl")] + public string? DownloadUrl { get; set; } +} \ No newline at end of file diff --git a/openapi-to-sdk/src/McpSamples.OpenApiToSdk.HybridApp/Program.cs b/openapi-to-sdk/src/McpSamples.OpenApiToSdk.HybridApp/Program.cs new file mode 100644 index 0000000..884b02d --- /dev/null +++ b/openapi-to-sdk/src/McpSamples.OpenApiToSdk.HybridApp/Program.cs @@ -0,0 +1,113 @@ +using System.Text.Json; +using Microsoft.Extensions.FileProviders; +using McpSamples.OpenApiToSdk.HybridApp.Configurations; +using McpSamples.OpenApiToSdk.HybridApp.Prompts; +using McpSamples.OpenApiToSdk.HybridApp.Services; +using McpSamples.Shared.Configurations; +using McpSamples.Shared.Extensions; + +var useStreamableHttp = AppSettings.UseStreamableHttp(Environment.GetEnvironmentVariables(), args); + +IHostApplicationBuilder builder = useStreamableHttp + ? WebApplication.CreateBuilder(args) + : Host.CreateApplicationBuilder(args); + +builder.Services.AddAppSettings(builder.Configuration, args); +builder.Services.AddHttpContextAccessor(); + +var options = new JsonSerializerOptions +{ + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + WriteIndented = true, + AllowTrailingCommas = true, + PropertyNameCaseInsensitive = true +}; +builder.Services.AddSingleton(options); + +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); + +IHost app = builder.BuildApp(useStreamableHttp); + +var appSettings = app.Services.GetRequiredService(); +InitializeRuntimeSettings(appSettings, useStreamableHttp); + +if (useStreamableHttp) +{ + var webApp = (app as WebApplication)!; + + if (!Directory.Exists(appSettings.GeneratedPath)) + { + Directory.CreateDirectory(appSettings.GeneratedPath); + } + + webApp.UseStaticFiles(new StaticFileOptions + { + FileProvider = new PhysicalFileProvider(appSettings.GeneratedPath), + RequestPath = "/download", + ServeUnknownFileTypes = true + }); + + webApp.MapPost("/upload", async (IFormFile file, OpenApiToSdkAppSettings settings) => + { + if (file == null || file.Length == 0) + return Results.BadRequest("No file uploaded."); + + if (!Directory.Exists(settings.SpecsPath)) + Directory.CreateDirectory(settings.SpecsPath); + + var fileName = Path.GetFileName(file.FileName); + var filePath = Path.Combine(settings.SpecsPath, fileName); + + using (var stream = new FileStream(filePath, FileMode.Create)) + { + await file.CopyToAsync(stream); + } + + return Results.Ok(new { Message = "File uploaded successfully.", SavedPath = filePath }); + }) + .DisableAntiforgery(); +} + +await app.RunAsync(); + +void InitializeRuntimeSettings(OpenApiToSdkAppSettings settings, bool isHttp) +{ + string baseDirectory; + + if (settings.Runtime.Mode.Equals("Local", StringComparison.OrdinalIgnoreCase)) + { + baseDirectory = TryFindProjectRoot(Directory.GetCurrentDirectory()) ?? Directory.GetCurrentDirectory(); + + Console.WriteLine($"[Init] Local Base Directory resolved to: {baseDirectory}"); + } + else + { + baseDirectory = Directory.GetCurrentDirectory(); + } + + string workspacePath = Path.Combine(baseDirectory, "workspace"); + + settings.WorkspacePath = workspacePath; + settings.GeneratedPath = Path.Combine(workspacePath, "generated"); + settings.SpecsPath = Path.Combine(workspacePath, "specs"); + settings.IsHttpMode = isHttp; + + if (!Directory.Exists(settings.WorkspacePath)) Directory.CreateDirectory(settings.WorkspacePath); + if (!Directory.Exists(settings.SpecsPath)) Directory.CreateDirectory(settings.SpecsPath); + if (!Directory.Exists(settings.GeneratedPath)) Directory.CreateDirectory(settings.GeneratedPath); +} + +string? TryFindProjectRoot(string startPath) +{ + var dir = new DirectoryInfo(startPath); + while (dir != null) + { + if (dir.Name.Equals("openapi-to-sdk", StringComparison.OrdinalIgnoreCase)) + { + return dir.FullName; + } + dir = dir.Parent; + } + return null; +} \ No newline at end of file diff --git a/openapi-to-sdk/src/McpSamples.OpenApiToSdk.HybridApp/Prompts/SdkGenerationPrompt.cs b/openapi-to-sdk/src/McpSamples.OpenApiToSdk.HybridApp/Prompts/SdkGenerationPrompt.cs new file mode 100644 index 0000000..c35537d --- /dev/null +++ b/openapi-to-sdk/src/McpSamples.OpenApiToSdk.HybridApp/Prompts/SdkGenerationPrompt.cs @@ -0,0 +1,91 @@ +using System.ComponentModel; +using ModelContextProtocol.Server; + +namespace McpSamples.OpenApiToSdk.HybridApp.Prompts; + +/// +/// Defines the interface for SDK generation prompts. +/// +public interface ISdkGenerationPrompt +{ + /// + /// Gets a prompt to guide the user in generating a client SDK. + /// + string GetSdkGenerationPrompt( + string specSource, + string language, + string? clientClassName = "ApiClient", + string? namespaceName = "ApiSdk", + string? additionalOptions = "None"); +} + +/// +/// Represents the prompts for the OpenAPI to SDK generator. +/// +[McpServerPromptType] +public class SdkGenerationPrompt : ISdkGenerationPrompt +{ + /// + [McpServerPrompt(Name = "generate_sdk_prompt", Title = "Prompt for generating client SDK")] + [Description("A prompt to guide the user in generating a client SDK from an OpenAPI specification.")] + public string GetSdkGenerationPrompt( + [Description("The URL or local file path of the OpenAPI specification.")] + string specSource, + [Description("The target programming language. Supported values: CSharp, Java, TypeScript, PHP, Python, Go, Ruby, Dart, HTTP.")] + string language, + [Description("The name of the generated client class. Default: 'ApiClient'.")] + string? clientClassName = "ApiClient", + [Description("The namespace for the generated code. Default: 'ApiSdk'.")] + string? namespaceName = "ApiSdk", + [Description("Any additional options for Kiota generation (e.g., --version).")] + string? additionalOptions = "None") + { + return $""" + You are an expert SDK generator using Microsoft Kiota. + + Your task is to generate a client SDK based on the following inputs: + - OpenAPI Source: `{specSource}` + - Target Language: `{language}` + - Configuration: + - Class Name: {clientClassName} + - Namespace: {namespaceName} + - Additional Options: {additionalOptions} + + --- + ### Execution Rules (Follow Strictly) + + 1. **Smart Language Normalization**: + The `generate_sdk` tool ONLY accepts the following language identifiers: + [ CSharp, Java, TypeScript, PHP, Python, Go, Ruby, Dart, HTTP ] + + You MUST intelligently map the user's input to one of these valid identifiers. + + - **Handle Aliases & Variations**: + - "C#", "c#", ".NET", "dotnet", "chsarp" (typo) -> Use CSharp + - "TS", "Ts", "ts", "node", "typoscript" (typo) -> Use TypeScript + - "Golang", "Goo" (typo) -> Use Go + - "py", "pyton" (typo), "python3" -> Use Python + - "jav", "Jave" (typo) -> Use Java + + - **Auto-Correction**: + - If the user makes a minor typo or uses a common abbreviation, automatically correct it to the nearest valid identifier from the list above. + + - **Validation**: + - If the input refers to a completely unsupported language (e.g., "Rust", "C++", "Assembly"), STOP and politely inform the user that it is not currently supported by Kiota. + + 2. **Handle Output Path**: + - The `generate_sdk` tool manages the output path internally to create a ZIP file. + - NEVER pass `-o` or `--output` in the `additionalOptions` argument, even if the user asks to save it to a specific location (e.g., "Generate to D:/Work"). + - Instead, follow this workflow: + 1. Call `generate_sdk` WITHOUT the output path option. + 2. Once the tool returns the ZIP file path (or download link), tell the user: "I have generated the SDK. Would you like me to move/extract it to [User's Requested Path]?" + 3. If the user agrees, use your filesystem tools to move the file. + + 3. **Call the Tool**: + Use the `generate_sdk` tool with the normalized language and filtered options (excluding -o). + + 4. **Report**: + Provide the download link or file path returned by the tool. + """; + } +} \ No newline at end of file diff --git a/openapi-to-sdk/src/McpSamples.OpenApiToSdk.HybridApp/Properties/launchSettings.json b/openapi-to-sdk/src/McpSamples.OpenApiToSdk.HybridApp/Properties/launchSettings.json new file mode 100644 index 0000000..69b4af3 --- /dev/null +++ b/openapi-to-sdk/src/McpSamples.OpenApiToSdk.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:5222", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + }, + "https": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": false, + "applicationUrl": "https://localhost:45222;http://localhost:5222", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + } + } +} \ No newline at end of file diff --git a/openapi-to-sdk/src/McpSamples.OpenApiToSdk.HybridApp/Services/IOpenApiService.cs b/openapi-to-sdk/src/McpSamples.OpenApiToSdk.HybridApp/Services/IOpenApiService.cs new file mode 100644 index 0000000..2092aaa --- /dev/null +++ b/openapi-to-sdk/src/McpSamples.OpenApiToSdk.HybridApp/Services/IOpenApiService.cs @@ -0,0 +1,16 @@ +namespace McpSamples.OpenApiToSdk.HybridApp.Services; + +public interface IOpenApiService +{ + /// + /// Generates a client SDK from an OpenAPI specification. + /// + /// The URL or local file path of the OpenAPI spec. + /// The target programming language for the SDK. + /// The name of the generated client class (default: ApiClient). + /// The namespace for the generated code (default: ApiSdk). + /// Additional Kiota command line options. + /// Cancellation token. + /// A message indicating the result path or download URL. + Task GenerateSdkAsync(string specSource, string language, string? clientClassName, string? namespaceName, string? additionalOptions, CancellationToken cancellationToken = default); +} \ No newline at end of file diff --git a/openapi-to-sdk/src/McpSamples.OpenApiToSdk.HybridApp/Services/OpenApiService.cs b/openapi-to-sdk/src/McpSamples.OpenApiToSdk.HybridApp/Services/OpenApiService.cs new file mode 100644 index 0000000..ea2f20a --- /dev/null +++ b/openapi-to-sdk/src/McpSamples.OpenApiToSdk.HybridApp/Services/OpenApiService.cs @@ -0,0 +1,214 @@ +using System.Diagnostics; +using System.IO.Compression; +using McpSamples.OpenApiToSdk.HybridApp.Configurations; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.Logging; + +namespace McpSamples.OpenApiToSdk.HybridApp.Services; + +/// +/// This represents the service for generating client SDKs from OpenAPI specifications. +/// +/// instance. +/// instance. +/// instance. +public class OpenApiService(OpenApiToSdkAppSettings settings, IHttpContextAccessor httpContextAccessor, ILogger logger) : IOpenApiService +{ + /// + public async Task GenerateSdkAsync(string specSource, string language, string? clientClassName, string? namespaceName, string? additionalOptions, CancellationToken cancellationToken = default) + { + if (string.IsNullOrWhiteSpace(specSource)) + throw new ArgumentException("Spec source cannot be empty.", nameof(specSource)); + + var finalClassName = string.IsNullOrWhiteSpace(clientClassName) ? "ApiClient" : clientClassName; + var finalNamespace = string.IsNullOrWhiteSpace(namespaceName) ? "ApiSdk" : namespaceName; + var finalOptions = additionalOptions ?? string.Empty; + + if (finalOptions.Contains("-o ") || finalOptions.Contains("--output ")) + { + return """ + Input Error: Invalid Option Detected + + Please DO NOT include the `-o` or `--output` option in 'additionalOptions'. + The output path is managed automatically by the server. + + If you want to save the file to a specific location, please generate it first, and then move the resulting ZIP file to your desired destination. + """; + } + + string inputPath; + bool isUrl = Uri.TryCreate(specSource, UriKind.Absolute, out var uriResult) + && (uriResult.Scheme == Uri.UriSchemeHttp || uriResult.Scheme == Uri.UriSchemeHttps); + + if (isUrl) + { + inputPath = specSource; + logger.LogInformation("Input is a URL: {InputPath}", inputPath); + } + else + { + if (settings.Runtime.Mode == "Local") + { + inputPath = specSource; + + if (!File.Exists(inputPath)) + { + throw new FileNotFoundException($"Local file not found at: {inputPath}. Please check the path."); + } + } + else + { + string fileName = Path.GetFileName(specSource); + if (fileName.Contains('\\')) + { + fileName = fileName.Split('\\').Last(); + } + + inputPath = Path.Combine(settings.SpecsPath, fileName); + + if (!File.Exists(inputPath)) + { + switch (settings.Runtime.Mode) + { + case "Azure": + string uploadUrl = "/upload"; + var request = httpContextAccessor.HttpContext?.Request; + if (request != null) + { + uploadUrl = $"{request.Scheme}://{request.Host}/upload"; + } + + return $$""" + Action Required: File Upload Needed (Azure) + + I am running in an Azure environment and cannot access your local file `{{specSource}}` directly. + To proceed, you need to upload the file to the server. + + Please execute the appropriate command for your operating system (OS) using your terminal or shell tool: + + ### macOS & Linux + curl -F "file=@{{specSource}}" {{uploadUrl}} + + ### Windows (PowerShell) + Invoke-RestMethod -Uri "{{uploadUrl}}" -Method Post -Form @{ file = Get-Item -Path "{{specSource}}" } + + After the command completes successfully, please call this `generate_sdk` tool again. + """; + case "Container": + string targetHostPath = $"workspace/specs/{fileName}"; + string? hostRootPath = Environment.GetEnvironmentVariable("HOST_ROOT_PATH"); + + if (!string.IsNullOrEmpty(hostRootPath)) + { + string normalizedHostRoot = hostRootPath.Replace('\\', '/').TrimEnd('/'); + targetHostPath = $"{normalizedHostRoot}/workspace/specs/{fileName}"; + } + + return $""" + Action Required: File Synchronization Needed + + The file '{fileName}' is not currently in the shared volume visible to the container. + To proceed with SDK generation, I need access to this file. + + EXECUTION PLAN (Follow Strictly): + 1. COPY the file from `{specSource}` to `{targetHostPath}`. + 2. RETRY the `generate_sdk` tool immediately with the same arguments. + """; + + default: + throw new FileNotFoundException($"File not found in volume: {inputPath}"); + } + } + } + } + + var outputId = Guid.NewGuid().ToString(); + var tempOutputPath = Path.Combine(settings.GeneratedPath, outputId); + + if (!Directory.Exists(tempOutputPath)) + { + Directory.CreateDirectory(tempOutputPath); + } + + var startInfo = new ProcessStartInfo + { + FileName = "kiota", + Arguments = $"generate -l {language} -c {finalClassName} -n {finalNamespace} -d \"{inputPath}\" -o \"{tempOutputPath}\" {finalOptions}", + RedirectStandardOutput = true, + RedirectStandardError = true, + UseShellExecute = false, + CreateNoWindow = true + }; + + using var process = Process.Start(startInfo); + if (process == null) + { + throw new InvalidOperationException("Failed to start Kiota process."); + } + + var stdout = await process.StandardOutput.ReadToEndAsync(cancellationToken); + var stderr = await process.StandardError.ReadToEndAsync(cancellationToken); + await process.WaitForExitAsync(cancellationToken); + + if (process.ExitCode != 0) + { + logger.LogError("Kiota failed: {StdErr}", stderr); + return $"[Error] Kiota generation failed:\n{stderr}\n{stdout}"; + } + + string zipFileName = $"sdk-{language}-{outputId.Substring(0, 8)}.zip"; + string localZipPath = Path.Combine(settings.GeneratedPath, zipFileName); + + ZipFile.CreateFromDirectory(tempOutputPath, localZipPath); + + try + { + Directory.Delete(tempOutputPath, true); + } + catch + { + } + + if (settings.IsHttpMode) + { + string relativePath = $"/download/{zipFileName}"; + string downloadUrl; + + var request = httpContextAccessor.HttpContext?.Request; + + if (request != null) + { + string baseUrl = $"{request.Scheme}://{request.Host}"; + downloadUrl = $"{baseUrl}{relativePath}"; + } + else + { + downloadUrl = relativePath; + } + + return $"SDK Generation Successful!\n" + + $"Download Link: {downloadUrl}"; + } + else + { + string finalPath = localZipPath; + + if (settings.Runtime.Mode == "Container") + { + string? hostRootPath = Environment.GetEnvironmentVariable("HOST_ROOT_PATH"); + + if (!string.IsNullOrEmpty(hostRootPath)) + { + string relativePathFromApp = finalPath.Substring("/app".Length).TrimStart('/'); + string hostPathNormalized = hostRootPath.TrimEnd('/', '\\'); + + finalPath = $"{hostPathNormalized}/{relativePathFromApp}"; + } + } + return $"SDK Generation Successful!\n" + + $"File Saved At: {localZipPath}\n" + + $"The file is currently in the workspace. Please check if this location is correct.\n" + + $"If the user wants the file elsewhere, please move it to the desired destination."; + } + } +} \ No newline at end of file diff --git a/openapi-to-sdk/src/McpSamples.OpenApiToSdk.HybridApp/Tools/OpenApiToSdkTool.cs b/openapi-to-sdk/src/McpSamples.OpenApiToSdk.HybridApp/Tools/OpenApiToSdkTool.cs new file mode 100644 index 0000000..9918bdc --- /dev/null +++ b/openapi-to-sdk/src/McpSamples.OpenApiToSdk.HybridApp/Tools/OpenApiToSdkTool.cs @@ -0,0 +1,58 @@ +using System.ComponentModel; +using McpSamples.OpenApiToSdk.HybridApp.Services; +using ModelContextProtocol.Server; + +namespace McpSamples.OpenApiToSdk.HybridApp.Tools; + +/// +/// Defines the interface for the OpenAPI to SDK tool. +/// +public interface IOpenApiToSdkTool +{ + /// + /// Generates a client SDK from an OpenAPI specification. + /// + Task GenerateSdkAsync( + string specSource, + string language, + string? clientClassName = null, + string? namespaceName = null, + string? additionalOptions = null); +} + +/// +/// Represents the tool for generating client SDKs from OpenAPI specifications. +/// +/// instance. +[McpServerToolType] +public class OpenApiToSdkTool(IOpenApiService service) : IOpenApiToSdkTool +{ + /// + [McpServerTool(Name = "generate_sdk", Title = "Generates a client SDK")] + [Description("Generates a client SDK from an OpenAPI specification URL or local file path.")] + public async Task GenerateSdkAsync( + [Description("The URL or local file path of the OpenAPI specification.")] + string specSource, + + [Description("The target programming language (e.g., CSharp, Python, Java, TypeScript).")] + string language, + + [Description("The name of the generated client class. Default is 'ApiClient'.")] + string? clientClassName = null, + + [Description("The namespace for the generated code. Default is 'ApiSdk'.")] + string? namespaceName = null, + + [Description("Additional Kiota command line options (e.g., --version).")] + string? additionalOptions = null) + { + var resultMessage = await service.GenerateSdkAsync( + specSource, + language, + clientClassName, + namespaceName, + additionalOptions); + + return resultMessage; + } +} \ No newline at end of file diff --git a/openapi-to-sdk/src/McpSamples.OpenApiToSdk.HybridApp/appsettings.Development.json b/openapi-to-sdk/src/McpSamples.OpenApiToSdk.HybridApp/appsettings.Development.json new file mode 100644 index 0000000..0c208ae --- /dev/null +++ b/openapi-to-sdk/src/McpSamples.OpenApiToSdk.HybridApp/appsettings.Development.json @@ -0,0 +1,8 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + } +} diff --git a/openapi-to-sdk/src/McpSamples.OpenApiToSdk.HybridApp/appsettings.json b/openapi-to-sdk/src/McpSamples.OpenApiToSdk.HybridApp/appsettings.json new file mode 100644 index 0000000..62963f5 --- /dev/null +++ b/openapi-to-sdk/src/McpSamples.OpenApiToSdk.HybridApp/appsettings.json @@ -0,0 +1,13 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + }, + "AllowedHosts": "*", + "UseHttp": false, + "Runtime": { + "Mode": "Local" + } +} \ No newline at end of file diff --git a/openapi-to-sdk/src/McpSamples.OpenApiToSdk.HybridApp/wwwroot/.gitkeep b/openapi-to-sdk/src/McpSamples.OpenApiToSdk.HybridApp/wwwroot/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/openapi-to-sdk/workspace/generated/.gitkeep b/openapi-to-sdk/workspace/generated/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/openapi-to-sdk/workspace/specs/.gitkeep b/openapi-to-sdk/workspace/specs/.gitkeep new file mode 100644 index 0000000..e69de29