diff --git a/McpServer/BlogTools.cs b/McpServer/BlogTools.cs new file mode 100644 index 0000000..da94651 --- /dev/null +++ b/McpServer/BlogTools.cs @@ -0,0 +1,83 @@ +using System.ComponentModel; +using System.Net.Http.Json; +using System.Text.Json; +using ModelContextProtocol; +using ModelContextProtocol.Server; + +namespace ServerlessBlog.McpServer; + +[McpServerToolType] +public sealed class BlogTools +{ + private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web); + + [McpServerTool(Name = "list_blog_posts"), Description("List published ServerlessBlog posts and their metadata.")] + public static async Task ListBlogPosts(HttpClient client, CancellationToken cancellationToken) + { + using var response = await client.GetAsync("api/post", cancellationToken); + response.EnsureSuccessStatusCode(); + + var posts = await response.Content.ReadFromJsonAsync>(cancellationToken: cancellationToken) + ?? []; + + return JsonSerializer.Serialize( + posts.Where(post => post.IsPublic) + .Select(post => new PublishedPost(post.Slug, post.Title, post.Published, post.Tags, post.Preview, post.ImageUrl)), + JsonOptions); + } + + [McpServerTool(Name = "get_blog_post"), Description("Get a published ServerlessBlog post, including its metadata and Markdown content.")] + public static async Task GetBlogPost( + HttpClient client, + [Description("The exact slug of the published post.")] string slug, + CancellationToken cancellationToken) + { + if (string.IsNullOrWhiteSpace(slug)) + { + throw new McpException("A post slug is required."); + } + + using var metadataResponse = await client.GetAsync($"api/post/{Uri.EscapeDataString(slug)}", cancellationToken); + metadataResponse.EnsureSuccessStatusCode(); + var metadata = await metadataResponse.Content.ReadFromJsonAsync(cancellationToken: cancellationToken); + + if (metadata is null || !metadata.IsPublic) + { + throw new McpException("The post is not published or could not be found."); + } + + using var markdownResponse = await client.GetAsync( + $"api/post/{Uri.EscapeDataString(slug)}/markdown", + cancellationToken); + markdownResponse.EnsureSuccessStatusCode(); + var markdown = await markdownResponse.Content.ReadAsStringAsync(cancellationToken); + var post = new PublishedPost( + metadata.Slug, + metadata.Title, + metadata.Published, + metadata.Tags, + metadata.Preview, + metadata.ImageUrl); + + return JsonSerializer.Serialize(new { metadata = post, markdown }, JsonOptions); + } + + private sealed class BlogPostMetadata + { + public string? Slug { get; init; } + public string? Title { get; init; } + public string? Published { get; init; } + public string? Tags { get; init; } + public string? Preview { get; init; } + public string? ImageUrl { get; init; } + public bool IsPublic { get; init; } + } + + private sealed record PublishedPost( + string? Slug, + string? Title, + string? Published, + string? Tags, + string? Preview, + string? ImageUrl); +} diff --git a/McpServer/Program.cs b/McpServer/Program.cs new file mode 100644 index 0000000..7a1687a --- /dev/null +++ b/McpServer/Program.cs @@ -0,0 +1,34 @@ +using System.Net.Http.Headers; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using ModelContextProtocol.Server; +using ServerlessBlog.McpServer; + +var builder = Host.CreateApplicationBuilder(args); +var apiUrl = Environment.GetEnvironmentVariable("SERVERLESSBLOG_API_URL") + ?? "https://func-blog-engine-we-prod-001.azurewebsites.net"; + +if (!Uri.TryCreate(apiUrl, UriKind.Absolute, out var apiUri) || + apiUri.Scheme is not ("http" or "https")) +{ + throw new InvalidOperationException("SERVERLESSBLOG_API_URL must be an absolute HTTP or HTTPS URL."); +} + +var httpClient = new HttpClient { BaseAddress = new Uri($"{apiUri.ToString().TrimEnd('/')}/") }; +var apiToken = Environment.GetEnvironmentVariable("SERVERLESSBLOG_API_TOKEN"); +if (!string.IsNullOrWhiteSpace(apiToken)) +{ + httpClient.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", apiToken); +} + +builder.Services.AddSingleton(httpClient); +builder.Services.AddMcpServer() + .WithStdioServerTransport() + .WithTools(); +builder.Logging.AddConsole(options => +{ + options.LogToStandardErrorThreshold = LogLevel.Trace; +}); + +await builder.Build().RunAsync(); diff --git a/McpServer/ServerlessBlog.McpServer.csproj b/McpServer/ServerlessBlog.McpServer.csproj new file mode 100644 index 0000000..bd2a4ba --- /dev/null +++ b/McpServer/ServerlessBlog.McpServer.csproj @@ -0,0 +1,15 @@ + + + + Exe + net9.0 + enable + enable + + + + + + + + diff --git a/README.md b/README.md index f765229..949cacb 100644 --- a/README.md +++ b/README.md @@ -18,6 +18,13 @@ Components used - `Engine` - Azure Functions backend (API, rendering and scheduling) - `Frontend` - Azure Functions serving the public blog pages - `Editor` - React + TypeScript admin portal hosted in Azure Static Web Apps, see [Editor/README.md](Editor/README.md) +- `McpServer` - stdio MCP server for reading published blog posts + +## MCP server + +The MCP server exposes two read-only tools: `list_blog_posts` returns published post metadata, and `get_blog_post` returns a published post's metadata and Markdown. It uses the existing Engine API and does not expose draft content. + +Configure an MCP client to launch the server with `dotnet run --project /path/to/ServerlessBlog/McpServer/ServerlessBlog.McpServer.csproj`. Set `SERVERLESSBLOG_API_URL` to override the Engine API base URL; the default is the production blog API. If the API requires a bearer token, set `SERVERLESSBLOG_API_TOKEN`. ## How to run diff --git a/ServerlessBlog.sln b/ServerlessBlog.sln index 41bba5d..e9f1d36 100644 --- a/ServerlessBlog.sln +++ b/ServerlessBlog.sln @@ -7,6 +7,8 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Frontend", "Frontend\Fronte EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Engine", "Engine\Engine.csproj", "{D25BDE2E-1EFD-4894-9C26-CBCABDF7DD80}" EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "ServerlessBlog.McpServer", "McpServer\ServerlessBlog.McpServer.csproj", "{83D369E6-DDF8-4B9F-A3D5-159F9AB2DDF7}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -21,6 +23,10 @@ Global {D25BDE2E-1EFD-4894-9C26-CBCABDF7DD80}.Debug|Any CPU.Build.0 = Debug|Any CPU {D25BDE2E-1EFD-4894-9C26-CBCABDF7DD80}.Release|Any CPU.ActiveCfg = Release|Any CPU {D25BDE2E-1EFD-4894-9C26-CBCABDF7DD80}.Release|Any CPU.Build.0 = Release|Any CPU + {83D369E6-DDF8-4B9F-A3D5-159F9AB2DDF7}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {83D369E6-DDF8-4B9F-A3D5-159F9AB2DDF7}.Debug|Any CPU.Build.0 = Debug|Any CPU + {83D369E6-DDF8-4B9F-A3D5-159F9AB2DDF7}.Release|Any CPU.ActiveCfg = Release|Any CPU + {83D369E6-DDF8-4B9F-A3D5-159F9AB2DDF7}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE