Compare commits

...

2 Commits

Author SHA1 Message Date
Aaron Powell
85cca28ad7 updating .net version for CI 2025-09-10 17:15:00 +10:00
Aaron Powell
c030f3cc89 Bringing the MCP server into the repo 2025-09-10 17:11:55 +10:00
39 changed files with 4513 additions and 1 deletions

3
.aspire/settings.json Normal file
View File

@ -0,0 +1,3 @@
{
"appHostPath": "../mcp-server/src/AwesomeCopilot.AppHost/AwesomeCopilot.AppHost.csproj"
}

63
.github/workflows/docker-image.yml vendored Normal file
View File

@ -0,0 +1,63 @@
name: CI - Build and push Docker images
on:
push:
branches: [main]
pull_request:
branches: [main]
permissions:
contents: read
packages: write
id-token: write
jobs:
build:
runs-on: ubuntu-latest
env:
IMAGE_NAME: ghcr.io/${{ github.repository_owner }}/awesome-copilot-mcp-server
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup .NET
uses: actions/setup-dotnet@v4
with:
dotnet-version: "10.0.x"
- name: Restore
working-directory: ./mcp-server
run: dotnet restore src/AwesomeCopilot.McpServer/AwesomeCopilot.McpServer.csproj
- name: Run unit tests (if any)
working-directory: ./mcp-server
run: |
if [ -f "src/AwesomeCopilot.McpServer/Tests.csproj" ]; then
dotnet test --no-build --verbosity normal
else
echo "No tests found, skipping"
fi
- name: Login to GHCR
uses: docker/login-action@v2
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Build and push image
uses: docker/build-push-action@v4
with:
context: ./mcp-server
file: ./mcp-server/Dockerfile
platforms: linux/amd64
push: true
tags: |
${{ env.IMAGE_NAME }}:latest
${{ env.IMAGE_NAME }}:${{ github.sha }}
build-args: |
RUNTIME=linux-x64
- name: Image cleanup
run: docker image prune -f || true

View File

@ -22,5 +22,7 @@
"*.chatmode.md": "markdown", "*.chatmode.md": "markdown",
"*.instructions.md": "markdown", "*.instructions.md": "markdown",
"*.prompt.md": "markdown" "*.prompt.md": "markdown"
} },
"python-envs.defaultEnvManager": "ms-python.python:system",
"python-envs.pythonProjects": []
} }

32
mcp-server/.dockerignore Normal file
View File

@ -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

10
mcp-server/.gitignore vendored Normal file
View File

@ -0,0 +1,10 @@
src/awesome-copilot/
!.vscode/mcp.json
.azure
.vs
bin
obj
*.user
*.suo
appsettings.*.json

View File

@ -0,0 +1,12 @@
<Solution>
<Configurations>
<Platform Name="Any CPU" />
<Platform Name="x64" />
<Platform Name="x86" />
</Configurations>
<Folder Name="/src/">
<Project Path="src/AwesomeCopilot.AppHost/AwesomeCopilot.AppHost.csproj" />
<Project Path="src/AwesomeCopilot.McpServer/AwesomeCopilot.McpServer.csproj" />
<Project Path="src/AwesomeCopilot.ServiceDefaults/AwesomeCopilot.ServiceDefaults.csproj" />
</Folder>
</Solution>

21
mcp-server/Dockerfile Normal file
View File

@ -0,0 +1,21 @@
# syntax=docker/dockerfile:1
FROM mcr.microsoft.com/dotnet/sdk:10.0-noble-aot AS build
WORKDIR /src
# Copy everything and restore/publish
COPY . .
ARG CONFIG=Release
ARG RUNTIME=linux-x64
RUN dotnet restore src/AwesomeCopilot.McpServer/AwesomeCopilot.McpServer.csproj
# Publish: if PUBLISH_AOT=true, enable Native AOT publish flags
RUN dotnet publish src/AwesomeCopilot.McpServer -c $CONFIG -r $RUNTIME -p:PublishAot=true -p:PublishTrimmed=true -p:TrimMode=link -p:PublishSingleFile=true -o /app/publish;
FROM mcr.microsoft.com/dotnet/runtime-deps:10.0-noble-chiseled AS release
WORKDIR /app
COPY --from=build /app/publish/ .
EXPOSE 8080
ENTRYPOINT ["/app/AwesomeCopilot.McpServer"]

17
mcp-server/NuGet.config Normal file
View File

@ -0,0 +1,17 @@
<?xml version="1.0" encoding="utf-8"?>
<configuration>
<packageSources>
<clear />
<add key="https://pkgs.dev.azure.com/dnceng/public/_packaging/dotnet9/nuget/v3/index.json" value="https://pkgs.dev.azure.com/dnceng/public/_packaging/dotnet9/nuget/v3/index.json" />
<add key="https://api.nuget.org/v3/index.json" value="https://api.nuget.org/v3/index.json" />
</packageSources>
<packageSourceMapping>
<packageSource key="https://pkgs.dev.azure.com/dnceng/public/_packaging/dotnet9/nuget/v3/index.json">
<package pattern="Aspire*" />
<package pattern="Microsoft.Extensions.ServiceDiscovery*" />
</packageSource>
<packageSource key="https://api.nuget.org/v3/index.json">
<package pattern="*" />
</packageSource>
</packageSourceMapping>
</configuration>

139
mcp-server/README.md Normal file
View File

@ -0,0 +1,139 @@
# MCP Server: Awesome Copilot
This is an MCP server that retrieves GitHub Copilot customizations from the [awesome-copilot](https://github.com/github/awesome-copilot) repository.
## Install
[![Install in VS Code](https://img.shields.io/badge/VS_Code-Install-0098FF?style=flat-square&logo=visualstudiocode&logoColor=white)](https://aka.ms/awesome-copilot/mcp/vscode) [![Install in VS Code Insiders](https://img.shields.io/badge/VS_Code_Insiders-Install-24bfa5?style=flat-square&logo=visualstudiocode&logoColor=white)](https://aka.ms/awesome-copilot/mcp/vscode-insiders)
## Prerequisites
- [.NET 10 SDK](https://dotnet.microsoft.com/download/dotnet/10.0)
- Aspire CLI nightly: `iex "& { $(irm https://aspire.dev/install.ps1) } -Quality dev"`
- [Visual Studio Code](https://code.visualstudio.com/) with
- [C# Dev Kit](https://marketplace.visualstudio.com/items/?itemName=ms-dotnettools.csdevkit) extension
- [Azure Developer CLI](https://learn.microsoft.com/azure/developer/azure-developer-cli/install-azd)
- [Docker Desktop](https://docs.docker.com/get-started/get-docker/)
## What's Included
Awesome Copilot MCP server includes:
| Building Block | Name | Description | Usage |
| -------------- | --------------------- | --------------------------------------------------------------------- | ---------------------------------------- |
| Tools | `search_instructions` | Searches custom instructions based on keywords in their descriptions. | `#search_instructions` |
| Tools | `load_instruction` | Loads a custom instruction from the repository. | `#load_instruction` |
| Prompts | `get_search_prompt` | Get a prompt for searching copilot instructions. | `/mcp.awesome-copilot.get_search_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)
- [VS Code + Agent Mode + Local MCP server](#vs-code--agent-mode--local-mcp-server)
### Running MCP server
#### On a local machine
1. Run the MCP server app using Aspire.
```bash
aspire run
```
Once running, the Aspire dashboard will be loaded in your default web browser, or you can click the URL provided in the terminal. From here, you'll have access to the MCP server endpoint, logs and metrics.
#### In a container
1. Build the MCP server app as a container image.
```bash
cd mcp-server
docker build -f Dockerfile -t awesome-copilot:latest .
```
1. Run the MCP server app in a container.
```bash
docker run -i --rm -p 8080:8080 awesome-copilot:latest
```
Alternatively, use the container image from the container registry.
```bash
docker run -i --rm -p 8080:8080 ghcr.io/github/awesome-copilot:latest
```
#### On Azure
1. Navigate to the directory.
```bash
cd mcp-server
```
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_AWESOME_COPILOT_FQDN
```
### Connect MCP server to an MCP host/client
#### Install the MCP server:
[![Install in VS Code](https://img.shields.io/badge/VS_Code-Install-0098FF?style=flat-square&logo=visualstudiocode&logoColor=white)](https://aka.ms/awesome-copilot/mcp/vscode) [![Install in VS Code Insiders](https://img.shields.io/badge/VS_Code_Insiders-Install-24bfa5?style=flat-square&logo=visualstudiocode&logoColor=white)](https://aka.ms/awesome-copilot/mcp/vscode-insiders)
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 `awesome-copilot` then click `Start Server`.
1. When prompted, enter one of the following values:
- The absolute directory path of the `AwesomeCopilot.McpServer` project
- The FQDN of Azure Container Apps.
1. Use a prompt by typing `/mcp.awesome-copilot.get_search_prompt` and enter keywords to search. You'll get a prompt like:
```text
Please search all the chatmodes, instructions and prompts that are related to the search keyword, `{keyword}`.
Here's the process to follow:
1. Use the `awesome-copilot` MCP server.
1. Search all chatmodes, instructions, and prompts for the keyword provided.
1. DO NOT load any chatmodes, instructions, or prompts from the MCP server until the user asks to do so.
1. Scan local chatmodes, instructions, and prompts markdown files in `.github/chatmodes`, `.github/instructions`, and `.github/prompts` directories respectively.
1. Compare existing chatmodes, instructions, and prompts with the search results.
1. Provide a structured response in a table format that includes the already exists, mode (chatmodes, instructions or prompts), filename, title and description of each item found. Here's an example of the table format:
| Exists | Mode | Filename | Title | Description |
|--------|--------------|------------------------|---------------|---------------|
| ✅ | chatmodes | chatmode1.json | ChatMode 1 | Description 1 |
| ❌ | instructions | instruction1.json | Instruction 1 | Description 1 |
| ✅ | prompts | prompt1.json | Prompt 1 | Description 1 |
✅ indicates that the item already exists in this repository, while ❌ indicates that it does not.
1. If any item doesn't exist in the repository, ask which item the user wants to save.
1. If the user wants to save it, save the item in the appropriate directory (`.github/chatmodes`, `.github/instructions`, or `.github/prompts`) using the mode and filename, with NO modification.
```
1. Confirm the result.

16
mcp-server/azure.yaml Normal file
View File

@ -0,0 +1,16 @@
# yaml-language-server: $schema=https://raw.githubusercontent.com/Azure/azure-dev/main/schemas/v1.0/azure.yaml.json
name: awesome-copilot
metadata:
template: azd-init@1.14.0
services:
awesome-copilot:
project: src/AwesomeCopilot.McpServer
host: containerapp
language: dotnet
docker:
path: ../../../Dockerfile.awesome-copilot-azure
context: ../../../
remoteBuild: true

View File

@ -0,0 +1,121 @@
{
"$schema": "http://json-schema.org/draft-07/schema#",
"title": "Frontmatter Schema",
"description": "Schema for validating frontmatter data in the awesome-copilot repository",
"type": "object",
"properties": {
"chatmodes": {
"type": "array",
"description": "Array of chat mode configurations",
"items": {
"$ref": "#/definitions/chatmode"
}
},
"instructions": {
"type": "array",
"description": "Array of instruction file configurations",
"items": {
"$ref": "#/definitions/instruction"
}
},
"prompts": {
"type": "array",
"description": "Array of prompt file configurations",
"items": {
"$ref": "#/definitions/prompt"
}
}
},
"additionalProperties": false,
"definitions": {
"chatmode": {
"type": "object",
"description": "Configuration for a chat mode",
"properties": {
"filename": {
"type": "string",
"description": "Name of the chat mode file",
"pattern": "^[a-zA-Z0-9._-]+\\.chatmode\\.md$"
},
"title": {
"type": "string",
"description": "Display title for the chat mode",
"minLength": 1
},
"description": {
"type": "string",
"description": "Description of the chat mode functionality",
"minLength": 1
},
"model": {
"type": "string",
"description": "AI model to use for this chat mode"
},
"tools": {
"type": "array",
"description": "Array of available tools for this chat mode",
"items": {
"type": "string"
}
}
},
"required": ["filename", "description"],
"additionalProperties": false
},
"instruction": {
"type": "object",
"description": "Configuration for an instruction file",
"properties": {
"filename": {
"type": "string",
"description": "Name of the instruction file",
"pattern": "^[a-zA-Z0-9._-]+\\.instructions\\.md$"
},
"description": {
"type": "string",
"description": "Description of the instruction file purpose",
"minLength": 1
},
"applyTo": {
"type": "array",
"description": "File patterns that this instruction applies to",
"items": {
"type": "string",
"minLength": 1
}
}
},
"required": ["filename", "description"],
"additionalProperties": false
},
"prompt": {
"type": "object",
"description": "Configuration for a prompt file",
"properties": {
"filename": {
"type": "string",
"description": "Name of the prompt file",
"pattern": "^[a-zA-Z0-9._-]+\\.prompt\\.md$"
},
"description": {
"type": "string",
"description": "Description of the prompt functionality",
"minLength": 1
},
"mode": {
"type": "string",
"description": "Execution mode for the prompt"
},
"tools": {
"type": "array",
"description": "Array of available tools for this prompt",
"items": {
"type": "string"
}
}
},
"required": ["filename", "description"],
"additionalProperties": false
}
}
}

View File

@ -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-"
}

View File

@ -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 mcpAwesomeCopilotExists 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': <service name in azure.yaml> })
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
mcpAwesomeCopilotExists: mcpAwesomeCopilotExists
}
}
output AZURE_CONTAINER_REGISTRY_ENDPOINT string = resources.outputs.AZURE_CONTAINER_REGISTRY_ENDPOINT
output AZURE_RESOURCE_MCP_AWESOME_COPILOT_ID string = resources.outputs.AZURE_RESOURCE_MCP_AWESOME_COPILOT_ID
output AZURE_RESOURCE_MCP_AWESOME_COPILOT_NAME string = resources.outputs.AZURE_RESOURCE_MCP_AWESOME_COPILOT_NAME
output AZURE_RESOURCE_MCP_AWESOME_COPILOT_FQDN string = resources.outputs.AZURE_RESOURCE_MCP_AWESOME_COPILOT_FQDN

View File

@ -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}"
},
"mcpAwesomeCopilotExists": {
"value": "${SERVICE_AWESOME_COPILOT_RESOURCE_EXISTS=false}"
},
"principalId": {
"value": "${AZURE_PRINCIPAL_ID}"
}
}
}

View File

@ -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 : []

View File

@ -0,0 +1,144 @@
@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 mcpAwesomeCopilotExists 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)
// 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: mcpAwesomeCopilotIdentity.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
}
}
// User assigned identity
module mcpAwesomeCopilotIdentity 'br/public:avm/res/managed-identity/user-assigned-identity:0.2.1' = {
name: 'mcpAwesomeCopilotIdentity'
params: {
name: '${abbrs.managedIdentityUserAssignedIdentities}mcpawesomecopilot-${resourceToken}'
location: location
}
}
// Azure Container Apps
module mcpAwesomeCopilotFetchLatestImage './modules/fetch-container-image.bicep' = {
name: 'mcpAwesomeCopilot-fetch-image'
params: {
exists: mcpAwesomeCopilotExists
name: 'awesome-copilot'
}
}
module mcpAwesomeCopilot 'br/public:avm/res/app/container-app:0.8.0' = {
name: 'mcpAwesomeCopilot'
params: {
name: 'awesome-copilot'
ingressTargetPort: 8080
scaleMinReplicas: 1
scaleMaxReplicas: 10
secrets: {
secureList: [
]
}
containers: [
{
image: mcpAwesomeCopilotFetchLatestImage.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: mcpAwesomeCopilotIdentity.outputs.clientId
}
{
name: 'PORT'
value: '8080'
}
]
args: [
'--http'
]
}
]
managedIdentities: {
systemAssigned: false
userAssignedResourceIds: [
mcpAwesomeCopilotIdentity.outputs.resourceId
]
}
registries: [
{
server: containerRegistry.outputs.loginServer
identity: mcpAwesomeCopilotIdentity.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': 'awesome-copilot' })
}
}
output AZURE_CONTAINER_REGISTRY_ENDPOINT string = containerRegistry.outputs.loginServer
output AZURE_RESOURCE_MCP_AWESOME_COPILOT_ID string = mcpAwesomeCopilot.outputs.resourceId
output AZURE_RESOURCE_MCP_AWESOME_COPILOT_NAME string = mcpAwesomeCopilot.outputs.name
output AZURE_RESOURCE_MCP_AWESOME_COPILOT_FQDN string = mcpAwesomeCopilot.outputs.fqdn

View File

@ -0,0 +1,9 @@
var builder = DistributedApplication.CreateBuilder(args);
var mcpServer = builder.AddProject<Projects.AwesomeCopilot_McpServer>("mcp-server");
builder.AddMcpInspector("mcp-inspector")
.WithMcpServer(mcpServer)
.WithEnvironment("NODE_OPTIONS", "--use-system-ca");
builder.Build().Run();

View File

@ -0,0 +1,22 @@
<Project Sdk="Microsoft.NET.Sdk">
<Sdk Name="Aspire.AppHost.Sdk" Version="9.5.0-preview.1.25459.5" />
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<UserSecretsId>801a20cd-64b4-4a88-89e2-948b802e2f50</UserSecretsId>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Aspire.Hosting.AppHost" Version="9.5.0-preview.1.25459.5" />
<PackageReference Include="CommunityToolkit.Aspire.Hosting.McpInspector" Version="9.8.0-beta.376" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\AwesomeCopilot.McpServer\AwesomeCopilot.McpServer.csproj" />
</ItemGroup>
</Project>

View File

@ -0,0 +1,29 @@
{
"$schema": "https://json.schemastore.org/launchsettings.json",
"profiles": {
"https": {
"commandName": "Project",
"dotnetRunMessages": true,
"launchBrowser": true,
"applicationUrl": "https://localhost:17128;http://localhost:15190",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development",
"DOTNET_ENVIRONMENT": "Development",
"ASPIRE_DASHBOARD_OTLP_ENDPOINT_URL": "https://localhost:21005",
"ASPIRE_RESOURCE_SERVICE_ENDPOINT_URL": "https://localhost:22267"
}
},
"http": {
"commandName": "Project",
"dotnetRunMessages": true,
"launchBrowser": true,
"applicationUrl": "http://localhost:15190",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development",
"DOTNET_ENVIRONMENT": "Development",
"ASPIRE_DASHBOARD_OTLP_ENDPOINT_URL": "http://localhost:19096",
"ASPIRE_RESOURCE_SERVICE_ENDPOINT_URL": "http://localhost:20175"
}
}
}
}

View File

@ -0,0 +1,9 @@
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning",
"Aspire.Hosting.Dcp": "Warning"
}
}
}

View File

@ -0,0 +1,27 @@
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<LangVersion>latest</LangVersion>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<UserSecretsId>3b9db7fd-b2cb-4b92-81b7-f3865823ef0a</UserSecretsId>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="ModelContextProtocol.AspNetCore" Version="0.3.0-preview.4" />
</ItemGroup>
<ItemGroup>
<None Update="metadata.json">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</None>
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\AwesomeCopilot.ServiceDefaults\AwesomeCopilot.ServiceDefaults.csproj" />
</ItemGroup>
</Project>

View File

@ -0,0 +1,17 @@
using System.Text.Json.Serialization;
namespace AwesomeCopilot.McpServer.Json
{
// Source-generated JsonSerializerContext to provide JsonTypeInfo metadata for AOT trimming
[JsonSourceGenerationOptions(PropertyNameCaseInsensitive = true)]
[JsonSerializable(typeof(Models.Metadata))]
[JsonSerializable(typeof(Models.ChatMode))]
[JsonSerializable(typeof(Models.Instruction))]
[JsonSerializable(typeof(Models.Prompt))]
[JsonSerializable(typeof(Models.MetadataResult))]
[JsonSerializable(typeof(Tools.InstructionMode))]
public partial class SourceGenerationContext : JsonSerializerContext
{
// The source generator will provide the Default instance and JsonTypeInfo data.
}
}

View File

@ -0,0 +1,35 @@
using AwesomeCopilot.McpServer.Prompts;
using AwesomeCopilot.McpServer.Services;
using AwesomeCopilot.McpServer.Tools;
using System.Text.Json;
var builder = WebApplication.CreateBuilder(args);
builder.AddServiceDefaults();
var options = new JsonSerializerOptions
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
WriteIndented = true,
AllowTrailingCommas = true,
PropertyNameCaseInsensitive = true,
TypeInfoResolver = AwesomeCopilot.McpServer.Json.SourceGenerationContext.Default
};
builder.Services.AddSingleton(options);
builder.Services.AddHttpClient<IMetadataService, MetadataService>();
builder.Services.AddMcpServer()
.WithHttpTransport(o => o.Stateless = true)
.WithPrompts<MetadataPrompt>(options)
.WithTools<MetadataTool>(options);
var app = builder.Build();
app.MapDefaultEndpoints();
app.UseHttpsRedirection();
app.MapMcp("/mcp");
await app.RunAsync();

View File

@ -0,0 +1,32 @@
namespace AwesomeCopilot.McpServer.Models;
/// <summary>
/// This represents the data entity for a chat mode.
/// </summary>
public class ChatMode
{
/// <summary>
/// Gets or sets the name of the chat mode file.
/// </summary>
public required string Filename { get; set; }
/// <summary>
/// Gets or sets the title.
/// </summary>
public string? Title { get; set; }
/// <summary>
/// Gets or sets the description.
/// </summary>
public required string Description { get; set; }
/// <summary>
/// Gets or sets the AI model.
/// </summary>
public string? Model { get; set; }
/// <summary>
/// Gets or sets the list of tools.
/// </summary>
public List<string> Tools { get; set; } = [];
}

View File

@ -0,0 +1,27 @@
namespace AwesomeCopilot.McpServer.Models;
/// <summary>
/// This represents the data entity for an instruction.
/// </summary>
public class Instruction
{
/// <summary>
/// Gets or sets the name of the instruction file.
/// </summary>
public required string Filename { get; set; }
/// <summary>
/// Gets or sets the title.
/// </summary>
public string? Title { get; set; }
/// <summary>
/// Gets or sets the description.
/// </summary>
public required string Description { get; set; }
/// <summary>
/// Gets or sets the file patterns that this instruction applies to.
/// </summary>
public List<string> ApplyTo { get; set; } = [];
}

View File

@ -0,0 +1,25 @@
using System.Text.Json.Serialization;
namespace AwesomeCopilot.McpServer.Models;
/// <summary>
/// This represents the data entity for metadata.json.
/// </summary>
public class Metadata
{
/// <summary>
/// Gets or sets the list of <see cref="ChatMode"/> objects.
/// </summary>
[JsonPropertyName("chatmodes")]
public List<ChatMode> ChatModes { get; set; } = [];
/// <summary>
/// Gets or sets the list of <see cref="Instruction"/> objects.
/// </summary>
public List<Instruction> Instructions { get; set; } = [];
/// <summary>
/// Gets or sets the list of <see cref="Prompt"/> objects.
/// </summary>
public List<Prompt> Prompts { get; set; } = [];
}

View File

@ -0,0 +1,17 @@
namespace AwesomeCopilot.McpServer.Models;
/// <summary>
/// This represents the result entity to retrieve metadata.
/// </summary>
public class MetadataResult
{
/// <summary>
/// Gets or sets the <see cref="Metadata"/> object.
/// </summary>
public Metadata? Metadata { get; set; }
/// <summary>
/// Gets or sets the error message if any error occurs.
/// </summary>
public string? ErrorMessage { get; set; }
}

View File

@ -0,0 +1,27 @@
namespace AwesomeCopilot.McpServer.Models;
/// <summary>
/// This represents the data entity for a prompt.
/// </summary>
public class Prompt
{
/// <summary>
/// Gets or sets the name of the prompt file.
/// </summary>
public required string Filename { get; set; }
/// <summary>
/// Gets or sets the description.
/// </summary>
public required string Description { get; set; }
/// <summary>
/// Gets or sets the execution mode.
/// </summary>
public string? Mode { get; set; }
/// <summary>
/// Gets or sets the list of tools.
/// </summary>
public List<string>? Tools { get; set; }
}

View File

@ -0,0 +1,58 @@
using System.ComponentModel;
using ModelContextProtocol.Server;
namespace AwesomeCopilot.McpServer.Prompts;
/// <summary>
/// This provides interfaces for metadata prompts.
/// </summary>
public interface IMetadataPrompt
{
/// <summary>
/// Gets a prompt for searching copilot instructions.
/// </summary>
/// <param name="keyword">The keyword to search for.</param>
/// <returns>A formatted search prompt.</returns>
string GetSearchPrompt(string keyword);
}
/// <summary>
/// This represents the prompts entity for the awesome-copilot repository.
/// </summary>
[McpServerPromptType]
public class MetadataPrompt : IMetadataPrompt
{
/// <inheritdoc />
[McpServerPrompt(Name = "get_search_prompt", Title = "Prompt for searching copilot instructions")]
[Description("Get a prompt for searching copilot instructions.")]
public string GetSearchPrompt(
[Description("The keyword to search for")] string keyword)
{
return $"""
Please search all the chatmodes, instructions and prompts that are related to the search keyword, `{keyword}`.
Here's the process to follow:
1. Use the `awesome-copilot` MCP server.
1. Search all chatmodes, instructions, and prompts for the keyword provided.
1. DO NOT load any chatmodes, instructions, or prompts from the MCP server until the user asks to do so.
1. Scan local chatmodes, instructions, and prompts markdown files in `.github/chatmodes`, `.github/instructions`, and `.github/prompts` directories respectively.
1. Compare existing chatmodes, instructions, and prompts with the search results.
1. Provide a structured response in a table format that includes the already exists, mode (chatmodes, instructions or prompts), filename, title and description of each item found.
Here's an example of the table format:
| Exists | Mode | Filename | Title | Description |
|--------|--------------|------------------------|---------------|---------------|
| | chatmodes | chatmode1.json | ChatMode 1 | Description 1 |
| | instructions | instruction1.json | Instruction 1 | Description 1 |
| | prompts | prompt1.json | Prompt 1 | Description 1 |
indicates that the item already exists in this repository, while indicates that it does not.
1. If any item doesn't exist in the repository, ask which item the user wants to save.
1. If the user wants to save it, save the item in the appropriate directory (`.github/chatmodes`, `.github/instructions`, or `.github/prompts`)
using the mode and filename, with NO modification.
""";
}
}

View File

@ -0,0 +1,23 @@
{
"$schema": "https://json.schemastore.org/launchsettings.json",
"profiles": {
"http": {
"commandName": "Project",
"dotnetRunMessages": true,
"launchBrowser": false,
"applicationUrl": "http://localhost:5250",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
}
},
"https": {
"commandName": "Project",
"dotnetRunMessages": true,
"launchBrowser": false,
"applicationUrl": "https://localhost:45250;http://localhost:5250",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
}
}
}
}

View File

@ -0,0 +1,26 @@
using AwesomeCopilot.McpServer.Models;
namespace AwesomeCopilot.McpServer.Services;
/// <summary>
/// This provides interfaces for metadata service operations.
/// </summary>
public interface IMetadataService
{
/// <summary>
/// Searches for relevant data in chatmodes, instructions, and prompts based on keywords in their description fields
/// </summary>
/// <param name="keywords">The keywords to search for</param>
/// <param name="cancellationToken">Cancellation token</param>
/// <returns>Returns <see cref="Metadata"/> object containing all matching search results</returns>
Task<Metadata> SearchAsync(string keywords, CancellationToken cancellationToken = default);
/// <summary>
/// Loads file contents from the awesome-copilot repository
/// </summary>
/// <param name="directory">The mode directory (chatmodes, instructions, or prompts)</param>
/// <param name="filename">The filename to load</param>
/// <param name="cancellationToken">Cancellation token</param>
/// <returns>Returns the file contents as a string</returns>
Task<string> LoadAsync(string directory, string filename, CancellationToken cancellationToken = default);
}

View File

@ -0,0 +1,112 @@
using System.Text.Json;
using AwesomeCopilot.McpServer.Json;
using AwesomeCopilot.McpServer.Models;
namespace AwesomeCopilot.McpServer.Services;
/// <summary>
/// This represents the service entity for searching and loading custom instructions from the awesome-copilot repository.
/// </summary>
public class MetadataService(HttpClient http, JsonSerializerOptions options, ILogger<MetadataService> logger) : IMetadataService
{
private const string MetadataFileName = "metadata.json";
private const string AwesomeCopilotFileUrl = "https://raw.githubusercontent.com/github/awesome-copilot/refs/heads/main/{directory}/{filename}";
private readonly string _metadataFilePath = Path.Combine(AppContext.BaseDirectory, MetadataFileName);
private Metadata? _cachedMetadata;
/// <inheritdoc />
public async Task<Metadata> SearchAsync(string keywords, CancellationToken cancellationToken = default)
{
if (string.IsNullOrWhiteSpace(keywords) == true)
{
return new Metadata();
}
var metadata = await GetMetadataAsync(cancellationToken).ConfigureAwait(false);
var searchTerms = keywords.Split(' ', StringSplitOptions.RemoveEmptyEntries)
.Select(term => term.Trim().ToLowerInvariant())
.Where(term => string.IsNullOrWhiteSpace(term) != true)
.ToArray();
logger.LogInformation("Search terms: {terms}", string.Join(", ", searchTerms));
var result = new Metadata
{
// Search in ChatModes
ChatModes = [.. metadata.ChatModes.Where(cm => ContainsAnyKeyword(cm.Title, searchTerms) == true ||
ContainsAnyKeyword(cm.Description, searchTerms) == true)],
// Search in Instructions
Instructions = [.. metadata.Instructions.Where(inst => ContainsAnyKeyword(inst.Title, searchTerms) == true ||
ContainsAnyKeyword(inst.Description, searchTerms) == true)],
// Search in Prompts
Prompts = [.. metadata.Prompts.Where(prompt => ContainsAnyKeyword(prompt.Description, searchTerms) == true)]
};
return result;
}
/// <inheritdoc />
public async Task<string> LoadAsync(string directory, string filename, CancellationToken cancellationToken = default)
{
if (string.IsNullOrWhiteSpace(directory) == true)
{
throw new ArgumentException("Directory cannot be null or empty", nameof(directory));
}
if (string.IsNullOrWhiteSpace(filename) == true)
{
throw new ArgumentException("Filename cannot be null or empty", nameof(filename));
}
var url = AwesomeCopilotFileUrl.Replace("{directory}", directory).Replace("{filename}", filename);
try
{
var response = await http.GetAsync(url, cancellationToken).ConfigureAwait(false);
response.EnsureSuccessStatusCode();
var content = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
logger.LogInformation("Loaded content from {url}", url);
return content;
}
catch (HttpRequestException ex)
{
throw new InvalidOperationException($"Failed to load file '{filename}' from directory '{directory}': {ex.Message}", ex);
}
}
private async Task<Metadata> GetMetadataAsync(CancellationToken cancellationToken)
{
if (_cachedMetadata != null)
{
return _cachedMetadata;
}
if (File.Exists(_metadataFilePath) != true)
{
throw new FileNotFoundException($"Metadata file not found at: {_metadataFilePath}");
}
var json = await File.ReadAllTextAsync(_metadataFilePath, cancellationToken).ConfigureAwait(false);
_cachedMetadata = JsonSerializer.Deserialize<Metadata>(json, options)
?? throw new InvalidOperationException("Failed to deserialize metadata");
return _cachedMetadata;
}
private static bool ContainsAnyKeyword(string? text, string[] searchTerms)
{
if (string.IsNullOrWhiteSpace(text))
{
return false;
}
var result = searchTerms.Any(term => text.Contains(term, StringComparison.InvariantCultureIgnoreCase) == true);
return result;
}
}

View File

@ -0,0 +1,19 @@
using System.Text.Json.Serialization;
namespace AwesomeCopilot.McpServer.Tools;
[JsonConverter(typeof(JsonStringEnumConverter<InstructionMode>))]
public enum InstructionMode
{
[JsonStringEnumMemberName("undefined")]
Undefined,
[JsonStringEnumMemberName("chatmodes")]
ChatModes,
[JsonStringEnumMemberName("instructions")]
Instructions,
[JsonStringEnumMemberName("prompts")]
Prompts
}

View File

@ -0,0 +1,89 @@
using System.ComponentModel;
using AwesomeCopilot.McpServer.Models;
using AwesomeCopilot.McpServer.Services;
using ModelContextProtocol.Server;
namespace AwesomeCopilot.McpServer.Tools;
/// <summary>
/// This provides interfaces for metadata tool operations.
/// </summary>
public interface IMetadataTool
{
/// <summary>
/// Searches custom instructions based on keywords in their titles and descriptions.
/// </summary>
/// <param name="keywords">The keyword to search for</param>
/// <returns>A <see cref="MetadataResult"/> containing the search results.</returns>
Task<MetadataResult> SearchAsync(string keywords);
/// <summary>
/// Loads a custom instruction from the awesome-copilot repository.
/// </summary>
/// <param name="mode">The instruction mode</param>
/// <param name="filename">The filename of the instruction</param>
/// <returns>The file contents as a string</returns>
Task<string> LoadAsync(InstructionMode mode, string filename);
}
/// <summary>
/// This represents the tools entity for metadata of Awesome Copilot repository.
/// </summary>
[McpServerToolType]
public class MetadataTool(IMetadataService service, ILogger<MetadataTool> logger) : IMetadataTool
{
/// <inheritdoc />
[McpServerTool(Name = "search_instructions", Title = "Searches custom instructions")]
[Description("Searches custom instructions based on keywords in their titles and descriptions.")]
public async Task<MetadataResult> SearchAsync(
[Description("The keyword to search for")] string keywords)
{
var result = new MetadataResult();
try
{
var metadata = await service.SearchAsync(keywords).ConfigureAwait(false);
logger.LogInformation("Search completed successfully with keyword '{Keywords}'.", keywords);
result.Metadata = metadata;
}
catch (Exception ex)
{
logger.LogError(ex, "Error occurred while searching instructions with keyword '{Keywords}'.", keywords);
result.ErrorMessage = ex.Message;
}
return result;
}
/// <inheritdoc />
[McpServerTool(Name = "load_instruction", Title = "Loads a custom instruction")]
[Description("Loads a custom instruction from the repository.")]
public async Task<string> LoadAsync(
[Description("The instruction mode")] InstructionMode mode,
[Description("The filename of the instruction")] string filename)
{
try
{
if (mode == InstructionMode.Undefined)
{
throw new ArgumentException("Instruction mode must be defined.", nameof(mode));
}
var result = await service.LoadAsync(mode.ToString().ToLowerInvariant(), filename).ConfigureAwait(false);
logger.LogInformation("Load completed successfully with mode {Mode} and filename {Filename}.", mode, filename);
return result;
}
catch (Exception ex)
{
logger.LogError(ex, "Error occurred while loading instruction with mode {Mode} and filename {Filename}.", mode, filename);
return ex.Message;
}
}
}

View File

@ -0,0 +1,12 @@
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
},
"AllowedHosts": "*",
"UseHttp": false
}

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,22 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<IsAspireSharedProject>true</IsAspireSharedProject>
</PropertyGroup>
<ItemGroup>
<FrameworkReference Include="Microsoft.AspNetCore.App" />
<PackageReference Include="Microsoft.Extensions.Http.Resilience" Version="9.8.0" />
<PackageReference Include="Microsoft.Extensions.ServiceDiscovery" Version="9.5.0-preview.1.25459.5" />
<PackageReference Include="OpenTelemetry.Exporter.OpenTelemetryProtocol" Version="1.12.0" />
<PackageReference Include="OpenTelemetry.Extensions.Hosting" Version="1.12.0" />
<PackageReference Include="OpenTelemetry.Instrumentation.AspNetCore" Version="1.12.0" />
<PackageReference Include="OpenTelemetry.Instrumentation.Http" Version="1.12.0" />
<PackageReference Include="OpenTelemetry.Instrumentation.Runtime" Version="1.12.0" />
</ItemGroup>
</Project>

View File

@ -0,0 +1,127 @@
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Diagnostics.HealthChecks;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Diagnostics.HealthChecks;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.ServiceDiscovery;
using OpenTelemetry;
using OpenTelemetry.Metrics;
using OpenTelemetry.Trace;
namespace Microsoft.Extensions.Hosting;
// Adds common Aspire services: service discovery, resilience, health checks, and OpenTelemetry.
// This project should be referenced by each service project in your solution.
// To learn more about using this project, see https://aka.ms/dotnet/aspire/service-defaults
public static class Extensions
{
private const string HealthEndpointPath = "/health";
private const string AlivenessEndpointPath = "/alive";
public static TBuilder AddServiceDefaults<TBuilder>(this TBuilder builder) where TBuilder : IHostApplicationBuilder
{
builder.ConfigureOpenTelemetry();
builder.AddDefaultHealthChecks();
builder.Services.AddServiceDiscovery();
builder.Services.ConfigureHttpClientDefaults(http =>
{
// Turn on resilience by default
http.AddStandardResilienceHandler();
// Turn on service discovery by default
http.AddServiceDiscovery();
});
// Uncomment the following to restrict the allowed schemes for service discovery.
// builder.Services.Configure<ServiceDiscoveryOptions>(options =>
// {
// options.AllowedSchemes = ["https"];
// });
return builder;
}
public static TBuilder ConfigureOpenTelemetry<TBuilder>(this TBuilder builder) where TBuilder : IHostApplicationBuilder
{
builder.Logging.AddOpenTelemetry(logging =>
{
logging.IncludeFormattedMessage = true;
logging.IncludeScopes = true;
});
builder.Services.AddOpenTelemetry()
.WithMetrics(metrics =>
{
metrics.AddAspNetCoreInstrumentation()
.AddHttpClientInstrumentation()
.AddRuntimeInstrumentation();
})
.WithTracing(tracing =>
{
tracing.AddSource(builder.Environment.ApplicationName)
.AddAspNetCoreInstrumentation(tracing =>
// Exclude health check requests from tracing
tracing.Filter = context =>
!context.Request.Path.StartsWithSegments(HealthEndpointPath)
&& !context.Request.Path.StartsWithSegments(AlivenessEndpointPath)
)
// Uncomment the following line to enable gRPC instrumentation (requires the OpenTelemetry.Instrumentation.GrpcNetClient package)
//.AddGrpcClientInstrumentation()
.AddHttpClientInstrumentation();
});
builder.AddOpenTelemetryExporters();
return builder;
}
private static TBuilder AddOpenTelemetryExporters<TBuilder>(this TBuilder builder) where TBuilder : IHostApplicationBuilder
{
var useOtlpExporter = !string.IsNullOrWhiteSpace(builder.Configuration["OTEL_EXPORTER_OTLP_ENDPOINT"]);
if (useOtlpExporter)
{
builder.Services.AddOpenTelemetry().UseOtlpExporter();
}
// Uncomment the following lines to enable the Azure Monitor exporter (requires the Azure.Monitor.OpenTelemetry.AspNetCore package)
//if (!string.IsNullOrEmpty(builder.Configuration["APPLICATIONINSIGHTS_CONNECTION_STRING"]))
//{
// builder.Services.AddOpenTelemetry()
// .UseAzureMonitor();
//}
return builder;
}
public static TBuilder AddDefaultHealthChecks<TBuilder>(this TBuilder builder) where TBuilder : IHostApplicationBuilder
{
builder.Services.AddHealthChecks()
// Add a default liveness check to ensure app is responsive
.AddCheck("self", () => HealthCheckResult.Healthy(), ["live"]);
return builder;
}
public static WebApplication MapDefaultEndpoints(this WebApplication app)
{
// Adding health checks endpoints to applications in non-development environments has security implications.
// See https://aka.ms/dotnet/aspire/healthchecks for details before enabling these endpoints in non-development environments.
if (app.Environment.IsDevelopment())
{
// All health checks must pass for app to be considered ready to accept traffic after starting
app.MapHealthChecks(HealthEndpointPath);
// Only health checks tagged with the "live" tag must pass for app to be considered alive
app.MapHealthChecks(AlivenessEndpointPath, new HealthCheckOptions
{
Predicate = r => r.Tags.Contains("live")
});
}
return app;
}
}

View File

@ -0,0 +1,343 @@
#!/usr/bin/env node
const fs = require("fs");
const path = require("path");
// Constants
const APPLY_TO_KEY = "applyTo";
// Helper function to process applyTo field values
function processApplyToField(value) {
if (value.includes(",")) {
return value
.split(",")
.map((item) => item.trim())
.filter((item) => item.length > 0);
} else if (value.length > 0) {
return [value];
} else {
return [];
}
}
// Read the JSON schema to understand the structure
const schemaPath = path.join(__dirname, "frontmatter-schema.json");
const schema = JSON.parse(fs.readFileSync(schemaPath, "utf8"));
// Define the directories to process
const directories = {
chatmodes: path.join(__dirname, "src", "awesome-copilot", "chatmodes"),
instructions: path.join(__dirname, "src", "awesome-copilot", "instructions"),
prompts: path.join(__dirname, "src", "awesome-copilot", "prompts"),
};
/**
* Parses a simple YAML frontmatter string into a JavaScript object.
*
* This function handles key-value pairs, multi-line values, arrays, and special cases
* like the `applyTo` key, which is processed into an array of strings. It also removes
* comments and trims unnecessary whitespace.
*
* @param {string} yamlContent - The YAML frontmatter content as a string.
* Each line should represent a key-value pair, an array item,
* or a comment (starting with `#`).
* @returns {Object} A JavaScript object representing the parsed YAML content.
* Keys are strings, and values can be strings, arrays, or objects.
* Special handling is applied to the `applyTo` key, converting
* comma-separated strings into arrays.
*/
function parseSimpleYaml(yamlContent) {
const result = {};
const lines = yamlContent.split("\n");
let currentKey = null;
let currentValue = "";
let inArray = false;
let arrayItems = [];
// Helper to parse a bracket-style array string into array items.
function parseBracketArrayString(str) {
const items = [];
const arrayContent = str.slice(1, -1);
if (!arrayContent.trim()) return items;
// Split by comma, but be defensive and trim each item and remove trailing commas/quotes
const rawItems = arrayContent.split(",");
for (let raw of rawItems) {
let item = raw.trim();
if (!item) continue;
// Remove trailing commas left over (defensive)
if (item.endsWith(",")) item = item.slice(0, -1).trim();
// Remove surrounding quotes if present
if (
(item.startsWith('"') && item.endsWith('"')) ||
(item.startsWith("'") && item.endsWith("'"))
) {
item = item.slice(1, -1);
}
if (item.length > 0) items.push(item);
}
return items;
}
for (let i = 0; i < lines.length; i++) {
const line = lines[i];
const trimmed = line.trim();
if (!trimmed || trimmed.startsWith("#")) continue;
// Check if this is a key-value pair
const colonIndex = trimmed.indexOf(":");
if (colonIndex !== -1 && !trimmed.startsWith("-")) {
// Finish previous key if we were building one
if (currentKey) {
if (inArray) {
result[currentKey] = arrayItems;
arrayItems = [];
inArray = false;
} else {
let trimmedValue = currentValue.trim();
// If the accumulated value looks like a bracket array (possibly multiline), parse it
if (trimmedValue.startsWith("[") && trimmedValue.endsWith("]")) {
result[currentKey] = parseBracketArrayString(trimmedValue);
} else {
// Handle comma-separated strings for specific fields that should be arrays
if (currentKey === APPLY_TO_KEY) {
result[currentKey] = processApplyToField(trimmedValue);
} else {
result[currentKey] = trimmedValue;
}
}
}
}
currentKey = trimmed.substring(0, colonIndex).trim();
currentValue = trimmed.substring(colonIndex + 1).trim();
// Remove quotes if present
if (
(currentValue.startsWith('"') && currentValue.endsWith('"')) ||
(currentValue.startsWith("'") && currentValue.endsWith("'"))
) {
currentValue = currentValue.slice(1, -1);
}
// Check if this is an inline bracket-array
if (currentValue.startsWith("[") && currentValue.endsWith("]")) {
result[currentKey] = parseBracketArrayString(currentValue);
currentKey = null;
currentValue = "";
} else if (currentValue === "" || currentValue === "[]") {
// Empty value or empty array, might be multi-line
if (currentValue === "[]") {
result[currentKey] = [];
currentKey = null;
currentValue = "";
} else {
// Check if next line starts with a dash (array item)
if (i + 1 < lines.length && lines[i + 1].trim().startsWith("-")) {
inArray = true;
arrayItems = [];
}
}
}
} else if (trimmed.startsWith("-") && currentKey && inArray) {
// Array item
let item = trimmed.substring(1).trim();
// Remove trailing commas and surrounding quotes
if (item.endsWith(",")) item = item.slice(0, -1).trim();
if (
(item.startsWith('"') && item.endsWith('"')) ||
(item.startsWith("'") && item.endsWith("'"))
) {
item = item.slice(1, -1);
}
arrayItems.push(item);
} else if (currentKey && !inArray) {
// Multi-line value
currentValue += " " + trimmed;
}
}
// Finish the last key
if (currentKey) {
if (inArray) {
result[currentKey] = arrayItems;
} else {
let finalValue = currentValue.trim();
// Remove quotes if present
if (
(finalValue.startsWith('"') && finalValue.endsWith('"')) ||
(finalValue.startsWith("'") && finalValue.endsWith("'"))
) {
finalValue = finalValue.slice(1, -1);
}
// If the final value looks like a bracket array, parse it
if (finalValue.startsWith("[") && finalValue.endsWith("]")) {
result[currentKey] = parseBracketArrayString(finalValue);
} else {
// Handle comma-separated strings for specific fields that should be arrays
if (currentKey === APPLY_TO_KEY) {
result[currentKey] = processApplyToField(finalValue);
} else {
result[currentKey] = finalValue;
}
}
}
}
return result;
}
// Function to extract frontmatter from a markdown file
function extractFrontmatter(filePath) {
let content = fs.readFileSync(filePath, "utf8");
// Remove BOM if present (handles files with Byte Order Mark)
if (content.charCodeAt(0) === 0xfeff) {
content = content.slice(1);
}
// Check if the file starts with frontmatter
if (!content.startsWith("---")) {
return null;
}
const lines = content.split("\n");
let frontmatterEnd = -1;
// Find the end of frontmatter
for (let i = 1; i < lines.length; i++) {
if (lines[i].trim() === "---") {
frontmatterEnd = i;
break;
}
}
if (frontmatterEnd === -1) {
return null;
}
// Extract frontmatter content
const frontmatterContent = lines.slice(1, frontmatterEnd).join("\n");
try {
return parseSimpleYaml(frontmatterContent);
} catch (error) {
console.error(`Error parsing frontmatter in ${filePath}:`, error.message);
return null;
}
}
// Function to process files in a directory
function processDirectory(dirPath, fileExtension) {
const files = fs
.readdirSync(dirPath)
.filter((file) => file.endsWith(fileExtension))
.sort();
const results = [];
for (const file of files) {
const filePath = path.join(dirPath, file);
const frontmatter = extractFrontmatter(filePath);
if (frontmatter) {
const result = {
filename: file,
...frontmatter,
};
// Ensure description is present (required by schema)
if (!result.description) {
console.warn(
`Warning: No description found in ${file}, adding placeholder`
);
result.description = "No description provided";
}
results.push(result);
} else {
console.warn(`Warning: No frontmatter found in ${file}, skipping`);
}
}
return results;
}
// Process all directories
const metadata = {
chatmodes: processDirectory(directories.chatmodes, ".chatmode.md"),
instructions: processDirectory(directories.instructions, ".instructions.md"),
prompts: processDirectory(directories.prompts, ".prompt.md"),
};
// Write the metadata.json file
const outputPath = path.join(
__dirname,
"src",
"AwesomeCopilot.McpServer",
"metadata.json"
);
fs.writeFileSync(outputPath, JSON.stringify(metadata, null, 2));
console.log(
`Extracted frontmatter from ${metadata.chatmodes.length} chatmode files`
);
console.log(
`Extracted frontmatter from ${metadata.instructions.length} instruction files`
);
console.log(
`Extracted frontmatter from ${metadata.prompts.length} prompt files`
);
console.log(`Metadata written to ${outputPath}`);
// Validate that required fields are present
let hasErrors = false;
// Check chatmodes
metadata.chatmodes.forEach((chatmode) => {
if (!chatmode.filename || !chatmode.description) {
console.error(
`Error: Chatmode missing required fields: ${
chatmode.filename || "unknown"
}`
);
hasErrors = true;
}
});
// Check instructions
metadata.instructions.forEach((instruction) => {
if (!instruction.filename || !instruction.description) {
console.error(
`Error: Instruction missing required fields: ${
instruction.filename || "unknown"
}`
);
hasErrors = true;
}
});
// Check prompts
metadata.prompts.forEach((prompt) => {
if (!prompt.filename || !prompt.description) {
console.error(
`Error: Prompt missing required fields: ${prompt.filename || "unknown"}`
);
hasErrors = true;
}
});
if (hasErrors) {
console.error(
"Some files are missing required fields. Please check the output above."
);
process.exit(1);
} else {
console.log(
"All files have required fields. Metadata extraction completed successfully."
);
}