Skip to content

Commit 367ff2b

Browse files
authored
Merge pull request #375 from ChangemakerStudios/feature/mcp-server
Add MCP server support to Papercut.Service
2 parents 6048c7a + 9caef27 commit 367ff2b

16 files changed

Lines changed: 575 additions & 36 deletions

README.md

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,14 +33,24 @@ docker run -d -p 8080:8080 -p 2525:2525 changemakerstudiosus/papercut-smtp:lates
3333

3434
Web UI at **http://localhost:8080**, SMTP on **localhost:2525**. Details on [Docker Hub](https://hub.docker.com/r/changemakerstudiosus/papercut-smtp).
3535

36+
## MCP Server (AI Agents)
37+
38+
The Papercut Service includes an optional [Model Context Protocol](https://modelcontextprotocol.io/) server, so AI agents like Claude Code can inspect the email your app sends — list messages, assert on bodies and headers, verify attachment content, and clean up between test runs. Off by default; enable it with the `EnableMcpServer` setting, then connect:
39+
40+
```bash
41+
claude mcp add --transport http papercut http://localhost:8080/mcp
42+
```
43+
44+
See the [MCP Server documentation](https://www.papercut-smtp.com/mcp/) for setup and the full tool reference.
45+
3646
## Documentation
3747

3848
**[www.papercut-smtp.com](https://www.papercut-smtp.com/)** — full documentation:
3949

4050
- [How It Works](https://www.papercut-smtp.com/how-it-works/) — what Papercut is (and isn't), in two minutes
4151
- [Getting Started](https://www.papercut-smtp.com/getting-started/) — install, first run, first test email
4252
- [Send Email from Your App](https://www.papercut-smtp.com/send-from-your-app/) — copy-paste config for .NET, Node, Python, PHP, Java, Ruby
43-
- [Desktop App](https://www.papercut-smtp.com/desktop/) · [Service & Web UI](https://www.papercut-smtp.com/service/) · [Docker](https://www.papercut-smtp.com/docker/) · [TLS & Auth](https://www.papercut-smtp.com/smtp-tls-auth/)
53+
- [Desktop App](https://www.papercut-smtp.com/desktop/) · [Service & Web UI](https://www.papercut-smtp.com/service/) · [MCP Server](https://www.papercut-smtp.com/mcp/) · [Docker](https://www.papercut-smtp.com/docker/) · [TLS & Auth](https://www.papercut-smtp.com/smtp-tls-auth/)
4454
- [Troubleshooting](https://www.papercut-smtp.com/troubleshooting/)
4555

4656
## Release History

docs/mcp.md

Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
# MCP Server (AI Agents)
2+
3+
The Papercut SMTP Service includes an optional **[Model Context Protocol](https://modelcontextprotocol.io/) (MCP) server** that lets AI agents and coding assistants — Claude Code, and any other MCP-capable client — inspect the email your application sends during development.
4+
5+
A typical agent-driven test loop:
6+
7+
1. The agent triggers your app to send an email
8+
2. `list_messages` — confirm it arrived
9+
3. `get_message` — assert on subject, recipients, and body
10+
4. `get_message_section` — verify an attachment's actual content
11+
5. `delete_all_messages` — reset for the next test
12+
13+
!!! note "Off by default"
14+
The MCP server is disabled unless you explicitly enable it. The service logs its status at startup either way:
15+
16+
```
17+
[INF] MCP server is enabled -- serving MCP endpoint at /mcp
18+
[INF] MCP server is disabled (set EnableMcpServer to true to enable)
19+
```
20+
21+
## Enabling
22+
23+
Set `EnableMcpServer` to `true` using any of the service's [configuration layers](service.md#configuration):
24+
25+
=== "appsettings.json"
26+
27+
```json
28+
{ "EnableMcpServer": true }
29+
```
30+
31+
=== "Environment variable"
32+
33+
```powershell
34+
$env:EnableMcpServer = 'true'
35+
```
36+
37+
=== "Docker"
38+
39+
```bash
40+
docker run -d -p 8080:8080 -p 2525:2525 \
41+
-e EnableMcpServer=true \
42+
changemakerstudiosus/papercut-smtp:latest
43+
```
44+
45+
Restart the service after changing it. When enabled, the endpoint is served at:
46+
47+
```
48+
http://localhost:8080/mcp
49+
```
50+
51+
(Streamable HTTP transport; the path respects `HttpPathPrefix` if configured.) The web UI shows an **MCP** badge in the navigation bar when the server is on — hover it for the endpoint URL, click to copy. The URL is also available programmatically at `GET /api/mcp`.
52+
53+
## Connecting a client
54+
55+
**Claude Code:**
56+
57+
```bash
58+
claude mcp add --transport http papercut http://localhost:8080/mcp
59+
```
60+
61+
**Generic MCP client configuration:**
62+
63+
```json
64+
{
65+
"mcpServers": {
66+
"papercut": {
67+
"type": "http",
68+
"url": "http://localhost:8080/mcp"
69+
}
70+
}
71+
}
72+
```
73+
74+
## Tools
75+
76+
| Tool | Description |
77+
|------|-------------|
78+
| `list_messages` | Paged message summaries, newest first (`limit`, `start`) |
79+
| `get_message` | Full detail for one message: from/to/cc/bcc, subject, text and HTML bodies, headers, and a manifest of MIME sections (index, contentId, media type, filename, attachment flag, size) |
80+
| `get_message_section` | Decoded content of a single MIME part, selected by `index` or `contentId` from the manifest — text parts return as text, binary parts as base64 |
81+
| `get_message_raw` | The raw RFC 822 (`.eml`) source of a message |
82+
| `delete_message` | Delete one message by id |
83+
| `delete_all_messages` | Clear the message store |
84+
85+
Large content is truncated to keep responses manageable (raw messages at 200K characters, binary sections at 512KB) with a `truncated` flag pointing to the full-content REST endpoints (`/api/messages/{id}/raw` and `/api/messages/{id}/sections/{index}`).
86+
87+
!!! warning "Network exposure"
88+
Like the REST API and web UI, the MCP endpoint has **no built-in authentication** — anyone who can reach the HTTP port can read and delete messages. Keep the binding on `localhost`, or put a reverse proxy with auth in front of it. See the [network exposure warning](service.md#configuration).

docs/service.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,3 +68,5 @@ Use `http://0.0.0.0:8080` to listen on all interfaces — but read the warning b
6868
## API
6969

7070
The web UI is backed by a small HTTP API (`/api/messages`, etc.) you can script against — handy for asserting "an email was sent" in end-to-end tests. Explore the endpoints via your browser's dev tools on the web UI.
71+
72+
For AI agents and coding assistants, the service can also expose these operations over the Model Context Protocol — see [MCP Server](mcp.md).

mkdocs.yml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,7 @@ nav:
4444
- Send Email from Your App: send-from-your-app.md
4545
- Desktop App: desktop.md
4646
- Service & Web UI: service.md
47+
- MCP Server (AI Agents): mcp.md
4748
- Docker: docker.md
4849
- TLS & Authentication: smtp-tls-auth.md
4950
- Troubleshooting: troubleshooting.md
Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
// Papercut
2+
//
3+
// Copyright © 2008 - 2012 Ken Robertson
4+
// Copyright © 2013 - 2025 Jaben Cargman
5+
//
6+
// Licensed under the Apache License, Version 2.0 (the "License");
7+
// you may not use this file except in compliance with the License.
8+
// You may obtain a copy of the License at
9+
//
10+
// http://www.apache.org/licenses/LICENSE-2.0
11+
//
12+
// Unless required by applicable law or agreed to in writing, software
13+
// distributed under the License is distributed on an "AS IS" BASIS,
14+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
15+
// See the License for the specific language governing permissions and
16+
// limitations under the License.
17+
18+
19+
using Microsoft.Extensions.Configuration;
20+
21+
using Papercut.Service.Application.Mcp;
22+
23+
namespace Papercut.Service.Application.Controllers;
24+
25+
[Route("api/[controller]")]
26+
public class McpController(ISettingStore settingStore, IConfiguration configuration) : ControllerBase
27+
{
28+
[HttpGet]
29+
public McpStatusDto Get()
30+
{
31+
var enabled = McpServerSettings.IsEnabled(settingStore, configuration);
32+
33+
return new McpStatusDto
34+
{
35+
Enabled = enabled,
36+
Url = enabled
37+
? $"{this.Request.Scheme}://{this.Request.Host}{this.Request.PathBase}{McpServerSettings.EndpointPath}"
38+
: null
39+
};
40+
}
41+
42+
public class McpStatusDto
43+
{
44+
public bool Enabled { get; set; }
45+
46+
public string? Url { get; set; }
47+
}
48+
}
Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
// Papercut
2+
//
3+
// Copyright © 2008 - 2012 Ken Robertson
4+
// Copyright © 2013 - 2025 Jaben Cargman
5+
//
6+
// Licensed under the Apache License, Version 2.0 (the "License");
7+
// you may not use this file except in compliance with the License.
8+
// You may obtain a copy of the License at
9+
//
10+
// http://www.apache.org/licenses/LICENSE-2.0
11+
//
12+
// Unless required by applicable law or agreed to in writing, software
13+
// distributed under the License is distributed on an "AS IS" BASIS,
14+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
15+
// See the License for the specific language governing permissions and
16+
// limitations under the License.
17+
18+
19+
using Microsoft.AspNetCore.Mvc.Filters;
20+
21+
using ModelContextProtocol;
22+
23+
namespace Papercut.Service.Application.Controllers;
24+
25+
/// <summary>
26+
/// Derives from <see cref="McpException" /> so the same throw surfaces as a tool error
27+
/// over MCP and, via <see cref="MessageNotFoundExceptionFilterAttribute" />, a 404 over REST.
28+
/// </summary>
29+
public class MessageNotFoundException(string messageId) : McpException($"Message '{messageId}' was not found");
30+
31+
public class MessageNotFoundExceptionFilterAttribute : ExceptionFilterAttribute
32+
{
33+
public override void OnException(ExceptionContext context)
34+
{
35+
if (context.Exception is MessageNotFoundException)
36+
{
37+
context.Result = new NotFoundResult();
38+
context.ExceptionHandled = true;
39+
}
40+
}
41+
}

src/Papercut.Service/Application/Controllers/MessagesController.cs

Lines changed: 48 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -16,16 +16,28 @@
1616
// limitations under the License.
1717

1818

19+
using System.ComponentModel;
20+
21+
using ModelContextProtocol;
22+
using ModelContextProtocol.Server;
23+
1924
using Papercut.Service.Web;
2025

2126
namespace Papercut.Service.Application.Controllers;
2227

2328
[Route("api/[controller]")]
29+
[McpServerToolType]
30+
[MessageNotFoundExceptionFilter]
2431
public class MessagesController(IMessageRepository messageRepository, IMimeMessageLoader messageLoader, ILogger logger)
2532
: ControllerBase
2633
{
2734
[HttpGet]
28-
public async Task<GetMessagesResponse> GetAll(int limit = 10, int start = 0, CancellationToken token = default)
35+
[McpServerTool(Name = "list_messages")]
36+
[Description("Lists received email messages, newest first. Returns the total message count and a page of message summaries (id, subject, size, created date).")]
37+
public async Task<GetMessagesResponse> GetAll(
38+
[Description("Maximum number of messages to return (default 10)")] int limit = 10,
39+
[Description("Zero-based offset to start from, for paging (default 0)")] int start = 0,
40+
CancellationToken token = default)
2941
{
3042
var messageEntries = messageRepository.LoadMessages().ToList();
3143

@@ -43,29 +55,39 @@ public async Task<GetMessagesResponse> GetAll(int limit = 10, int start = 0, Can
4355
}
4456

4557
[HttpDelete]
46-
public void DeleteAll()
58+
[McpServerTool(Name = "delete_all_messages")]
59+
[Description("Deletes all received email messages.")]
60+
public string DeleteAll()
4761
{
62+
var deleted = 0;
63+
var failed = 0;
64+
4865
foreach (var msg in messageRepository.LoadMessages())
4966
{
5067
try
5168
{
5269
messageRepository.DeleteMessage(msg);
70+
deleted++;
5371
}
5472
catch (Exception ex)
5573
{
5674
logger.Warning(ex, "Failure Deleting Message File {MessageFile}", msg.File);
75+
failed++;
5776
}
5877
}
78+
79+
return failed == 0
80+
? $"Deleted {deleted} message(s)"
81+
: $"Deleted {deleted} message(s); {failed} failed to delete";
5982
}
6083

6184
[HttpDelete("{id}")]
62-
public ActionResult Delete(string id)
85+
[McpServerTool(Name = "delete_message")]
86+
[Description("Deletes a received email message by id.")]
87+
public string Delete(
88+
[Description("The message id (as returned by list_messages)")] string id)
6389
{
64-
var messageEntry = messageRepository.LoadMessages().FirstOrDefault(msg => msg.Name == id);
65-
if (messageEntry == null)
66-
{
67-
return this.NotFound();
68-
}
90+
var messageEntry = this.GetMessageEntry(id);
6991

7092
try
7193
{
@@ -74,32 +96,28 @@ public ActionResult Delete(string id)
7496
catch (Exception ex)
7597
{
7698
logger.Warning(ex, "Failure Deleting Message File {MessageFile}", messageEntry.File);
77-
return this.StatusCode(500);
99+
throw new McpException($"Failed to delete message '{id}'");
78100
}
79101

80-
return this.NoContent();
102+
return $"Deleted message '{id}'";
81103
}
82104

83105
[HttpGet("{id}")]
84-
public async Task<ActionResult<MimeMessageEntry.DetailDto>> Get(string id)
106+
[McpServerTool(Name = "get_message")]
107+
[Description("Gets the full detail of a received email message by id: from/to/cc/bcc addresses, subject, text and HTML bodies, headers, and a manifest of MIME sections (body parts and attachments).")]
108+
public async Task<MimeMessageEntry.DetailDto> Get(
109+
[Description("The message id (as returned by list_messages)")] string id,
110+
CancellationToken token = default)
85111
{
86-
var messageEntry = messageRepository.LoadMessages().FirstOrDefault(msg => msg.Name == id);
87-
if (messageEntry == null)
88-
{
89-
return this.NotFound();
90-
}
112+
var messageEntry = this.GetMessageEntry(id);
91113

92-
return MimeMessageEntry.DetailDto.CreateFrom(new MimeMessageEntry(messageEntry, (await messageLoader.GetAsync(messageEntry))!));
114+
return MimeMessageEntry.DetailDto.CreateFrom(new MimeMessageEntry(messageEntry, (await messageLoader.GetAsync(messageEntry, token))!));
93115
}
94116

95117
[HttpGet("{messageId}/raw")]
96118
public ActionResult DownloadRaw(string messageId)
97119
{
98-
var messageEntry = messageRepository.LoadMessages().FirstOrDefault(msg => msg.Name == messageId);
99-
if (messageEntry == null)
100-
{
101-
return this.NotFound();
102-
}
120+
var messageEntry = this.GetMessageEntry(messageId);
103121

104122
var response = new FileStreamResult(System.IO.File.OpenRead(messageEntry.File), "message/rfc822")
105123
{
@@ -121,13 +139,16 @@ public Task<ActionResult> DownloadSectionContent(string messageId, string conten
121139
return this.DownloadSection(messageId, sections => sections.FirstOrDefault(s => s.ContentId == contentId));
122140
}
123141

142+
MessageEntry GetMessageEntry(string id)
143+
{
144+
var messageEntry = messageRepository.LoadMessages().FirstOrDefault(msg => msg.Name == id);
145+
146+
return messageEntry ?? throw new MessageNotFoundException(id);
147+
}
148+
124149
async Task<ActionResult> DownloadSection(string messageId, Func<List<MimePart>, MimePart?> findSection)
125150
{
126-
var messageEntry = messageRepository.LoadMessages().FirstOrDefault(msg => msg.Name == messageId);
127-
if (messageEntry == null)
128-
{
129-
return this.NotFound();
130-
}
151+
var messageEntry = this.GetMessageEntry(messageId);
131152

132153
var mimeMessage = new MimeMessageEntry(messageEntry, (await messageLoader.GetAsync(messageEntry))!);
133154
var sections = mimeMessage.MailMessage.BodyParts.OfType<MimePart>().ToList();
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
// Papercut
2+
//
3+
// Copyright © 2008 - 2012 Ken Robertson
4+
// Copyright © 2013 - 2025 Jaben Cargman
5+
//
6+
// Licensed under the Apache License, Version 2.0 (the "License");
7+
// you may not use this file except in compliance with the License.
8+
// You may obtain a copy of the License at
9+
//
10+
// http://www.apache.org/licenses/LICENSE-2.0
11+
//
12+
// Unless required by applicable law or agreed to in writing, software
13+
// distributed under the License is distributed on an "AS IS" BASIS,
14+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
15+
// See the License for the specific language governing permissions and
16+
// limitations under the License.
17+
18+
19+
using Microsoft.Extensions.Configuration;
20+
21+
namespace Papercut.Service.Application.Mcp;
22+
23+
public static class McpServerSettings
24+
{
25+
public const string EnabledSettingKey = "EnableMcpServer";
26+
27+
public const string EndpointPath = "/mcp";
28+
29+
public static bool IsEnabled(ISettingStore settingStore, IConfiguration configuration)
30+
{
31+
var setting = settingStore.Get(EnabledSettingKey, configuration[EnabledSettingKey]);
32+
33+
return bool.TryParse(setting, out var enabled) && enabled;
34+
}
35+
}

0 commit comments

Comments
 (0)