Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
83 changes: 83 additions & 0 deletions McpServer/BlogTools.cs
Original file line number Diff line number Diff line change
@@ -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<string> ListBlogPosts(HttpClient client, CancellationToken cancellationToken)
{
using var response = await client.GetAsync("api/post", cancellationToken);
response.EnsureSuccessStatusCode();

var posts = await response.Content.ReadFromJsonAsync<List<BlogPostMetadata>>(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<string> 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<BlogPostMetadata>(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);
}
34 changes: 34 additions & 0 deletions McpServer/Program.cs
Original file line number Diff line number Diff line change
@@ -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<BlogTools>();
builder.Logging.AddConsole(options =>
{
options.LogToStandardErrorThreshold = LogLevel.Trace;
});

await builder.Build().RunAsync();
15 changes: 15 additions & 0 deletions McpServer/ServerlessBlog.McpServer.csproj
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
<Project Sdk="Microsoft.NET.Sdk">

<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net9.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>

<ItemGroup>
<PackageReference Include="Microsoft.Extensions.Hosting" Version="10.0.10" />
<PackageReference Include="ModelContextProtocol" Version="2.2.0" />
</ItemGroup>

</Project>
7 changes: 7 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
6 changes: 6 additions & 0 deletions ServerlessBlog.sln
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down