Skip to content
Merged
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
76 changes: 73 additions & 3 deletions docs/cli-reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -1091,8 +1091,8 @@ tmforge mcp [--root <path>] [--max-read-bytes <n>] [--max-write-bytes <n>]

| Option | Default | Meaning |
| --- | --- | --- |
| `--root <path>` | 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 <n>` | `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 <path>` | 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 <n>` | `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 <n>` | `67108864` (64 MiB) | Maximum serialized output accepted by `save`, enforced while the format writes. |

Configure your MCP client to launch the tool:
Expand Down Expand Up @@ -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
Expand Down
7 changes: 6 additions & 1 deletion src/ThreatModelForge.Cli/McpCommand.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
Expand All @@ -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\"].");
}

Expand Down
115 changes: 115 additions & 0 deletions src/ThreatModelForge.Cli/McpGroundingResources.cs
Original file line number Diff line number Diff line change
@@ -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;

/// <summary>Versioned, fingerprinted grounding snapshots for MCP resource clients.</summary>
[McpServerResourceType]
public static class McpGroundingResources
{
private static readonly Lazy<string> FormatSnapshot = new Lazy<string>(
() => Snapshot("formats", "application/json", CliJson.Serialize(McpGroundingTools.Formats())));

private static readonly Lazy<string> PropertySnapshot = new Lazy<string>(
() => Snapshot("property-schema", "application/json", CliJson.Serialize(McpGroundingTools.PropertySchema())));

private static readonly Lazy<string> ManifestSnapshot = new Lazy<string>(
() => Snapshot("manifest-schema", "text/plain", McpGroundingTools.ManifestSchema()));

private static readonly Lazy<string> BuiltInPackSnapshot = new Lazy<string>(
() => RulePackSnapshot(null));

/// <summary>Reads the supported format catalog without invoking an MCP tool.</summary>
/// <returns>The versioned format snapshot.</returns>
[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;

/// <summary>Reads the typed element and flow property catalog.</summary>
/// <returns>The versioned property snapshot.</returns>
[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;

/// <summary>Reads the manifest authoring guide shared with the compatibility tool.</summary>
/// <returns>A versioned text guide, not a JSON Schema validation document.</returns>
[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;

/// <summary>Reads metadata for the built-in analysis rule packs.</summary>
/// <returns>The versioned built-in rule-pack snapshot.</returns>
[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;

/// <summary>Reads effective pack metadata for one sandboxed custom rule file.</summary>
/// <param name="services">The request services containing the workspace path policy.</param>
/// <param name="rulesPath">The custom rule file inside the workspace root.</param>
/// <param name="fingerprint">An optional expected snapshot fingerprint.</param>
/// <returns>The current versioned metadata, or an error when a pin no longer matches.</returns>
[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<RulePackDto> catalog);
string content = CliJson.Serialize(new
{
rulePacks = catalog,
customPacks = metadata.RulePacks,
diagnostics = metadata.Diagnostics,
sources = (rules?.Sources ?? Array.Empty<RuleSourceDto>()).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<AssemblyInformationalVersionAttribute>()?.InformationalVersion ?? string.Empty,
contentType,
fingerprint,
content,
}));
}
}
}
7 changes: 7 additions & 0 deletions src/ThreatModelForge.Cli/McpToolSupport.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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.";
Expand Down
Loading
Loading