Skip to content

Commit f0ad238

Browse files
Surface skill staleness, self-introspection, and skill-in-instructions (#28)
Addresses four pieces of Claude Desktop feedback: * Skill freshness detection: capture a fingerprint (SHA-256 over sorted tool+prompt names) and the downstream RemoteVersion when update_skill is called, then compare against the live surface area on every list_services / get_service_details to emit skillFreshness=fresh|stale|unknown plus skillRecordedVersion and skillRecordedAt. Skill docs are operator-authored, so the aggregator detects rather than auto-rewrites drift. * Aggregator self-introspection: populate remoteName / remoteTitle / remoteVersion on the aggregator's own row in list_services from the configured McpServerOptions.ServerInfo, instead of leaving them null. * Skill in initialize handshake: read data/skills/{SelfName}.md at server startup and append it (capped at 16KB) to McpServerOptions.ServerInstructions, so MCP clients get aggregator orientation during the initialize handshake without needing to know to call get_service_skill. * refresh_service semantics: tighten the tool description to spell out that it clears the cached connection, ServerInfo, tools, and prompts, and that it does NOT modify the skill document (admin-authored via update_skill).
1 parent f5f249d commit f0ad238

12 files changed

Lines changed: 336 additions & 23 deletions

File tree

src/McpAggregator.Core/Configuration/McpServerBuilderExtensions.cs

Lines changed: 47 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,11 @@ namespace McpAggregator.Core.Configuration;
88

99
public static class McpServerBuilderExtensions
1010
{
11+
// Cap on how much of the self skill document we embed in the MCP initialize-handshake
12+
// instructions payload. Larger skills are still available through get_service_skill,
13+
// but smaller payloads keep the handshake cheap for every client.
14+
private const int MaxEmbeddedSkillChars = 16 * 1024;
15+
1116
public static IMcpServerBuilder AddAggregatorMcpServer(this IServiceCollection services)
1217
{
1318
var version = GetAggregatorVersion();
@@ -23,27 +28,53 @@ public static IMcpServerBuilder AddAggregatorMcpServer(this IServiceCollection s
2328
Title = "MCP Aggregator",
2429
Version = version,
2530
};
26-
mcpOpts.ServerInstructions = BuildInstructions(agg.SelfName);
31+
mcpOpts.ServerInstructions = BuildInstructions(agg.SelfName, LoadSelfSkill(agg));
2732
});
2833

2934
return builder;
3035
}
3136

32-
private static string BuildInstructions(string selfName) =>
33-
$"""
34-
MCP Aggregator — a single MCP endpoint that fans out to many downstream MCP servers.
35-
One connection gives the client the union of tools across every registered server,
36-
without consuming a slot per server in clients that cap concurrent MCP connections.
37-
38-
Discovery flow:
39-
1. list_services() — see every registered downstream server and its summary.
40-
2. get_service_skill(serverName: "{selfName}") — full usage guide for this aggregator.
41-
3. get_service_skill(serverName: "<downstream>") — usage guide for a specific service.
42-
4. call_tool(serverName, toolName, arguments) — invoke any downstream tool through the aggregator.
43-
44-
Downstream connections are established lazily on first use and reused across calls.
45-
Start by calling get_service_skill(serverName: "{selfName}").
46-
""";
37+
private static string? LoadSelfSkill(AggregatorOptions options)
38+
{
39+
var path = Path.Combine(options.SkillsDirectoryPath, $"{options.SelfName}.md");
40+
if (!File.Exists(path))
41+
return null;
42+
43+
try
44+
{
45+
var content = File.ReadAllText(path);
46+
return content.Length > MaxEmbeddedSkillChars
47+
? content[..MaxEmbeddedSkillChars] + "\n\n[…truncated — call get_service_skill for full text]"
48+
: content;
49+
}
50+
catch
51+
{
52+
return null;
53+
}
54+
}
55+
56+
private static string BuildInstructions(string selfName, string? selfSkill)
57+
{
58+
var header = $"""
59+
MCP Aggregator — a single MCP endpoint that fans out to many downstream MCP servers.
60+
One connection gives the client the union of tools across every registered server,
61+
without consuming a slot per server in clients that cap concurrent MCP connections.
62+
63+
Discovery flow:
64+
1. list_services() — see every registered downstream server and its summary.
65+
2. get_service_skill(serverName: "{selfName}") — full usage guide for this aggregator.
66+
3. get_service_skill(serverName: "<downstream>") — usage guide for a specific service.
67+
4. call_tool(serverName, toolName, arguments) — invoke any downstream tool through the aggregator.
68+
69+
Downstream connections are established lazily on first use and reused across calls.
70+
Start by calling get_service_skill(serverName: "{selfName}").
71+
""";
72+
73+
if (string.IsNullOrWhiteSpace(selfSkill))
74+
return header;
75+
76+
return header + "\n\n---\n\n# Aggregator Skill Document\n\n" + selfSkill;
77+
}
4778

4879
private static string GetAggregatorVersion()
4980
{

src/McpAggregator.Core/Models/RegisteredServer.cs

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,4 +16,12 @@ public class RegisteredServer
1616
public string? RemoteTitle { get; set; }
1717
public string? RemoteVersion { get; set; }
1818
public string? RemoteInstructions { get; set; }
19+
20+
// Snapshot of the downstream's identity and surface area at the moment the
21+
// skill document was last authored, used to detect drift between the skill
22+
// and the actual server. Null when no skill has been recorded or when the
23+
// server was unreachable at update time.
24+
public string? SkillRecordedVersion { get; set; }
25+
public string? SkillRecordedFingerprint { get; set; }
26+
public DateTimeOffset? SkillRecordedAt { get; set; }
1927
}

src/McpAggregator.Core/Models/ServiceIndex.cs

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,13 @@ public class ServiceIndex
1515
public string? RemoteTitle { get; set; }
1616
public string? RemoteVersion { get; set; }
1717

18+
// Skill staleness signal. "fresh" = current tools/prompts/version match the snapshot
19+
// captured when the skill was authored; "stale" = drift detected; "unknown" = no
20+
// snapshot recorded or the server was unreachable at read time.
21+
public string? SkillFreshness { get; set; }
22+
public string? SkillRecordedVersion { get; set; }
23+
public DateTimeOffset? SkillRecordedAt { get; set; }
24+
1825
public List<ToolSummary> Tools { get; set; } = [];
1926
}
2027

@@ -39,6 +46,10 @@ public class ServiceDetails
3946
public string? RemoteVersion { get; set; }
4047
public string? RemoteInstructions { get; set; }
4148

49+
public string? SkillFreshness { get; set; }
50+
public string? SkillRecordedVersion { get; set; }
51+
public DateTimeOffset? SkillRecordedAt { get; set; }
52+
4253
public List<ToolDetail> Tools { get; set; } = [];
4354
public List<PromptDetail> Prompts { get; set; } = [];
4455
}

src/McpAggregator.Core/Services/ServerRegistry.cs

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -105,6 +105,20 @@ public async Task UpdateSummaryAsync(string name, string summary, CancellationTo
105105
await PersistAsync(ct);
106106
}
107107

108+
public async Task UpdateSkillSnapshotAsync(
109+
string name,
110+
string? recordedVersion,
111+
string? recordedFingerprint,
112+
DateTimeOffset? recordedAt,
113+
CancellationToken ct = default)
114+
{
115+
var server = Get(name);
116+
server.SkillRecordedVersion = recordedVersion;
117+
server.SkillRecordedFingerprint = recordedFingerprint;
118+
server.SkillRecordedAt = recordedAt;
119+
await PersistAsync(ct);
120+
}
121+
108122
public async Task UpdateRemoteMetadataAsync(
109123
string name,
110124
string? remoteName,
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
using System.Security.Cryptography;
2+
using System.Text;
3+
4+
namespace McpAggregator.Core.Services;
5+
6+
public static class SkillFingerprint
7+
{
8+
public static string Compute(IEnumerable<string> toolNames, IEnumerable<string> promptNames)
9+
{
10+
var sortedTools = toolNames.OrderBy(n => n, StringComparer.Ordinal);
11+
var sortedPrompts = promptNames.OrderBy(n => n, StringComparer.Ordinal);
12+
var input = string.Join("\n", sortedTools) + "\n--\n" + string.Join("\n", sortedPrompts);
13+
var bytes = SHA256.HashData(Encoding.UTF8.GetBytes(input));
14+
return Convert.ToHexString(bytes)[..16].ToLowerInvariant();
15+
}
16+
}
Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
using McpAggregator.Core.Models;
2+
3+
namespace McpAggregator.Core.Services;
4+
5+
public static class SkillSnapshot
6+
{
7+
public static async Task CaptureAsync(
8+
ServerRegistry registry,
9+
ToolIndex toolIndex,
10+
string serverName,
11+
CancellationToken ct)
12+
{
13+
try
14+
{
15+
var tools = await toolIndex.GetToolsForServerAsync(serverName, ct);
16+
List<PromptDetail> prompts;
17+
try
18+
{
19+
prompts = await toolIndex.GetPromptsForServerAsync(serverName, ct);
20+
}
21+
catch
22+
{
23+
prompts = [];
24+
}
25+
26+
var fingerprint = SkillFingerprint.Compute(
27+
tools.Select(t => t.Name),
28+
prompts.Select(p => p.Name));
29+
var server = registry.Get(serverName);
30+
await registry.UpdateSkillSnapshotAsync(
31+
serverName,
32+
server.RemoteVersion,
33+
fingerprint,
34+
DateTimeOffset.UtcNow,
35+
ct);
36+
}
37+
catch
38+
{
39+
// Server unreachable — leave the snapshot empty so freshness reports as "unknown".
40+
}
41+
}
42+
}

src/McpAggregator.Core/Services/ToolIndex.cs

Lines changed: 56 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
using Microsoft.Extensions.Logging;
66
using Microsoft.Extensions.Options;
77
using ModelContextProtocol.Client;
8+
using ModelContextProtocol.Server;
89

910
namespace McpAggregator.Core.Services;
1011

@@ -14,6 +15,7 @@ public class ToolIndex
1415
private readonly ConnectionManager _connectionManager;
1516
private readonly SkillStore _skillStore;
1617
private readonly AggregatorOptions _options;
18+
private readonly IOptions<McpServerOptions>? _mcpServerOptions;
1719
private readonly ILogger<ToolIndex> _logger;
1820

1921
private readonly ConcurrentDictionary<string, CachedTools> _cache = new(StringComparer.OrdinalIgnoreCase);
@@ -27,12 +29,14 @@ public ToolIndex(
2729
ConnectionManager connectionManager,
2830
SkillStore skillStore,
2931
IOptions<AggregatorOptions> options,
30-
ILogger<ToolIndex> logger)
32+
ILogger<ToolIndex> logger,
33+
IOptions<McpServerOptions>? mcpServerOptions = null)
3134
{
3235
_registry = registry;
3336
_connectionManager = connectionManager;
3437
_skillStore = skillStore;
3538
_options = options.Value;
39+
_mcpServerOptions = mcpServerOptions;
3640
_logger = logger;
3741

3842
_registry.RegistryChanged += () =>
@@ -55,14 +59,18 @@ public async Task<List<ServiceIndex>> GetIndexAsync(CancellationToken ct = defau
5559
// Advertise the aggregator itself if it has a skill document
5660
if (_skillStore.Exists(_options.SelfName))
5761
{
62+
var selfInfo = _mcpServerOptions?.Value.ServerInfo;
5863
results.Add(new ServiceIndex
5964
{
6065
Name = _options.SelfName,
6166
DisplayName = "MCP Aggregator",
6267
Description = _options.SelfDescription,
6368
Enabled = true,
6469
Available = true,
65-
HasSkillDocument = true
70+
HasSkillDocument = true,
71+
RemoteName = selfInfo?.Name,
72+
RemoteTitle = selfInfo?.Title,
73+
RemoteVersion = selfInfo?.Version
6674
});
6775
}
6876

@@ -74,14 +82,19 @@ public async Task<List<ServiceIndex>> GetIndexAsync(CancellationToken ct = defau
7482
DisplayName = server.DisplayName,
7583
Description = server.AiSummary ?? server.Description,
7684
Enabled = server.Enabled,
77-
HasSkillDocument = server.HasSkillDocument
85+
HasSkillDocument = server.HasSkillDocument,
86+
SkillRecordedVersion = server.SkillRecordedVersion,
87+
SkillRecordedAt = server.SkillRecordedAt
7888
};
7989

90+
List<ToolDetail>? tools = null;
91+
List<PromptDetail>? prompts = null;
92+
8093
if (server.Enabled)
8194
{
8295
try
8396
{
84-
var tools = await GetToolsForServerAsync(server.Name, ct);
97+
tools = await GetToolsForServerAsync(server.Name, ct);
8598
index.Available = true;
8699
index.Tools = tools.Select(t => new ToolSummary
87100
{
@@ -94,19 +107,55 @@ public async Task<List<ServiceIndex>> GetIndexAsync(CancellationToken ct = defau
94107
_logger.LogWarning(ex, "Failed to get tools for '{Server}'", server.Name);
95108
index.Available = false;
96109
}
110+
111+
if (index.Available && server.HasSkillDocument)
112+
{
113+
try
114+
{
115+
prompts = await GetPromptsForServerAsync(server.Name, ct);
116+
}
117+
catch
118+
{
119+
prompts = [];
120+
}
121+
}
97122
}
98123

99124
// Populate after the tools call so a fresh connect has refreshed the cached metadata.
100125
index.RemoteName = server.RemoteName;
101126
index.RemoteTitle = server.RemoteTitle;
102127
index.RemoteVersion = server.RemoteVersion;
128+
index.SkillFreshness = ComputeFreshness(server, tools, prompts);
103129

104130
results.Add(index);
105131
}
106132

107133
return results;
108134
}
109135

136+
private static string? ComputeFreshness(
137+
RegisteredServer server,
138+
IReadOnlyList<ToolDetail>? currentTools,
139+
IReadOnlyList<PromptDetail>? currentPrompts)
140+
{
141+
if (!server.HasSkillDocument)
142+
return null;
143+
144+
if (string.IsNullOrEmpty(server.SkillRecordedFingerprint))
145+
return "unknown";
146+
147+
if (currentTools is null)
148+
return "unknown";
149+
150+
var currentFingerprint = SkillFingerprint.Compute(
151+
currentTools.Select(t => t.Name),
152+
currentPrompts?.Select(p => p.Name) ?? []);
153+
154+
return string.Equals(currentFingerprint, server.SkillRecordedFingerprint, StringComparison.Ordinal)
155+
? "fresh"
156+
: "stale";
157+
}
158+
110159
public async Task<ServiceDetails> GetDetailsAsync(string serverName, CancellationToken ct = default)
111160
{
112161
await _registry.EnsureLoadedAsync(ct);
@@ -135,6 +184,9 @@ public async Task<ServiceDetails> GetDetailsAsync(string serverName, Cancellatio
135184
RemoteTitle = server.RemoteTitle,
136185
RemoteVersion = server.RemoteVersion,
137186
RemoteInstructions = server.RemoteInstructions,
187+
SkillFreshness = ComputeFreshness(server, tools, prompts),
188+
SkillRecordedVersion = server.SkillRecordedVersion,
189+
SkillRecordedAt = server.SkillRecordedAt,
138190
Tools = tools,
139191
Prompts = prompts
140192
};

src/McpAggregator.Core/Tools/AdminTools.cs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -114,6 +114,7 @@ public static async Task<string> UnregisterServer(
114114
public static async Task<string> UpdateSkill(
115115
ServerRegistry registry,
116116
SkillStore skillStore,
117+
ToolIndex toolIndex,
117118
[Description("The name of the registered server")] string serverName,
118119
[Description("Markdown content for the skill document")] string markdown,
119120
CancellationToken ct)
@@ -122,6 +123,7 @@ public static async Task<string> UpdateSkill(
122123
registry.Get(serverName); // Validate server exists
123124
await skillStore.SetAsync(serverName, markdown, ct);
124125
await registry.UpdateSkillFlagAsync(serverName, true, ct);
126+
await SkillSnapshot.CaptureAsync(registry, toolIndex, serverName, ct);
125127
return $"Skill document updated for '{serverName}'.";
126128
}
127129

src/McpAggregator.Core/Tools/ConsumerTools.cs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -79,7 +79,7 @@ public static async Task<string> GetPrompt(
7979
}
8080

8181
[McpServerTool(Name = "refresh_service")]
82-
[Description("Drop the cached metadata and connection for a registered MCP server, forcing a fresh reconnect and metadata reload on the next use. Use this after a downstream server has been updated.")]
82+
[Description("Drop the cached connection, ServerInfo, tool list, and prompt list for a registered MCP server so the next call re-fetches them from the downstream. Does NOT touch the skill document — that is admin-authored via update_skill. Use this after a downstream server has been upgraded or restarted.")]
8383
public static async Task<string> RefreshService(
8484
ToolIndex toolIndex,
8585
ConnectionManager connectionManager,
@@ -88,7 +88,7 @@ public static async Task<string> RefreshService(
8888
{
8989
toolIndex.InvalidateCache(serverName);
9090
await connectionManager.DisconnectAsync(serverName);
91-
return $"Cache and connection cleared for '{serverName}'. Metadata will be reloaded on next use.";
91+
return $"Cleared cached connection, ServerInfo, tools, and prompts for '{serverName}'. Skill document was not modified. Metadata will be reloaded on next use.";
9292
}
9393

9494
[McpServerTool(Name = "enable_service")]

0 commit comments

Comments
 (0)