Skip to content

Commit 7fde5bd

Browse files
authored
Fix MCP resources and improbe the sample project (#334)
1 parent 3b01de0 commit 7fde5bd

File tree

10 files changed

+533
-124
lines changed

10 files changed

+533
-124
lines changed

src/Core/CrestApps.OrchardCore.AI.Mcp.Core/McpResourceUri.cs

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -23,11 +23,14 @@ public static bool TryMatch(string uriTemplate, string actualUri, out IReadOnlyD
2323
{
2424
variables = null;
2525

26-
if (string.IsNullOrEmpty(uriTemplate) || string.IsNullOrEmpty(actualUri))
26+
if (string.IsNullOrWhiteSpace(uriTemplate) || string.IsNullOrWhiteSpace(actualUri))
2727
{
2828
return false;
2929
}
3030

31+
uriTemplate = uriTemplate.Trim();
32+
actualUri = actualUri.Trim();
33+
3134
// Collect all variable matches first so we know which is the last one.
3235
var matches = new List<(int Index, int Length, string Name)>();
3336

@@ -101,7 +104,7 @@ public static bool TryMatch(string uriTemplate, string actualUri, out IReadOnlyD
101104
/// </summary>
102105
public static bool IsTemplate(string uri)
103106
{
104-
return !string.IsNullOrEmpty(uri) && uri.Contains('{');
107+
return !string.IsNullOrWhiteSpace(uri) && uri.AsSpan().Trim().Contains('{');
105108
}
106109

107110
[GeneratedRegex(@"\{(\w+)\}")]

src/CrestApps.OrchardCore.Documentations/docs/ai/mcp/server.md

Lines changed: 38 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -29,21 +29,53 @@ The MCP server exposes the following capabilities:
2929

3030
## Prompt Support
3131

32-
The MCP server exposes MCP prompts registered in Orchard Core. Prompts can be:
32+
MCP **Prompts** are reusable prompt templates that MCP clients can discover and invoke. They allow you to define pre-configured system or user messages that external AI agents can request on demand — for example, a "summarize" prompt that instructs the model to summarize a given document, or a "translate" prompt that translates text into a target language.
3333

34-
- Created and managed via the admin UI under **Artificial Intelligence → MCP Prompts**
35-
- Registered programmatically in code
36-
- Discovered by external MCP clients via `ListPrompts` and invoked via `GetPrompt`
34+
Prompts are listed by clients via `ListPrompts` and invoked via `GetPrompt`, which returns the prompt messages for the client to include in its conversation.
35+
36+
### Managing Prompts via Admin UI
37+
38+
1. Navigate to **Artificial Intelligence****MCP Prompts**
39+
2. Click **Add Prompt** to create a new prompt
40+
3. Fill in the required fields:
41+
- **Name**: A unique identifier for the prompt (used by MCP clients to reference it)
42+
- **Display Text**: A human-readable name shown in the admin list
43+
- **Description**: Optional description that helps clients understand what the prompt does
44+
4. Add one or more **Messages** to the prompt:
45+
- Each message has a **Role** (e.g., `system`, `user`) and **Content** (the message text)
46+
- Messages are returned in order when a client calls `GetPrompt`
47+
5. Save the prompt
48+
49+
Prompts can also be registered programmatically in code or imported via recipes.
3750

3851
## Resource Support
3952

40-
The MCP server exposes MCP resources registered in Orchard Core, allowing clients to access various data sources through the MCP protocol.
53+
MCP **Resources** represent data that MCP clients can read. A resource has a URI that the client uses to request its content. Resources come in two flavors:
54+
55+
- **Static Resources**: Have a fixed URI with no variable placeholders (e.g., `recipe-schema://abc123/recipe`). They return the same data every time and appear in `ListResources`.
56+
- **Templated Resources**: Have a URI containing `{variable}` placeholders (e.g., `file://abc123/{fileName}`). The client fills in the variables when reading the resource. These appear in `ListResourceTemplates` and allow dynamic content resolution.
4157

4258
Resources can be:
43-
- Created and managed via the admin UI under **Artificial Intelligence → MCP Resources**
59+
- Created and managed via the admin UI under **Artificial Intelligence****MCP Resources**
4460
- Registered programmatically in code
4561
- Discovered and accessed by external MCP clients
4662

63+
### Managing Resources via Admin UI
64+
65+
1. Navigate to **Artificial Intelligence****MCP Resources**
66+
2. Click **Add Resource** to create a new resource
67+
3. Select a **Resource Type** (e.g., File, Content Item, Recipe Step Schema). Each type defines what kind of data the resource serves and which URI variables are available.
68+
4. Fill in the required fields:
69+
- **Display Text**: A friendly name for the resource shown in the admin list
70+
- **Path**: The path portion of the URI. For templated resources, include variable placeholders from the supported variables list shown in the UI (e.g., `{fileName}`, `{contentType}`)
71+
- **Name**: The MCP resource name (used by clients to identify the resource)
72+
- **Title**: Optional human-readable title
73+
- **Description**: Optional description that helps clients understand the resource
74+
- **MIME Type**: The content type of the resource (e.g., `application/json`, `text/plain`)
75+
5. Save the resource
76+
77+
The system automatically constructs the full URI by prepending the scheme and a unique resource ID to your path. For example, if you select the **File** resource type and enter `{fileName}` as the path, the full URI might be `file://abc123/{fileName}`.
78+
4779
### Built-in Resource Types
4880

4981
| Type | Supported Variables | Description |
@@ -72,20 +104,6 @@ Each resource instance has a URI that is auto-constructed by the system as:
72104

73105
When creating a resource in the admin UI, you only provide the **path** portion. The system automatically prepends the scheme and resource ID.
74106

75-
### Creating Resources via Admin UI
76-
77-
1. Navigate to **Artificial Intelligence****MCP Resources**
78-
2. Click **Add Resource**
79-
3. Select a resource type (e.g., File, Content Item, Recipe Step Schema)
80-
4. Fill in the required fields:
81-
- **Display Text**: A friendly name for the resource
82-
- **Path**: The path portion of the URI, using any supported variables shown in the UI
83-
- **Name**: The MCP resource name (used by clients)
84-
- **Title**: Optional human-readable title
85-
- **Description**: Optional description
86-
- **MIME Type**: Content type of the resource
87-
5. Save the resource
88-
89107
### Registering Custom Resource Types
90108

91109
You can register custom resource types with their handlers:

src/CrestApps.OrchardCore.Documentations/docs/changelog/v2.0.0.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -90,6 +90,8 @@ A new suite of modules for multi-channel communication:
9090
- **MCP Prompts and Resources** — Prompts and resources can be added and managed via the admin UI.
9191
- **Templated Resources** — Support for dynamic MCP resources defined with URI templates.
9292
- **Stdio Transport** — Connect to local MCP servers (e.g., Docker containers) via Standard Input/Output.
93+
- **Template URI Whitespace Handling** — Resource URI templates and incoming URIs are now trimmed of leading/trailing whitespace before matching, preventing mismatches caused by accidental spaces in URI definitions.
94+
- **File Resource Directory Rejection** — The file resource handler now returns a descriptive error when the resolved path is a directory instead of a file, rather than attempting to read directory content.
9395

9496
### AI Agent (`CrestApps.OrchardCore.AI.Agent`)
9597

src/Modules/CrestApps.OrchardCore.AI.Mcp/Drivers/McpResourceDisplayDriver.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -95,7 +95,7 @@ public override async Task<IDisplayResult> UpdateAsync(McpResource entry, Update
9595
};
9696

9797
// Build the full URI from the user-provided path.
98-
entry.Resource.Uri = Handlers.McpResourceHandler.BuildUri(entry.Source, entry.ItemId, model.Path);
98+
entry.Resource.Uri = Handlers.McpResourceHandler.BuildUri(entry.Source, entry.ItemId, model.Path?.Trim());
9999

100100
entry.Resource.Name = model.Name ?? string.Empty;
101101
entry.Resource.Title = entry.DisplayText;

src/Modules/CrestApps.OrchardCore.AI.Mcp/Handlers/FileResourceTypeHandler.cs

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,11 @@ protected override async Task<ReadResourceResult> GetResultAsync(McpResource res
5959
return CreateErrorResult(resource.Resource.Uri, $"File not found: {fileName}");
6060
}
6161

62+
if (fileInfo.IsDirectory)
63+
{
64+
return CreateErrorResult(resource.Resource.Uri, $"The path '{fileName}' is a directory, not a file. Only file resources are supported.");
65+
}
66+
6267
if (_logger.IsEnabled(LogLevel.Debug))
6368
{
6469
_logger.LogDebug("Reading file resource from provider '{ProviderName}': {FileName}", providerName, fileName);

src/Startup/CrestApps.OrchardCore.Samples.McpClient/Pages/Prompts.cshtml

Lines changed: 80 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@
1111
}
1212

1313
<div class="d-flex align-items-center gap-3 mb-3">
14-
<p class="text-muted mb-0">Prompts registered on the MCP server. Click "Get Prompt" to retrieve details.</p>
14+
<p class="text-muted mb-0">Prompts registered on the MCP server. Click "Get" to retrieve details.</p>
1515
<form method="post" asp-page-handler="Refresh" class="d-inline">
1616
<button class="btn btn-secondary btn-sm" type="submit">Refresh</button>
1717
</form>
@@ -24,8 +24,11 @@
2424
</div>
2525

2626
<ul class="list-group" id="promptsList">
27-
@foreach (var prompt in Model.Prompts)
27+
@for (var i = 0; i < Model.Prompts.Count; i++)
2828
{
29+
var prompt = Model.Prompts[i];
30+
var resultId = $"result-prompt-{i}";
31+
2932
<li class="list-group-item searchable-item" data-search-text="@prompt.Name?.ToLowerInvariant() @prompt.Description?.ToLowerInvariant()">
3033
<div class="d-flex justify-content-between align-items-start">
3134
<div>
@@ -35,10 +38,11 @@
3538
<div class="text-muted">@prompt.Description</div>
3639
}
3740
</div>
38-
<form method="post" asp-page-handler="GetPrompt">
39-
<input type="hidden" name="promptName" value="@prompt.Name" />
40-
<button class="btn btn-primary btn-sm" type="submit">Get Prompt</button>
41-
</form>
41+
<div class="prompt-form" data-prompt-name="@prompt.Name" data-result-target="#@resultId">
42+
<button class="btn btn-primary btn-sm btn-get-prompt" type="button">Get</button>
43+
</div>
44+
</div>
45+
<div id="@resultId" class="mt-3" style="display:none">
4246
</div>
4347
</li>
4448
}
@@ -51,39 +55,68 @@ else
5155
<p class="text-muted">No prompts were returned by the MCP server.</p>
5256
}
5357

54-
@if (Model.PromptResult is not null)
55-
{
56-
<div class="card mt-4">
57-
<div class="card-header">Prompt Details: @Model.SelectedPromptName</div>
58-
<div class="card-body">
59-
@if (!string.IsNullOrWhiteSpace(Model.PromptResult.Description))
60-
{
61-
<p><strong>Description:</strong> @Model.PromptResult.Description</p>
62-
}
63-
64-
@if (Model.PromptResult.Messages?.Count > 0)
65-
{
66-
<h6>Messages</h6>
67-
@foreach (var message in Model.PromptResult.Messages)
68-
{
69-
<div class="card mb-2">
70-
<div class="card-body p-2">
71-
<span class="badge bg-secondary me-1">@message.Role</span>
72-
<span>@message.Content</span>
73-
</div>
74-
</div>
75-
}
76-
}
77-
else
78-
{
79-
<p class="text-muted">No messages returned for this prompt.</p>
80-
}
81-
</div>
82-
</div>
83-
}
84-
8558
<script>
8659
(function () {
60+
// Handle Get button clicks via AJAX.
61+
document.querySelectorAll('.btn-get-prompt').forEach(function (btn) {
62+
btn.addEventListener('click', function () {
63+
var container = btn.closest('.prompt-form');
64+
var promptName = container.dataset.promptName;
65+
var resultDiv = document.querySelector(container.dataset.resultTarget);
66+
67+
btn.disabled = true;
68+
btn.textContent = 'Loading…';
69+
resultDiv.style.display = 'block';
70+
resultDiv.innerHTML = '<p class="text-muted">Loading…</p>';
71+
72+
var formData = new FormData();
73+
formData.append('promptName', promptName);
74+
75+
fetch('?handler=GetPrompt', {
76+
method: 'POST',
77+
headers: {
78+
'RequestVerificationToken': document.querySelector('input[name="__RequestVerificationToken"]')?.value || ''
79+
},
80+
body: formData
81+
})
82+
.then(function (response) { return response.json(); })
83+
.then(function (data) {
84+
if (data.error) {
85+
resultDiv.innerHTML = '<div class="alert alert-danger mb-0">' + escapeHtml(data.error) + '</div>';
86+
return;
87+
}
88+
89+
var html = '';
90+
91+
if (data.description) {
92+
html += '<p><strong>Description:</strong> ' + escapeHtml(data.description) + '</p>';
93+
}
94+
95+
if (data.messages && data.messages.length > 0) {
96+
html += '<h6>Messages</h6>';
97+
data.messages.forEach(function (msg) {
98+
html += '<div class="card mb-2"><div class="card-body p-2">';
99+
html += '<span class="badge bg-secondary me-1">' + escapeHtml(msg.role) + '</span>';
100+
html += '<span>' + escapeHtml(msg.content || '') + '</span>';
101+
html += '</div></div>';
102+
});
103+
} else {
104+
html += '<p class="text-muted mb-0">No messages returned for this prompt.</p>';
105+
}
106+
107+
resultDiv.innerHTML = html;
108+
})
109+
.catch(function (err) {
110+
resultDiv.innerHTML = '<div class="alert alert-danger mb-0">' + escapeHtml(err.message || 'An error occurred.') + '</div>';
111+
})
112+
.finally(function () {
113+
btn.disabled = false;
114+
btn.textContent = 'Get';
115+
});
116+
});
117+
});
118+
119+
// Client-side search.
87120
var searchInput = document.getElementById('promptSearch');
88121
if (!searchInput) return;
89122
var items = document.querySelectorAll('#promptsList .searchable-item');
@@ -98,5 +131,15 @@ else
98131
});
99132
noResults.style.display = visible === 0 && query ? '' : 'none';
100133
});
134+
135+
function escapeHtml(text) {
136+
var div = document.createElement('div');
137+
div.appendChild(document.createTextNode(text));
138+
return div.innerHTML;
139+
}
101140
})();
102141
</script>
142+
143+
@* Antiforgery token for AJAX calls *@
144+
<form id="__AjaxAntiForgeryForm" method="post" style="display:none">
145+
</form>

src/Startup/CrestApps.OrchardCore.Samples.McpClient/Pages/Prompts.cshtml.cs

Lines changed: 15 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -17,10 +17,6 @@ public PromptsModel(McpClientFactory clientFactory)
1717

1818
public IList<McpClientPrompt> Prompts { get; private set; } = [];
1919

20-
public string SelectedPromptName { get; private set; }
21-
22-
public GetPromptResult PromptResult { get; private set; }
23-
2420
public string ErrorMessage { get; private set; }
2521

2622
public async Task OnGetAsync(CancellationToken cancellationToken)
@@ -39,31 +35,35 @@ public async Task<IActionResult> OnPostGetPromptAsync(string promptName, Cancell
3935
{
4036
if (string.IsNullOrWhiteSpace(promptName))
4137
{
42-
ErrorMessage = "Prompt name is required.";
43-
await LoadPromptsAsync(cancellationToken);
44-
45-
return Page();
38+
return new JsonResult(new { error = "Prompt name is required." });
4639
}
4740

4841
try
4942
{
5043
var client = await _clientFactory.CreateAsync(cancellationToken);
5144

52-
SelectedPromptName = promptName;
53-
PromptResult = await client.GetPromptAsync(
45+
var result = await client.GetPromptAsync(
5446
promptName,
5547
new Dictionary<string, object>(),
5648
options: null,
5749
cancellationToken);
50+
51+
var messages = new List<object>();
52+
53+
if (result.Messages?.Count > 0)
54+
{
55+
foreach (var message in result.Messages)
56+
{
57+
messages.Add(new { role = message.Role.ToString(), content = message.Content?.ToString() });
58+
}
59+
}
60+
61+
return new JsonResult(new { description = result.Description, messages });
5862
}
5963
catch (Exception ex)
6064
{
61-
ErrorMessage = ex.Message;
65+
return new JsonResult(new { error = ex.Message });
6266
}
63-
64-
await LoadPromptsAsync(cancellationToken);
65-
66-
return Page();
6767
}
6868

6969
private async Task LoadPromptsAsync(CancellationToken cancellationToken)

0 commit comments

Comments
 (0)