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
36 changes: 36 additions & 0 deletions docs/cli-reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@ tmforge <command> [options] <file>
| [`convert`](#convert) | Convert | Convert a model between file formats. |
| [`apply`](#apply) | Author | Build a model from a declarative JSON manifest (all-or-nothing). |
| [`export`](#export) | Author | Export a model as a declarative JSON manifest. |
| [`mcp`](#mcp) | Agent | Run an MCP server over stdio (exposes the engine + authoring facade as tools). |

---

Expand Down Expand Up @@ -669,6 +670,41 @@ tmforge export payments.tm7 | jq '.elements'

---

## Agent / MCP server

### mcp

`tmforge mcp` runs a [Model Context Protocol](https://modelcontextprotocol.io/) server over stdio, so
an AI agent can drive Threat Model Forge with the same engine Studio and the CLI use. It projects the
stateless engine and authoring facade as MCP tools — each takes a canonical `tmforge-json` model in
and returns the edited model (or findings, threats, or a report) out, so there is no server-side
session state.

```text
tmforge mcp
```

Configure your MCP client to launch the tool:

```json
{
"mcpServers": {
"tmforge": { "command": "tmforge", "args": ["mcp"] }
}
}
```

**Tools.** Grounding: `formats`, `stencils`, `property_schema`, `rules`, `rule_packs`,
`manifest_schema`, `detect`. Model I/O and analysis: `read`, `save`, `analyze`, `threats`, `report`,
`merge`. Authoring: `apply`, `export_manifest`, `add`, `connect`, `set`, `rename`, `remove`.

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.

---

## JSON output

With `--json`, every command emits a single **versioned envelope** to stdout:
Expand Down
269 changes: 269 additions & 0 deletions docs/feasibility-review-2026-07.md

Large diffs are not rendered by default.

1 change: 1 addition & 0 deletions src/ThreatModelForge.Cli/AcceptCommand.cs
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ namespace ThreatModelForge.Cli
using System;
using System.IO;
using ThreatModelForge.Analysis;
using ThreatModelForge.Engine;
using ThreatModelForge.Formats;
using ThreatModelForge.Model;

Expand Down
162 changes: 33 additions & 129 deletions src/ThreatModelForge.Cli/AddCommand.cs
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ namespace ThreatModelForge.Cli
using System.Globalization;
using System.IO;
using ThreatModelForge.Editing;
using ThreatModelForge.Engine;
using ThreatModelForge.Formats;
using ThreatModelForge.Model;
using ThreatModelForge.Model.Abstracts;
Expand Down Expand Up @@ -44,19 +45,11 @@ public static int Run(string[] args)
}

string? stencilId = parsed.Get("stencil");
StencilDto? stencil = null;
StencilKind kind;
string? kindNoun = null;
string input;

if (!string.IsNullOrEmpty(stencilId))
{
stencil = StencilCatalog.Find(stencilId!);
if (stencil == null)
{
Console.Error.WriteLine("Unknown stencil: " + stencilId + " (run 'tmforge stencils' to list available stencils).");
return 1;
}

if (parsed.Positionals.Count > 1)
{
Console.Error.WriteLine("Specify either an element kind or --stencil, not both.");
Expand All @@ -69,12 +62,6 @@ public static int Run(string[] args)
return 1;
}

if (!AuthoringSupport.TryParseKind(stencil.Base, out kind))
{
Console.Error.WriteLine("Stencil '" + stencil.Id + "' has an unrecognized base primitive: " + stencil.Base + ".");
return 1;
}

input = parsed.Positionals[0];
}
else
Expand All @@ -85,14 +72,14 @@ public static int Run(string[] args)
return 1;
}

string kindText = parsed.Positionals[0];
kindNoun = parsed.Positionals[0];
input = parsed.Positionals[1];
}

if (!AuthoringSupport.TryParseKind(kindText, out kind))
{
Console.Error.WriteLine("Unknown element kind: " + kindText + " (expected process, store, external, or boundary).");
return 1;
}
if (!AuthoringSupport.TryResolveKind(kindNoun, stencilId, out StencilKind kind, out StencilDto? stencil, out string? kindError))
{
Console.Error.WriteLine(kindError);
return 1;
}

if (!File.Exists(input))
Expand All @@ -108,125 +95,42 @@ public static int Run(string[] args)
return 1;
}

string? pageSpec = parsed.Get("page");
DrawingSurfaceModel diagram;
if (string.IsNullOrEmpty(pageSpec))
{
diagram = AuthoringSupport.GetOrCreateFirstDiagram(model);
}
else if (AuthoringSupport.TryResolveDiagram(model, pageSpec!, out DrawingSurfaceModel? resolved, out string? pageError))
{
diagram = resolved!;
}
else
{
Console.Error.WriteLine(pageError);
AddRequest request = new AddRequest
{
Kind = kind,
Stencil = stencil,
Name = parsed.Get("name"),
Page = parsed.Get("page"),
Left = TryGetInt(parsed, "left", out int parsedLeft) ? parsedLeft : null,
Top = TryGetInt(parsed, "top", out int parsedTop) ? parsedTop : null,
Width = TryGetInt(parsed, "width", out int parsedWidth) ? parsedWidth : null,
Height = TryGetInt(parsed, "height", out int parsedHeight) ? parsedHeight : null,
Alias = parsed.Get("alias"),
Boundary = parsed.Get("boundary"),
Properties = parsed.Properties,
Force = parsed.HasFlag("force"),
};
if (!AuthoringOperations.Add(model, request, out Guid id, out IReadOnlyList<string> warnings, out string? error))
{
Console.Error.WriteLine(error);
return 1;
}

DiagramEditor editor = new DiagramEditor(model);

(int defaultLeft, int defaultTop) = AuthoringSupport.NextPosition(diagram);
int left = TryGetInt(parsed, "left", out int parsedLeft) ? parsedLeft : defaultLeft;
int top = TryGetInt(parsed, "top", out int parsedTop) ? parsedTop : defaultTop;
bool hasWidth = TryGetInt(parsed, "width", out int argWidth);
bool hasHeight = TryGetInt(parsed, "height", out int argHeight);

Guid id = editor.AddElement(diagram, kind, left, top);
if (kind == StencilKind.TrustBoundary)
{
editor.ResizeElement(diagram, id, left, top, hasWidth ? argWidth : 260, hasHeight ? argHeight : 180);
}
else if (hasWidth || hasHeight)
{
DrawingElement? placed = DiagramEditor.FindElement(diagram, id) as DrawingElement;
editor.ResizeElement(diagram, id, left, top, hasWidth ? argWidth : placed?.Width ?? 100, hasHeight ? argHeight : placed?.Height ?? 60);
}

string? name = parsed.Get("name");
if (string.IsNullOrEmpty(name) && stencil != null)
{
name = stencil.Label;
}

if (!string.IsNullOrEmpty(name))
{
editor.SetElementName(diagram, id, name!);
}

Entity? added = DiagramEditor.FindElement(diagram, id);
if (stencil != null && added != null)
foreach (string warning in warnings)
{
DiagramElementHelper.SetCustomProperty(added, "StencilType", stencil.Id);
foreach (KeyValuePair<string, string> preset in stencil.Defaults)
{
DiagramElementHelper.SetCustomProperty(added, preset.Key, preset.Value);
}
Console.Error.WriteLine(warning);
}

if (added != null && parsed.Properties.Count > 0)
{
if (!AuthoringSupport.TryApplyProperties(added, parsed.Properties, AuthoringSupport.SchemaBase(kind), parsed.HasFlag("force"), out string? propertyError, out IReadOnlyList<string> propertyWarnings))
{
Console.Error.WriteLine(propertyError);
return 1;
}

foreach (string warning in propertyWarnings)
{
Console.Error.WriteLine(warning);
}
}

string? boundaryRef = parsed.Get("boundary");
if (!string.IsNullOrEmpty(boundaryRef) && added is DrawingElement placedComponent)
{
if (!AuthoringSupport.TryResolveElementId(model, diagram, boundaryRef!, out Guid boundaryId, out string? boundaryError))
{
Console.Error.WriteLine(boundaryError);
return 1;
}

Entity? boundaryEntity = DiagramEditor.FindElement(diagram, boundaryId);
if (boundaryEntity is not BorderBoundary boundaryBox)
{
Console.Error.WriteLine("--boundary must reference a trust boundary (run 'tmforge list boundaries " + input + "').");
return 1;
}

IReadOnlyDictionary<string, string> boundaryProps = DiagramElementHelper.GetCustomProperties(boundaryEntity);
string boundaryName = DiagramElementHelper.GetName(boundaryEntity);
string membershipKey = boundaryProps.TryGetValue(AuthoringSupport.AliasPropertyName, out string? boundaryAlias) && !string.IsNullOrEmpty(boundaryAlias)
? boundaryAlias!
: (string.IsNullOrWhiteSpace(boundaryName) ? boundaryRef! : boundaryName);
int memberIndex = AuthoringSupport.CountBoundaryMembers(diagram, membershipKey);
(int insideLeft, int insideTop) = AuthoringSupport.PositionInsideBoundary(boundaryBox, memberIndex);
editor.ResizeElement(diagram, id, insideLeft, insideTop, placedComponent.Width, placedComponent.Height);
DiagramElementHelper.SetCustomProperty(added, AuthoringSupport.BoundaryPropertyName, membershipKey);
}

string? alias = parsed.Get("alias");
if (!string.IsNullOrEmpty(alias) && added != null)
{
Guid desired = AuthoringSupport.DeterministicId(alias!);
if (desired != id && AuthoringSupport.FindDiagramContaining(model, desired) != null)
{
Console.Error.WriteLine("Alias '" + alias + "' already maps to an existing element in this model; aliases must be unique.");
return 1;
}

DiagramElementHelper.SetCustomProperty(added, AuthoringSupport.AliasPropertyName, alias!);
AuthoringSupport.RekeyComponent(diagram, id, desired);
id = desired;
}
AuthoringSupport.Save(model, input, format);

DrawingSurfaceModel? placedDiagram = AuthoringSupport.FindDiagramContaining(model, id);
Entity? added = placedDiagram != null ? DiagramEditor.FindElement(placedDiagram, id) : null;
string effectiveName = added != null ? DiagramElementHelper.GetName(added) : string.Empty;

AuthoringSupport.Save(model, input, format);
string? alias = parsed.Get("alias");

if (parsed.Json)
{
CliJson.WriteEnvelope("add", new { id, kind, name = effectiveName, stencil = stencil?.Id, diagramId = diagram.Guid, alias });
CliJson.WriteEnvelope("add", new { id, kind, name = effectiveName, stencil = stencil?.Id, diagramId = placedDiagram?.Guid, alias });
}
else
{
Expand Down
1 change: 1 addition & 0 deletions src/ThreatModelForge.Cli/ApplyCommand.cs
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ namespace ThreatModelForge.Cli
using System;
using System.IO;
using System.Text.Json;
using ThreatModelForge.Engine;
using ThreatModelForge.Formats;
using ThreatModelForge.Model;

Expand Down
1 change: 1 addition & 0 deletions src/ThreatModelForge.Cli/CommandCatalog.cs
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ internal static class CommandCatalog
new CommandInfo("apply", "Build a model from a declarative JSON manifest (all-or-nothing).", "output, format, dryRun, boundaries, elements, flows", ApplyCommand.Run),
new CommandInfo("export", "Export a model as a declarative JSON manifest.", "output, boundaries, elements, flows", ExportCommand.Run),
new CommandInfo("git-setup", "Wire git to use tmforge for .tm7 diff/merge (or --print the commands).", null, GitSetupCommand.Run),
new CommandInfo("mcp", "Run an MCP server over stdio for AI agents (exposes the engine + authoring facade as tools).", null, McpCommand.Run),
};

private static readonly IReadOnlyDictionary<string, CommandInfo> ByVerb = Index(All);
Expand Down
79 changes: 13 additions & 66 deletions src/ThreatModelForge.Cli/ConnectCommand.cs
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,9 @@ namespace ThreatModelForge.Cli
using System;
using System.Collections.Generic;
using System.IO;
using ThreatModelForge.Editing;
using ThreatModelForge.Engine;
using ThreatModelForge.Formats;
using ThreatModelForge.Model;
using ThreatModelForge.Model.Abstracts;

/// <summary>
/// Implements the <c>tmforge connect</c> command: adds a data-flow connector between two
Expand Down Expand Up @@ -70,76 +69,24 @@ public static int Run(string[] args)
return 1;
}

string? pageSpec = parsed.Get("page");
DrawingSurfaceModel? diagram;
if (string.IsNullOrEmpty(pageSpec))
ConnectRequest request = new ConnectRequest
{
diagram = AuthoringSupport.FirstDiagram(model);
}
else if (AuthoringSupport.TryResolveDiagram(model, pageSpec!, out DrawingSurfaceModel? resolved, out string? pageError))
{
diagram = resolved;
}
else
{
Console.Error.WriteLine(pageError);
return 1;
}

if (diagram == null)
{
Console.Error.WriteLine("The model has no diagram to connect within.");
return 1;
}

if (!AuthoringSupport.TryResolveElementId(model, diagram, sourceText!, out Guid source, out string? sourceError))
{
Console.Error.WriteLine(sourceError);
return 1;
}

if (!AuthoringSupport.TryResolveElementId(model, diagram, targetText!, out Guid target, out string? targetError))
{
Console.Error.WriteLine(targetError);
return 1;
}

if (!diagram.Borders.ContainsKey(source))
{
Console.Error.WriteLine("Source element not found on this page: " + sourceText);
return 1;
}

if (!diagram.Borders.ContainsKey(target))
Source = sourceText!,
Target = targetText!,
Name = parsed.Get("name"),
Page = parsed.Get("page"),
Properties = parsed.Properties,
Force = parsed.HasFlag("force"),
};
if (!AuthoringOperations.Connect(model, request, out Guid id, out Guid source, out Guid target, out IReadOnlyList<string> warnings, out string? error))
{
Console.Error.WriteLine("Target element not found on this page: " + targetText);
Console.Error.WriteLine(error);
return 1;
}

DiagramEditor editor = new DiagramEditor(model);
Guid id = editor.AddConnector(diagram, source, target);
string? name = parsed.Get("name");
if (!string.IsNullOrEmpty(name))
foreach (string warning in warnings)
{
editor.SetElementName(diagram, id, name!);
}

if (parsed.Properties.Count > 0)
{
Entity? flow = DiagramEditor.FindElement(diagram, id);
if (flow != null)
{
if (!AuthoringSupport.TryApplyProperties(flow, parsed.Properties, "flow", parsed.HasFlag("force"), out string? propertyError, out IReadOnlyList<string> propertyWarnings))
{
Console.Error.WriteLine(propertyError);
return 1;
}

foreach (string warning in propertyWarnings)
{
Console.Error.WriteLine(warning);
}
}
Console.Error.WriteLine(warning);
}

AuthoringSupport.Save(model, input!, format);
Expand Down
1 change: 1 addition & 0 deletions src/ThreatModelForge.Cli/ExportCommand.cs
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ namespace ThreatModelForge.Cli
{
using System;
using System.IO;
using ThreatModelForge.Engine;
using ThreatModelForge.Model;

/// <summary>
Expand Down
Loading
Loading