From ef098fa173b4c49f2ca95cf764d554f1afa1672d Mon Sep 17 00:00:00 2001 From: Hacks4Snacks Date: Tue, 15 Sep 2026 17:02:36 -0700 Subject: [PATCH] mcp cache support --- docs/cli-reference.md | 76 ++++- src/ThreatModelForge.Cli/McpCommand.cs | 7 +- .../McpGroundingResources.cs | 115 +++++++ src/ThreatModelForge.Cli/McpToolSupport.cs | 7 + src/ThreatModelForge.Engine/EngineService.cs | 82 ++--- .../EngineCustomRulesTest.cs | 23 ++ .../McpToolsTest.cs | 295 ++++++++++++++++++ 7 files changed, 566 insertions(+), 39 deletions(-) create mode 100644 src/ThreatModelForge.Cli/McpGroundingResources.cs diff --git a/docs/cli-reference.md b/docs/cli-reference.md index 0dd6c88..e1671b2 100644 --- a/docs/cli-reference.md +++ b/docs/cli-reference.md @@ -1091,8 +1091,8 @@ tmforge mcp [--root ] [--max-read-bytes ] [--max-write-bytes ] | Option | Default | Meaning | | --- | --- | --- | -| `--root ` | Process working directory | Workspace root for every MCP file read and write. Relative tool paths resolve beneath it; traversal and symbolic-link escapes are rejected. | -| `--max-read-bytes ` | `67108864` (64 MiB) | Maximum size of one model file read by `read` or `detect`; for VSDX, the same budget also caps total expanded ZIP content. | +| `--root ` | Process working directory | Workspace root for every MCP file read and write. Relative tool and resource paths resolve beneath it; traversal and symbolic-link escapes are rejected. | +| `--max-read-bytes ` | `67108864` (64 MiB) | Maximum size of one model or rule file read; for VSDX, the same budget also caps total expanded ZIP content. Rule loading also applies the engine's pack-size limits. | | `--max-write-bytes ` | `67108864` (64 MiB) | Maximum serialized output accepted by `save`, enforced while the format writes. | Configure your MCP client to launch the tool: @@ -1122,12 +1122,82 @@ an optional `rulesPath` naming a `*.tmrules.json` pack. It is resolved through t sandbox as every other file access, so an agent cannot load rules from outside `--root`. +#### Grounding resources + +Clients with resource support can use `resources/list` and `resources/read` to obtain the same +grounding without invoking a tool. Existing grounding tools keep their names and result shapes and +remain supported for at least one release after this addition; resource support is optional. + +| Resource URI | Content | +| --- | --- | +| `tmforge://grounding/v1/formats` | Supported format catalog, matching the `formats` tool. | +| `tmforge://grounding/v1/property-schema` | Typed property catalog, matching `property_schema`. | +| `tmforge://grounding/v1/manifest-schema` | Manifest authoring guide, matching `manifest_schema`, including pages, optional geometry, and stable flow aliases. This is text guidance, not a JSON Schema validation document. | +| `tmforge://grounding/v1/rule-packs` | Built-in pack catalog; no custom source is selected. | + +All resource responses contain one `TextResourceContents` entry with MIME type `application/json`. +Parse its `text` as this versioned envelope: + +| Field | Meaning | +| --- | --- | +| `schema`, `version` | `tmforge-grounding`, `1`; reject unsupported versions. This is the resource-envelope version, not the manifest or rule-pack version. | +| `kind` | The catalog identifier from the URI. | +| `engineVersion` | The engine informational version, including available build identity. | +| `contentType` | `application/json` for catalogs; `text/plain` for the manifest guide. | +| `content` | The exact catalog JSON or guide text, encoded as a JSON string. Decode this string before processing it. | +| `fingerprint` | `sha256:` followed by 64 lowercase hex digits, computed over the UTF-8 bytes of the decoded `content` string. Do not reformat the inner JSON before verifying it. | + +The fixed resources are immutable within the server process. Cache by server/workspace identity, +URI, envelope version, engine version, and content fingerprint; a URI alone is not a permanent +content identity across engine upgrades. These fingerprints identify grounding content, not an +analysis result, and do not prove the currently selected model/rules have been analyzed. + +`resources/templates/list` also advertises: + +```text +tmforge://grounding/v1/rule-packs/custom{?rulesPath,fingerprint} +``` + +`rulesPath` is required and names one custom rule file inside `--root`, just as on the compatibility +tools. URI-encode the entire path value, including spaces, slashes, `+`, `#`, and `%`. For example, +after the normal MCP initialization handshake: + +```json +{"jsonrpc":"2.0","id":1,"method":"resources/list"} +{"jsonrpc":"2.0","id":2,"method":"resources/read","params":{"uri":"tmforge://grounding/v1/formats"}} +{"jsonrpc":"2.0","id":3,"method":"resources/read","params":{"uri":"tmforge://grounding/v1/rule-packs/custom?rulesPath=rules%2Fcorporate.tmrules.json"}} +``` + +The rule-pack `content` contains `rulePacks` (the effective catalog with counts), `customPacks` +(v2 custom-pack identity, version, dialect, content fingerprint, and effective count), `diagnostics`, +and `sources` (logical file name and selected-source fingerprint). Counts and diagnostics come from +one rule-set load. Built-ins are represented in `rulePacks`, not mislabelled as custom content. +Legacy unversioned sources have no v2 pack identity, but their source hash still changes when their +rules change. Source hashes cover decoded rule text encoded as UTF-8, matching MCP rule loading; +they are not necessarily hashes of original BOM/UTF-16 file bytes. + +To pin a subsequent read, append `&fingerprint=` followed by the URI-encoded envelope fingerprint +from the first response. A stale pin returns an explicit MCP fingerprint-mismatch error with the +current hash and refresh guidance; it never silently returns a different snapshot. Pins do not +retain historical content, select analysis rules, or bypass access checks. Each custom read, +including pinned reads, reopens the file through the existing workspace sandbox and byte limits. +Malformed packs retain their diagnostics and source hash; a built-ins-only catalog with load +diagnostics is not evidence that the requested custom policy is available. + +There is no file watching or file-change notification for custom packs. Re-read after policy edits +or reconnecting; do not cache a successful read forever. Resource responses use the existing MCP +structured-response size checks, and unexpected server errors remain masked. No file or model is +written by a resource read. + +#### Model workflow and limits + A typical agent loop is **apply -> analyze -> set -> analyze -> save**: build a model from a manifest (or incrementally with `add`/`connect`), analyze it, resolve findings by setting the properties the rules read (for example `Protocol=HTTPS`), then materialize a `.tm7` with `save`. The JSON-RPC protocol owns stdout; all diagnostics go to stderr. -**Filesystem boundary.** `read`, `detect`, and `save` are the only tools that access local files. +**Filesystem boundary.** Model file tools (`read`, `detect`, and `save`), tools selecting a +`rulesPath`, and custom grounding resources use the same file sandbox. They accept paths inside `--root`; absolute paths are allowed only when they resolve inside that same root. The server canonicalizes every existing path component and follows symbolic links only when their final target remains inside the root. A missing intermediate directory is rejected rather than diff --git a/src/ThreatModelForge.Cli/McpCommand.cs b/src/ThreatModelForge.Cli/McpCommand.cs index 56f8672..31535bb 100644 --- a/src/ThreatModelForge.Cli/McpCommand.cs +++ b/src/ThreatModelForge.Cli/McpCommand.cs @@ -87,7 +87,8 @@ private static async Task RunAsync(string[] args, McpPathPolicy pathPolicy) builder.Services .AddMcpServer() .WithStdioServerTransport() - .WithToolsFromAssembly(typeof(McpCommand).Assembly); + .WithToolsFromAssembly(typeof(McpCommand).Assembly) + .WithResourcesFromAssembly(typeof(McpCommand).Assembly); await builder.Build().RunAsync().ConfigureAwait(false); } @@ -106,6 +107,10 @@ private static void PrintUsage() Console.Error.WriteLine("set, rename, remove, analyze, threats, report, save, merge, export_manifest, plus grounding"); Console.Error.WriteLine("(formats, stencils, property_schema, rules, rule_packs, manifest_schema, detect)."); Console.Error.WriteLine(); + Console.Error.WriteLine("Also exposes versioned grounding resources at tmforge://grounding/v1/:"); + Console.Error.WriteLine("formats, property-schema, manifest-schema, rule-packs, and a sandboxed custom-pack template."); + Console.Error.WriteLine("Existing grounding tools remain available for clients without resource support."); + Console.Error.WriteLine(); Console.Error.WriteLine("Configure your MCP client to launch: command \"tmforge\", args [\"mcp\"]."); } diff --git a/src/ThreatModelForge.Cli/McpGroundingResources.cs b/src/ThreatModelForge.Cli/McpGroundingResources.cs new file mode 100644 index 0000000..d1925af --- /dev/null +++ b/src/ThreatModelForge.Cli/McpGroundingResources.cs @@ -0,0 +1,115 @@ +namespace ThreatModelForge.Cli +{ + using System; + using System.Collections.Generic; + using System.ComponentModel; + using System.Linq; + using System.Reflection; + using System.Text; + using ModelContextProtocol; + using ModelContextProtocol.Server; + using ThreatModelForge.Analysis; + using ThreatModelForge.Engine; + + /// Versioned, fingerprinted grounding snapshots for MCP resource clients. + [McpServerResourceType] + public static class McpGroundingResources + { + private static readonly Lazy FormatSnapshot = new Lazy( + () => Snapshot("formats", "application/json", CliJson.Serialize(McpGroundingTools.Formats()))); + + private static readonly Lazy PropertySnapshot = new Lazy( + () => Snapshot("property-schema", "application/json", CliJson.Serialize(McpGroundingTools.PropertySchema()))); + + private static readonly Lazy ManifestSnapshot = new Lazy( + () => Snapshot("manifest-schema", "text/plain", McpGroundingTools.ManifestSchema())); + + private static readonly Lazy BuiltInPackSnapshot = new Lazy( + () => RulePackSnapshot(null)); + + /// Reads the supported format catalog without invoking an MCP tool. + /// The versioned format snapshot. + [McpServerResource(Name = "formats", UriTemplate = "tmforge://grounding/v1/formats", MimeType = "application/json")] + [Description("Versioned format catalog snapshot. The fingerprint covers the exact UTF-8 content string; existing formats tool clients remain supported.")] + public static string Formats() => FormatSnapshot.Value; + + /// Reads the typed element and flow property catalog. + /// The versioned property snapshot. + [McpServerResource(Name = "property_schema", UriTemplate = "tmforge://grounding/v1/property-schema", MimeType = "application/json")] + [Description("Typed property names, allowed values, and defaults with version and content fingerprint. Same catalog as the property_schema tool.")] + public static string PropertySchema() => PropertySnapshot.Value; + + /// Reads the manifest authoring guide shared with the compatibility tool. + /// A versioned text guide, not a JSON Schema validation document. + [McpServerResource(Name = "manifest_schema", UriTemplate = "tmforge://grounding/v1/manifest-schema", MimeType = "application/json")] + [Description("Versioned manifest authoring guide shared with manifest_schema. The enclosed content is text/plain guidance, not a JSON Schema validator.")] + public static string ManifestSchema() => ManifestSnapshot.Value; + + /// Reads metadata for the built-in analysis rule packs. + /// The versioned built-in rule-pack snapshot. + [McpServerResource(Name = "rule_packs", UriTemplate = "tmforge://grounding/v1/rule-packs", MimeType = "application/json")] + [Description("Built-in rule-pack catalog with an engine version and catalog fingerprint. No custom rules are selected by this resource.")] + public static string RulePacks() => BuiltInPackSnapshot.Value; + + /// Reads effective pack metadata for one sandboxed custom rule file. + /// The request services containing the workspace path policy. + /// The custom rule file inside the workspace root. + /// An optional expected snapshot fingerprint. + /// The current versioned metadata, or an error when a pin no longer matches. + [McpServerResource(Name = "rule_packs_for_file", UriTemplate = "tmforge://grounding/v1/rule-packs/custom{?rulesPath,fingerprint}", MimeType = "application/json")] + [Description("Effective built-in and custom rule-pack metadata for a workspace file. URI-encode rulesPath; optional fingerprint pins a previously read snapshot. Each read revalidates the file and sandbox; diagnostics are preserved.")] + public static string CustomRulePacks(IServiceProvider services, string rulesPath, string? fingerprint = null) + { + McpToolSupport.ValidateArguments(new[] { rulesPath, fingerprint }); + if (string.IsNullOrWhiteSpace(rulesPath)) + { + throw new ArgumentException("A custom rule file path is required.", nameof(rulesPath)); + } + + if (fingerprint != null && (fingerprint.Length != 71 || !fingerprint.StartsWith("sha256:", StringComparison.Ordinal) || + fingerprint.Skip(7).Any(character => !((character >= '0' && character <= '9') || (character >= 'a' && character <= 'f'))))) + { + throw new ArgumentException("A snapshot fingerprint must be sha256: followed by 64 lowercase hexadecimal characters.", nameof(fingerprint)); + } + + return RulePackSnapshot(McpToolSupport.LoadRules(services, rulesPath), fingerprint); + } + + private static string RulePackSnapshot(EngineRuleOptions? rules, string? fingerprint = null) + { + RuleBundleDto metadata = EngineService.DescribeRules(rules, out IReadOnlyList catalog); + string content = CliJson.Serialize(new + { + rulePacks = catalog, + customPacks = metadata.RulePacks, + diagnostics = metadata.Diagnostics, + sources = (rules?.Sources ?? Array.Empty()).Select(source => new + { + name = source.Name, + fingerprint = RulePackIdentity.CreateFingerprint(Encoding.UTF8.GetBytes(source.Json ?? string.Empty)), + }).ToArray(), + }); + return Snapshot("rule-packs", "application/json", content, fingerprint); + } + + private static string Snapshot(string kind, string contentType, string content, string? expectedFingerprint = null) + { + string fingerprint = RulePackIdentity.CreateFingerprint(Encoding.UTF8.GetBytes(content)); + if (expectedFingerprint != null && !string.Equals(fingerprint, expectedFingerprint, StringComparison.Ordinal)) + { + throw new McpException($"Grounding snapshot fingerprint mismatch: expected '{expectedFingerprint}', current '{fingerprint}'. Read the unpinned resource to refresh."); + } + + return McpToolSupport.ValidateResponse(CliJson.Serialize(new + { + schema = "tmforge-grounding", + version = 1, + kind, + engineVersion = typeof(EngineService).Assembly.GetCustomAttribute()?.InformationalVersion ?? string.Empty, + contentType, + fingerprint, + content, + })); + } + } +} diff --git a/src/ThreatModelForge.Cli/McpToolSupport.cs b/src/ThreatModelForge.Cli/McpToolSupport.cs index 9c45971..73fbc01 100644 --- a/src/ThreatModelForge.Cli/McpToolSupport.cs +++ b/src/ThreatModelForge.Cli/McpToolSupport.cs @@ -38,6 +38,13 @@ internal static class McpToolSupport " \"props\": { \"Protocol\": \"HTTPS\", \"Port\": \"443\" } // optional\n" + " } ]\n" + "}\n" + + "The optional versioned envelope is \"schema\": \"tmforge-manifest\", \"version\": 1; " + + "unversioned manifests remain supported. Optional \"pages\" is an array of {\"alias\":\"page-id\",\"name\":\"Page title\"}. " + + "A boundary or element can select a page with \"page\":\"page-id\"; omission uses the first page. " + + "Both flow endpoints must be on the same page.\n" + + "Boundaries and elements accept optional integer \"x\"/\"y\" coordinates and \"width\"/\"height\" dimensions. " + + "Supply coordinates as a pair and dimensions as a pair; omitted geometry uses deterministic placement. " + + "Flows can declare an \"alias\" for stable connector identity and later edits.\n" + "Elements and flow endpoints are referenced by alias (or unique name), so the manifest needs no GUIDs " + "and round-trips with the export_manifest tool. Property values are validated against the property_schema " + "tool's catalog; pass force=true to store unknown names or values."; diff --git a/src/ThreatModelForge.Engine/EngineService.cs b/src/ThreatModelForge.Engine/EngineService.cs index 4c3fedd..4e10784 100644 --- a/src/ThreatModelForge.Engine/EngineService.cs +++ b/src/ThreatModelForge.Engine/EngineService.cs @@ -143,43 +143,10 @@ public static IReadOnlyList GetRules(EngineRuleOptions? rules) /// The available rule packs, in presentation order. public static IReadOnlyList GetRulePacks(EngineRuleOptions? rules) { - Dictionary counts = new Dictionary(StringComparer.Ordinal); - Dictionary customNames = new Dictionary(StringComparer.Ordinal); using (RuleSet ruleSet = LoadRuleSet(rules, null, out IReadOnlyList packs)) { - foreach (Rule rule in ruleSet.Rules) - { - counts[rule.Pack] = counts.TryGetValue(rule.Pack, out int existing) ? existing + 1 : 1; - } - - foreach (RulePackDefinition pack in packs) - { - customNames[pack.Id] = pack.Name; - } + return MapRulePackCatalog(packs, ruleSet); } - - List result = new List(); - foreach (KeyValuePair pack in RulePackCatalog.Ordered) - { - bool found = counts.TryGetValue(pack.Key, out int known); - if (found) - { - result.Add(new RulePackDto { Id = pack.Key, Name = pack.Value, Count = known }); - counts.Remove(pack.Key); - } - } - - List remaining = new List(counts.Keys); - remaining.Sort(StringComparer.Ordinal); - foreach (string packId in remaining) - { - string name = customNames.TryGetValue(packId, out string? custom) - ? custom - : RulePackCatalog.DisplayName(packId); - result.Add(new RulePackDto { Id = packId, Name = name, Count = counts[packId] }); - } - - return result; } /// @@ -189,13 +156,20 @@ public static IReadOnlyList GetRulePacks(EngineRuleOptions? rules) /// /// The custom rule content to load, or for built-in rules only. /// The effective packs and load diagnostics. - public static RuleBundleDto DescribeRules(EngineRuleOptions? rules) + public static RuleBundleDto DescribeRules(EngineRuleOptions? rules) => DescribeRules(rules, out _); + + /// Returns catalog counts and custom metadata from one effective rule-set load. + /// The custom rule content, or for built-ins only. + /// The built-in and custom pack catalog in presentation order. + /// The custom pack identities and diagnostics from the same load. + public static RuleBundleDto DescribeRules(EngineRuleOptions? rules, out IReadOnlyList catalog) { List diagnostics = new List(); IReadOnlyList effective; using (RuleSet ruleSet = LoadRuleSet(rules, diagnostics, out IReadOnlyList packs)) { effective = MapRulePacks(packs, ruleSet); + catalog = MapRulePackCatalog(packs, ruleSet); } return new RuleBundleDto { RulePacks = effective, Diagnostics = diagnostics }; @@ -1665,6 +1639,44 @@ private static RuleSet LoadRuleSet( return AnalysisRuleSources.Create(options, out packs); } + private static IReadOnlyList MapRulePackCatalog(IReadOnlyList packs, RuleSet ruleSet) + { + Dictionary counts = new Dictionary(StringComparer.Ordinal); + Dictionary customNames = new Dictionary(StringComparer.Ordinal); + foreach (Rule rule in ruleSet.Rules) + { + counts[rule.Pack] = counts.TryGetValue(rule.Pack, out int existing) ? existing + 1 : 1; + } + + foreach (RulePackDefinition pack in packs) + { + customNames[pack.Id] = pack.Name; + } + + List result = new List(); + foreach (KeyValuePair pack in RulePackCatalog.Ordered) + { + bool found = counts.TryGetValue(pack.Key, out int known); + if (found) + { + result.Add(new RulePackDto { Id = pack.Key, Name = pack.Value, Count = known }); + counts.Remove(pack.Key); + } + } + + List remaining = new List(counts.Keys); + remaining.Sort(StringComparer.Ordinal); + foreach (string packId in remaining) + { + string name = customNames.TryGetValue(packId, out string? custom) + ? custom + : RulePackCatalog.DisplayName(packId); + result.Add(new RulePackDto { Id = packId, Name = name, Count = counts[packId] }); + } + + return result; + } + /// /// Describes the custom packs that contributed rules, with the rule count each pack added, so a /// caller sees the identity and content fingerprint of the rules that actually ran. diff --git a/test/ThreatModelForge.Api.Tests/EngineCustomRulesTest.cs b/test/ThreatModelForge.Api.Tests/EngineCustomRulesTest.cs index a7c97ca..4d6698d 100644 --- a/test/ThreatModelForge.Api.Tests/EngineCustomRulesTest.cs +++ b/test/ThreatModelForge.Api.Tests/EngineCustomRulesTest.cs @@ -230,6 +230,29 @@ public void MalformedPackIsReportedThroughDiagnostics() Assert.IsTrue(bundle.Diagnostics.Any(message => message.Contains("broken.tmrules.json", StringComparison.Ordinal))); } + /// The combined metadata operation retains the existing catalog and diagnostics contracts. + /// The rule source selection. + [TestMethod] + [DataRow("built-in")] + [DataRow("custom")] + [DataRow("invalid")] + public void RulePackCatalogAndMetadataDescribeOneBundle(string selection) + { + EngineRuleOptions? rules = selection == "built-in" ? null : selection == "custom" ? Rules() : new EngineRuleOptions + { + Sources = new[] { new RuleSourceDto { Name = "invalid.tmrules.json", Json = "{ invalid json" } }, + }; + RuleBundleDto metadata = EngineService.DescribeRules(rules, out IReadOnlyList catalog); + Assert.AreEqual(JsonSerializer.Serialize(EngineService.GetRulePacks(rules)), JsonSerializer.Serialize(catalog)); + Assert.AreEqual(JsonSerializer.Serialize(EngineService.DescribeRules(rules)), JsonSerializer.Serialize(metadata)); + Assert.AreEqual(selection == "custom" ? 1 : 0, metadata.RulePacks.Count); + Assert.AreEqual(selection == "invalid", metadata.Diagnostics.Count > 0); + foreach (RulePackInfoDto pack in metadata.RulePacks) + { + Assert.AreEqual(pack.RuleCount, catalog.Single(entry => entry.Id == pack.Id).Count); + } + } + /// New predicates preserve finding/threat identities, reports, and exported model semantics. /// The round-trip format. [TestMethod] diff --git a/test/ThreatModelForge.Cli.Tests/McpToolsTest.cs b/test/ThreatModelForge.Cli.Tests/McpToolsTest.cs index 12555d1..4360b47 100644 --- a/test/ThreatModelForge.Cli.Tests/McpToolsTest.cs +++ b/test/ThreatModelForge.Cli.Tests/McpToolsTest.cs @@ -6,9 +6,16 @@ namespace ThreatModelForge.Cli.Tests using System.IO; using System.IO.Compression; using System.Linq; + using System.Text; using System.Text.Json; + using System.Threading; + using System.Threading.Tasks; using Microsoft.Extensions.DependencyInjection; using Microsoft.VisualStudio.TestTools.UnitTesting; + using ModelContextProtocol; + using ModelContextProtocol.Client; + using ModelContextProtocol.Protocol; + using ThreatModelForge.Analysis; using ThreatModelForge.Engine; /// @@ -708,6 +715,272 @@ public void Grounding_Tools_ReturnCatalogs() Assert.IsTrue(McpGroundingTools.Formats().Count > 0); } + /// The formats resource is a stable snapshot of the existing grounding tool's content. + [TestMethod] + public void Grounding_FormatsResourceIsVersionedAndFingerprintable() + { + string snapshot = McpGroundingResources.Formats(); + Assert.AreEqual(snapshot, McpGroundingResources.Formats()); + using JsonDocument document = JsonDocument.Parse(snapshot); + JsonElement root = document.RootElement; + Assert.AreEqual("tmforge-grounding", root.GetProperty("schema").GetString()); + Assert.AreEqual(1, root.GetProperty("version").GetInt32()); + Assert.AreEqual("formats", root.GetProperty("kind").GetString()); + Assert.AreEqual("application/json", root.GetProperty("contentType").GetString()); + Assert.IsFalse(string.IsNullOrEmpty(root.GetProperty("engineVersion").GetString())); + string content = root.GetProperty("content").GetString() ?? string.Empty; + Assert.AreEqual(CliJson.Serialize(McpGroundingTools.Formats()), content); + Assert.AreEqual(RulePackIdentity.CreateFingerprint(Encoding.UTF8.GetBytes(content)), root.GetProperty("fingerprint").GetString()); + } + + /// All fixed resources retain compatibility-tool content and stable cache metadata. + /// The requested catalog. + [TestMethod] + [DataRow("manifest-schema")] + [DataRow("property-schema")] + [DataRow("rule-packs")] + public void Grounding_StaticResourcesMatchTheTools(string kind) + { + Func read = kind switch + { + "manifest-schema" => McpGroundingResources.ManifestSchema, + "property-schema" => McpGroundingResources.PropertySchema, + _ => McpGroundingResources.RulePacks, + }; + string snapshot = read(); + Assert.AreEqual(snapshot, read()); + using JsonDocument document = JsonDocument.Parse(snapshot); + JsonElement root = document.RootElement; + Assert.AreEqual("tmforge-grounding", root.GetProperty("schema").GetString()); + Assert.AreEqual(1, root.GetProperty("version").GetInt32()); + Assert.AreEqual(kind, root.GetProperty("kind").GetString()); + Assert.AreEqual(kind == "manifest-schema" ? "text/plain" : "application/json", root.GetProperty("contentType").GetString()); + string content = root.GetProperty("content").GetString() ?? string.Empty; + Assert.AreEqual(RulePackIdentity.CreateFingerprint(Encoding.UTF8.GetBytes(content)), root.GetProperty("fingerprint").GetString()); + if (kind == "manifest-schema") + { + Assert.AreEqual(McpGroundingTools.ManifestSchema(), content); + StringAssert.Contains(content, "\"schema\": \"" + Manifest.SchemaName + "\""); + StringAssert.Contains(content, "\"version\": " + Manifest.CurrentVersion); + foreach (string field in new[] { "pages", "page", "x", "y", "width", "height", "alias" }) + { + StringAssert.Contains(content, "\"" + field + "\""); + } + } + else if (kind == "property-schema") + { + Assert.AreEqual(CliJson.Serialize(McpGroundingTools.PropertySchema()), content); + } + else + { + using JsonDocument packs = JsonDocument.Parse(content); + IReadOnlyList? actual = packs.RootElement.GetProperty("rulePacks").Deserialize>(new JsonSerializerOptions(JsonSerializerDefaults.Web)); + Assert.AreEqual(CliJson.Serialize(McpGroundingTools.RulePacks(CreateServices(this.WorkingDirectory))), CliJson.Serialize(actual!)); + Assert.AreEqual(0, packs.RootElement.GetProperty("customPacks").GetArrayLength()); + Assert.AreEqual(0, packs.RootElement.GetProperty("diagnostics").GetArrayLength()); + } + } + + /// Custom metadata matches tool catalogs and invalidates pins even when pack counts do not change. + /// Whether the selected source carries v2 pack identity. + [TestMethod] + [DataRow(false)] + [DataRow(true)] + public void Grounding_CustomResourcesTrackContentAndPins(bool versioned) + { + const string path = "custom rules.tmrules.json"; + string json = GroundingRuleJson(versioned, "Before"); + File.WriteAllText(Path.Join(this.WorkingDirectory, path), json); + using ServiceProvider services = CreateServices(this.WorkingDirectory); + string snapshot = McpGroundingResources.CustomRulePacks(services, path); + using JsonDocument envelope = JsonDocument.Parse(snapshot); + string fingerprint = envelope.RootElement.GetProperty("fingerprint").GetString() ?? string.Empty; + string content = envelope.RootElement.GetProperty("content").GetString() ?? string.Empty; + using JsonDocument document = JsonDocument.Parse(content); + JsonElement root = document.RootElement; + Assert.AreEqual(RulePackIdentity.CreateFingerprint(Encoding.UTF8.GetBytes(content)), fingerprint); + Assert.AreEqual(0, root.GetProperty("diagnostics").GetArrayLength()); + Assert.AreEqual(versioned ? 1 : 0, root.GetProperty("customPacks").GetArrayLength()); + Assert.AreEqual(RulePackIdentity.CreateFingerprint(Encoding.UTF8.GetBytes(json)), root.GetProperty("sources")[0].GetProperty("fingerprint").GetString()); + IReadOnlyList? packs = root.GetProperty("rulePacks").Deserialize>(new JsonSerializerOptions(JsonSerializerDefaults.Web)); + Assert.IsNotNull(packs); + Assert.AreEqual(CliJson.Serialize(McpGroundingTools.RulePacks(services, path)), CliJson.Serialize(packs)); + Assert.AreEqual(snapshot, McpGroundingResources.CustomRulePacks(services, path)); + Assert.AreEqual(snapshot, McpGroundingResources.CustomRulePacks(services, path, fingerprint)); + + File.WriteAllText(Path.Join(this.WorkingDirectory, path), GroundingRuleJson(versioned, "After")); + string updated = McpGroundingResources.CustomRulePacks(services, path); + Assert.AreNotEqual(snapshot, updated); + using JsonDocument newEnvelope = JsonDocument.Parse(updated); + Assert.AreNotEqual(fingerprint, newEnvelope.RootElement.GetProperty("fingerprint").GetString()); + McpException mismatch = Assert.Throws(() => McpGroundingResources.CustomRulePacks(services, path, fingerprint)); + StringAssert.Contains(mismatch.Message, "fingerprint mismatch"); + } + + /// A malformed selected pack remains visible instead of presenting a built-ins-only success. + [TestMethod] + public void Grounding_CustomResourcePreservesDiagnostics() + { + const string path = "invalid.tmrules.json"; + File.WriteAllText(Path.Join(this.WorkingDirectory, path), "{ invalid json"); + using ServiceProvider services = CreateServices(this.WorkingDirectory); + using JsonDocument envelope = JsonDocument.Parse(McpGroundingResources.CustomRulePacks(services, path)); + using JsonDocument content = JsonDocument.Parse(envelope.RootElement.GetProperty("content").GetString() ?? string.Empty); + Assert.AreEqual(0, content.RootElement.GetProperty("customPacks").GetArrayLength()); + Assert.IsTrue(content.RootElement.GetProperty("diagnostics").GetArrayLength() > 0); + Assert.AreEqual(1, content.RootElement.GetProperty("sources").GetArrayLength()); + Assert.IsFalse(string.IsNullOrWhiteSpace(content.RootElement.GetProperty("sources")[0].GetProperty("fingerprint").GetString())); + } + + /// Resource reads retain file-tool path and byte limits, including pinned rereads. + [TestMethod] + public void Grounding_CustomResourceEnforcesSandboxAndLimits() + { + using ServiceProvider services = CreateServices(this.WorkingDirectory); + Assert.Throws(() => McpGroundingResources.CustomRulePacks(services, " ")); + Assert.Throws(() => McpGroundingResources.CustomRulePacks(services, "rules.tmrules.json", "invalid")); + Assert.Throws(() => McpGroundingResources.CustomRulePacks(services, "../outside.tmrules.json")); + Assert.Throws(() => McpGroundingResources.CustomRulePacks(services, "missing.tmrules.json")); + + string path = Path.Join(this.WorkingDirectory, "rules.tmrules.json"); + File.WriteAllText(path, GroundingRuleJson(versioned: true, "Read limit")); + string snapshot = McpGroundingResources.CustomRulePacks(services, "rules.tmrules.json"); + using JsonDocument document = JsonDocument.Parse(snapshot); + string fingerprint = document.RootElement.GetProperty("fingerprint").GetString() ?? string.Empty; + using ServiceProvider limited = CreateServices(this.WorkingDirectory, maxReadBytes: 16); + Assert.Throws(() => McpGroundingResources.CustomRulePacks(limited, "rules.tmrules.json", fingerprint)); + File.Delete(path); + Assert.Throws(() => McpGroundingResources.CustomRulePacks(services, "rules.tmrules.json", fingerprint)); + } + + /// A matching pin cannot authorize a file that now resolves outside the configured root. + [TestMethod] + public void Grounding_PinnedResourcesStillRejectSymlinkEscapes() + { + const string path = "rules.tmrules.json"; + string fullPath = Path.Join(this.WorkingDirectory, path); + string outside = this.WorkingDirectory + "-outside.tmrules.json"; + string json = GroundingRuleJson(versioned: true, "Same content"); + File.WriteAllText(fullPath, json); + using ServiceProvider services = CreateServices(this.WorkingDirectory); + using JsonDocument envelope = JsonDocument.Parse(McpGroundingResources.CustomRulePacks(services, path)); + string fingerprint = envelope.RootElement.GetProperty("fingerprint").GetString() ?? string.Empty; + File.WriteAllText(outside, json); + File.Delete(fullPath); + try + { + File.CreateSymbolicLink(fullPath, outside); + Assert.Throws(() => McpGroundingResources.CustomRulePacks(services, path, fingerprint)); + } + finally + { + File.Delete(fullPath); + File.Delete(outside); + } + } + + /// Pins use one canonical spelling and cannot turn malformed values into unpinned reads. + [TestMethod] + public void Grounding_RejectsMalformedFingerprintPins() + { + using ServiceProvider services = CreateServices(this.WorkingDirectory); + foreach (string pin in new[] { string.Empty, "sha256:" + new string('a', 63), "SHA256:" + new string('a', 64), "sha256:" + new string('g', 64) }) + { + Assert.Throws(() => McpGroundingResources.CustomRulePacks(services, "rules.tmrules.json", pin)); + } + } + + /// The real stdio host discovers and reads resources while retaining grounding tools. + /// A task. + [TestMethod] + public async Task Grounding_ResourcesAreDiscoverableOverStdio() + { + using CancellationTokenSource deadline = new CancellationTokenSource(TimeSpan.FromSeconds(30)); + await using McpClient client = await this.StartGroundingClient(deadline.Token); + Assert.IsNotNull(client.ServerCapabilities.Resources); + Dictionary> expected = new Dictionary>(StringComparer.Ordinal) + { + ["tmforge://grounding/v1/formats"] = McpGroundingResources.Formats, + ["tmforge://grounding/v1/property-schema"] = McpGroundingResources.PropertySchema, + ["tmforge://grounding/v1/manifest-schema"] = McpGroundingResources.ManifestSchema, + ["tmforge://grounding/v1/rule-packs"] = McpGroundingResources.RulePacks, + }; + IList resources = await client.ListResourcesAsync(cancellationToken: deadline.Token); + CollectionAssert.AreEquivalent(expected.Keys.ToArray(), resources.Select(resource => resource.Uri).ToArray()); + foreach (McpClientResource resource in resources) + { + Assert.AreEqual("application/json", resource.MimeType); + ReadResourceResult result = await client.ReadResourceAsync(resource.Uri, cancellationToken: deadline.Token); + TextResourceContents text = (TextResourceContents)result.Contents.Single(); + Assert.AreEqual(resource.Uri, text.Uri); + Assert.AreEqual("application/json", text.MimeType); + Assert.AreEqual(expected[resource.Uri](), text.Text); + } + + IList templates = await client.ListResourceTemplatesAsync(cancellationToken: deadline.Token); + Assert.AreEqual("tmforge://grounding/v1/rule-packs/custom{?rulesPath,fingerprint}", templates.Single().UriTemplate); + IList tools = await client.ListToolsAsync(cancellationToken: deadline.Token); + foreach (string name in new[] { "formats", "property_schema", "manifest_schema", "rule_packs", "rules", "stencils" }) + { + Assert.IsTrue(tools.Any(tool => tool.Name == name), name); + } + + CallToolResult legacy = await client.CallToolAsync("formats", cancellationToken: deadline.Token); + Assert.IsFalse(legacy.IsError == true); + string json = ((TextContentBlock)legacy.Content.Single()).Text; + IReadOnlyList? formats = JsonSerializer.Deserialize>(json, new JsonSerializerOptions(JsonSerializerDefaults.Web)); + Assert.IsNotNull(formats); + Assert.AreEqual(CliJson.Serialize(McpGroundingTools.Formats()), CliJson.Serialize(formats)); + await Assert.ThrowsAsync(() => client.ReadResourceAsync("tmforge://grounding/v99/formats", cancellationToken: deadline.Token).AsTask()); + } + + /// URI parameters preserve file names and cache pins without bypassing the file sandbox. + /// A task. + [TestMethod] + public async Task Grounding_CustomResourceUrisWorkOverStdio() + { + const string path = "policy files/custom +#%.tmrules.json"; + string fullPath = Path.Join(this.WorkingDirectory, path); + Directory.CreateDirectory(Path.Join(this.WorkingDirectory, "policy files")); + File.WriteAllText(fullPath, GroundingRuleJson(versioned: true, "Before")); + using CancellationTokenSource deadline = new CancellationTokenSource(TimeSpan.FromSeconds(30)); + await using McpClient client = await this.StartGroundingClient(deadline.Token); + using ServiceProvider services = CreateServices(this.WorkingDirectory); + const string resource = "tmforge://grounding/v1/rule-packs/custom"; + string uri = resource + "?rulesPath=" + Uri.EscapeDataString(path); + ReadResourceResult result = await client.ReadResourceAsync(uri, cancellationToken: deadline.Token); + TextResourceContents first = (TextResourceContents)result.Contents.Single(); + Assert.AreEqual(uri, first.Uri); + Assert.AreEqual("application/json", first.MimeType); + Assert.AreEqual(McpGroundingResources.CustomRulePacks(services, path), first.Text); + using JsonDocument envelope = JsonDocument.Parse(first.Text); + string fingerprint = envelope.RootElement.GetProperty("fingerprint").GetString() ?? string.Empty; + string pinnedUri = uri + "&fingerprint=" + Uri.EscapeDataString(fingerprint); + ReadResourceResult pinned = await client.ReadResourceAsync(pinnedUri, cancellationToken: deadline.Token); + Assert.AreEqual(first.Text, ((TextResourceContents)pinned.Contents.Single()).Text); + + CallToolResult legacy = await client.CallToolAsync("rule_packs", new Dictionary { ["rulesPath"] = path }, cancellationToken: deadline.Token); + Assert.IsFalse(legacy.IsError == true); + StringAssert.Contains(((TextContentBlock)legacy.Content.Single()).Text, "cached-policy"); + File.WriteAllText(fullPath, GroundingRuleJson(versioned: true, "After")); + ReadResourceResult updated = await client.ReadResourceAsync(uri, cancellationToken: deadline.Token); + Assert.AreNotEqual(first.Text, ((TextResourceContents)updated.Contents.Single()).Text); + McpException mismatch = await Assert.ThrowsAsync(() => client.ReadResourceAsync(pinnedUri, cancellationToken: deadline.Token).AsTask()); + StringAssert.Contains(mismatch.Message, "fingerprint mismatch"); + + string escapeUri = resource + "?rulesPath=" + Uri.EscapeDataString("../outside.tmrules.json"); + await Assert.ThrowsAsync(() => client.ReadResourceAsync(escapeUri, cancellationToken: deadline.Token).AsTask()); + await Assert.ThrowsAsync(() => client.ReadResourceAsync(resource, cancellationToken: deadline.Token).AsTask()); + File.WriteAllText(fullPath, "{ invalid json"); + ReadResourceResult invalid = await client.ReadResourceAsync(uri, cancellationToken: deadline.Token); + using JsonDocument invalidEnvelope = JsonDocument.Parse(((TextResourceContents)invalid.Contents.Single()).Text); + using JsonDocument invalidContent = JsonDocument.Parse(invalidEnvelope.RootElement.GetProperty("content").GetString() ?? string.Empty); + Assert.AreEqual(0, invalidContent.RootElement.GetProperty("customPacks").GetArrayLength()); + Assert.IsTrue(invalidContent.RootElement.GetProperty("diagnostics").GetArrayLength() > 0); + File.Delete(fullPath); + await Assert.ThrowsAsync(() => client.ReadResourceAsync(pinnedUri, cancellationToken: deadline.Token).AsTask()); + } + /// /// Verifies that a property map is marshaled into the KEY=VALUE assignment list. /// @@ -777,6 +1050,14 @@ public void RemoveThreat_DeletesManualOverlayEntry() Assert.AreEqual(id, removed.Removed!.Single()); } + private static string GroundingRuleJson(bool versioned, string message) + { + string rule = "{\"id\":\"CACHE\",\"appliesTo\":\"process\",\"message\":\"" + message + "\",\"when\":{\"property\":\"Isolation\"}}"; + return versioned + ? "{\"schema\":\"tmforge-rules\",\"version\":2,\"dialect\":\"urn:tmforge:rules:flat-v1\",\"pack\":{\"id\":\"cached-policy\",\"name\":\"Cached policy\",\"version\":\"1.0\"},\"properties\":[{\"name\":\"Isolation\"}],\"rules\":[" + rule + "]}" + : "{\"rules\":[" + rule + "]}"; + } + private static TmForgeModelDto ModelWithSpoofingThreat() { const string externalId = "11111111-1111-4111-8111-111111111111"; @@ -881,5 +1162,19 @@ private static void WriteZipEntry(ZipArchive archive, string name, string conten using StreamWriter writer = new StreamWriter(entry.Open()); writer.Write(content); } + + private Task StartGroundingClient(CancellationToken cancellationToken) + { + StdioClientTransport transport = new StdioClientTransport(new StdioClientTransportOptions + { + Command = Environment.GetEnvironmentVariable("DOTNET_HOST_PATH") ?? "dotnet", + Arguments = new[] { typeof(McpGroundingResources).Assembly.Location, "mcp", "--root", this.WorkingDirectory }, + WorkingDirectory = this.WorkingDirectory, + InheritEnvironmentVariables = false, + EnvironmentVariables = StdioClientTransportOptions.GetDefaultEnvironmentVariables(), + ShutdownTimeout = TimeSpan.FromSeconds(3), + }); + return McpClient.CreateAsync(transport, cancellationToken: cancellationToken); + } } }