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
26 changes: 22 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ The plugin bundles a Go MCP server that exposes the full Simulator.Company publi
| `simulator-graph` | "create actor", "link nodes", "add to layer" | Actors, links, layers, graph traversal, bulk push/pull |
| `simulator-forms` | "create form", "design template", "Account Template" | Form templates (Account Templates), field classes, system forms |
| `simulator-actors` | "create a record", "fill in a template", "update actor data" | Actor instances of a form, the `data` value protocol, search & filter |
| `simulator-smart-forms` | "smart form", "CDU", "edit page config", "push smart form" | Smart Form lifecycle, pages, CDU protocol, releases |
| `simulator-finance` | "record transaction", "account balance", "transfer funds"| Accounts, transactions, transfers, currencies |

## Requirements
Expand Down Expand Up @@ -182,10 +183,6 @@ the actor/node items.)
| Search | `searchAll` (global text/semantic search across actors & users) |
| Setup | `set-environment` (cloud preset or custom/local URL) `login` `getWorkspaces` `set-workspace` (by accId or name) |

> **Applications / Smart Forms (CDU)** are **documented** (see
> [`docs/user-flows/smart-forms.md`](plugins/simulator/docs/user-flows/smart-forms.md)) and
> covered by the public spec, but their MCP tools are **not registered at this stage**. The
> `appOps` definitions live in `internal/tools/apps.go`, ready to re-enable.

**Engine tools** (multi-call workflows + client-side computation):

Expand All @@ -197,6 +194,18 @@ the actor/node items.)
| `compactGraphLayout` | Auto-layout a layer into domain-clustered grids (replaces the pull → edit → push loop) |
| `pruneLongEdges` | Delete edges longer than a distance threshold; preserves hierarchy edges |
| `uploadActorPicture` / `uploadActorPictureBulk` | Set actor pictures from URL / file / base64; auto-rasterise SVG → PNG; bulk dedupes by SHA-256 |
| `createSmartForm` | Create a new Smart Form actor with develop + production environments |
| `pullSmartForm` | Download all env file trees of a Smart Form to `<actorId>/<env>/` with `.manifest.json` |
| `pushSmartForm` | Diff local develop files against `.manifest.json`, validate, and push changed files in one batch |
| `deploySmartForm` | Deploy one Smart Form env to another (develop → production); creates a new release |
| `listReleases` | List releases for a Smart Form environment |
| `diffReleases` | Show added / removed / modified files between two releases |
| `rollbackRelease` | Roll back to a prior release (forward-only: creates a new active release) |
| `getFileHistory` | List version history for a Smart Form file (fileId from `.manifest.json`) |
| `getFileVersion` | Fetch the source of one specific file version |
| `rollbackFile` | Restore a file to a prior version |
| `listTrash` | List soft-deleted objects in a Smart Form environment |
| `restoreFromTrash` | Restore a soft-deleted object from trash |
| `createChart` | Create a dashboard chart actor (dynamic `actorFilter` or explicit accounts mode) |

## Architecture
Expand Down Expand Up @@ -266,6 +275,14 @@ Specialist for actor instances (the *records* of a form / Account Template):
- Read by UUID or `(formId, ref)`, set status, delete
- Search across the workspace (`searchActors`) and list/rank a form's actors, optionally by account balance (`filterActors`)

### `/simulator-smart-forms`
Specialist for Smart Form (CDU / Script / Application) authoring:
- Pull all env files to disk with `pullSmartForm`, push changes with `pushSmartForm`
- Edit page `config` (grid → form → section → item), `locale`, `viewModel`, `definitions`, `styles`
- Full CDU page protocol: 28 component types, templating (`[[locale]]` / `{{viewModel}}` / `$ref`), change protocol (200/205/302)
- Deploy `develop → production` as an immutable release, list/diff/rollback releases
- File history, version restore, and trash management

### `/simulator-finance`
Specialist for financial and metric tracking:
- Set up currencies and account name categories
Expand Down Expand Up @@ -321,6 +338,7 @@ simulator-ai-plugin/
│ ├── simulator-graph/ # Graph specialist skill
│ ├── simulator-forms/ # Forms (Account Templates) specialist skill
│ ├── simulator-actors/ # Actor-instance / data-protocol specialist skill
│ ├── simulator-smart-forms/ # Smart Form (CDU) authoring specialist skill
│ ├── simulator-finance/ # Finance specialist skill
│ └── simulator-charts/ # Dashboard charts specialist skill
└── docs/ # Plugin-shipped reference (referenced by skills)
Expand Down
22 changes: 15 additions & 7 deletions docs/ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ files and the [entity docs](../plugins/simulator/docs/entities/README.md).
│ ├── simulator-init │ domain knowledge + tool-call │
│ ├── simulator-graph │ guidance is injected into the │
│ ├── simulator-forms ├─▶ model context; the model then │
│ ├── simulator-smart-forms │ calls MCP tools over stdio │
│ ├── simulator-actors │ calls MCP tools over stdio │
│ ├── simulator-finance │ │
│ └── simulator-charts ──┘ │
Expand Down Expand Up @@ -180,7 +181,7 @@ Tools are **declared in Go**, not generated from a spec at runtime. `op.go` defi
`Operation` (name = operationId, HTTP method, path template, typed `Param`s) and a generic
`register()` that turns any `Operation` into a typed MCP tool whose handler maps
arguments → path/query/body → one `apiclient.Do` call. The per-domain files
(`forms.go`, `actors.go`, `accounts.go`, `transactions.go`, `graph.go`, `apps.go`) each
(`forms.go`, `actors.go`, `accounts.go`, `transactions.go`, `graph.go`) each
declare a slice of `Operation`s; `build.go` registers them all plus the `set-environment` /
`login` / `set-workspace` helpers.

Expand Down Expand Up @@ -238,11 +239,6 @@ backend operation, with typed parameters:
| Search | `searchAll` (global text/semantic search across actors & users) |
| Setup | `set-environment` (cloud preset or custom/local URL; derives the account URL from the gateway's public config) `login` `getWorkspaces` `set-workspace` (by accId or name) |

> **Applications / Smart Forms (CDU)** are documented
> (`docs/user-flows/smart-forms.md`) and carried in the public spec
> (`testdata/papi-openapi.json`), but their MCP tools are **not registered at this stage**.
> The `appOps` definitions remain in `internal/tools/apps.go` (referenced but excluded from
> `allOps()`), ready to re-enable when the tools are wanted.

**Engine tools** (`internal/engines`) — multi-call workflows and client-side computation
ported from the original implementation:
Expand All @@ -255,7 +251,19 @@ ported from the original implementation:
| `compactGraphLayout` | `compact_layout.go` | Auto-layout a layer into domain-clustered grids |
| `pruneLongEdges` | `prune_edges.go` | Delete edges longer than a distance threshold; preserves hierarchy |
| `uploadActorPicture(Bulk)`| `upload.go` + `svg.go` | Set actor pictures (URL/file/base64); auto-rasterise SVG→PNG |
| `createChart` | `create_chart.go` | Create a dashboard chart actor (dynamic filter or explicit accounts) |
| `createSmartForm` | `create_smart_form.go` | Create Smart Form actor with develop + production envs |
| `pullSmartForm` | `pull_smart_form.go` | Download all env file trees of a Smart Form to `<actorId>/<env>/` with `.manifest.json` |
| `pushSmartForm` | `push_smart_form.go` | Diff local develop files against `.manifest.json`, validate, and push changes in one batch |
| `deploySmartForm` | `smart_form_releases.go` | Deploy one env to another; resolves env names to IDs internally |
| `listReleases` | `smart_form_releases.go` | List releases for a Smart Form env |
| `diffReleases` | `smart_form_releases.go` | Diff two releases (added/removed/modified, by source_hash) |
| `rollbackRelease` | `smart_form_releases.go` | Roll back to a prior release (forward-only) |
| `getFileHistory` | `smart_form_file_history.go` | List version history for a Smart Form file |
| `getFileVersion` | `smart_form_file_history.go` | Fetch source of one file version |
| `rollbackFile` | `smart_form_file_history.go` | Restore a file to a prior version |
| `listTrash` | `smart_form_file_history.go` | List soft-deleted objects in an env |
| `restoreFromTrash` | `smart_form_file_history.go` | Restore a soft-deleted object from trash |
| `createChart` | `create_chart.go` | Create a dashboard chart actor (dynamic filter or explicit accounts) |

Engines share a small runtime config (`engines.Configure`: base URL + TLS) and read the
auth header / `WORKSPACE_ID` per call. The graph sync (`sync_graph.go` + `push_graph.go`,
Expand Down
7 changes: 6 additions & 1 deletion plugins/simulator/mcp-server/app/auth/credentials.go
Original file line number Diff line number Diff line change
Expand Up @@ -30,8 +30,13 @@ func (c *Credentials) AuthorizationHeader() string {
return tokenType + " " + c.AccessToken
}

// envFilePath returns the path to the .env file in the current working directory.
// envFilePath returns the path to the .env file.
// It prefers SIMULATOR_WORK_DIR (the user's project directory, captured before the
// server cd-s into the plugin dir) and falls back to cwd for local dev runs.
func envFilePath() string {
if dir := os.Getenv("SIMULATOR_WORK_DIR"); dir != "" {
return filepath.Join(dir, ".env")
}
cwd, _ := os.Getwd()
return filepath.Join(cwd, ".env")
}
Expand Down
7 changes: 6 additions & 1 deletion plugins/simulator/mcp-server/cmd/server/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import (
"flag"
"log"
"os"
"path/filepath"
"strings"

"github.com/corezoid/simulator-ai-plugin/plugins/simulator/mcp-server/app/auth"
Expand All @@ -27,7 +28,11 @@ func main() {
insecure := flag.Bool("insecure", false, "Skip TLS verification (self-signed on-prem gateways only)")
flag.Parse()

loadDotEnv(".env")
if workDir := os.Getenv("SIMULATOR_WORK_DIR"); workDir != "" {
loadDotEnv(filepath.Join(workDir, ".env"))
} else {
loadDotEnv(".env")
}

prof, err := config.Resolve(*profileFlag)
if err != nil {
Expand Down
142 changes: 142 additions & 0 deletions plugins/simulator/mcp-server/internal/cduschema/schema.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,142 @@
// Package cduschema loads the CDU (Smart Form) page protocol schema from the
// bundled swagger and exposes lightweight validation rules derived from it.
package cduschema

import (
_ "embed"
"encoding/json"
"sync"
)

//go:embed smart-forms-swagger.json
var swaggerJSON []byte

// Rules holds sets of valid values extracted from the swagger schema.
type Rules struct {
// Classes is the set of valid item `class` values.
// Source: allOf[1].properties.class.allOf[1].enum in each component schema,
// plus `row` and `draggable` (renderer-side layout wrappers absent from the swagger).
Classes map[string]bool

// Visibility is the set of valid visibility values (visible|disabled|hidden).
Visibility map[string]bool

// SectionType is the set of valid section type values (body|block|modal|float).
SectionType map[string]bool

// GridType is the set of valid grid type values (one_column|two_column).
GridType map[string]bool
}

var (
rulesOnce sync.Once
rules *Rules
)

// GetRules returns the singleton Rules, parsing the embedded swagger on first call.
// If the swagger cannot be parsed the built-in fallback values are returned.
func GetRules() *Rules {
rulesOnce.Do(func() {
rules = parseRules(swaggerJSON)
})
return rules
}

func parseRules(data []byte) *Rules {
r := &Rules{
Classes: make(map[string]bool),
// Extracted from Form.properties.visibility.allOf[0].enum
Visibility: map[string]bool{"visible": true, "hidden": true, "disabled": true},
// Extracted from Form.properties.sections.items.properties.type.enum
SectionType: map[string]bool{"body": true, "block": true, "modal": true, "float": true},
// Extracted from Page.properties.grid.properties.type.example / Page-grid-* discriminator mapping
GridType: map[string]bool{"one_column": true, "two_column": true},
}

// Decode only the components.schemas map — avoid holding the full 3 MB in memory.
var swagger struct {
Components struct {
Schemas map[string]json.RawMessage `json:"schemas"`
} `json:"components"`
}
if err := json.Unmarshal(data, &swagger); err != nil {
return r
}

// Component schemas embed their class enum in one of two ways depending on depth:
//
// Pattern A (2-entry allOf, e.g. Button-default, Label):
// allOf[N].properties.class.allOf[1].enum = ["<class>"]
//
// Pattern B (Table-default — direct enum without inner allOf):
// allOf[N].properties.class.enum = ["<class>"]
//
// Schemas may have 2 or 3 allOf entries. We iterate all entries to be robust
// against depth variations.
// classEnumFromRaw extracts the single string from an enum array that may contain
// booleans — e.g. `{"enum": ["button"]}` → "button"; `{"enum": [true, false]}` → "".
classEnumFromRaw := func(raw json.RawMessage) string {
var arr []json.RawMessage
if json.Unmarshal(raw, &arr) != nil || len(arr) != 1 {
return ""
}
var s string
if json.Unmarshal(arr[0], &s) != nil {
return ""
}
return s
}

for _, schemaRaw := range swagger.Components.Schemas {
// Decode only the top-level allOf list as raw messages to avoid type
// conflicts (some properties carry bool enums, not string enums).
var s struct {
AllOf []json.RawMessage `json:"allOf"`
}
if json.Unmarshal(schemaRaw, &s) != nil {
continue
}
for _, aoRaw := range s.AllOf {
// Decode each allOf entry's properties map as raw values.
var ao struct {
Properties map[string]json.RawMessage `json:"properties"`
}
if json.Unmarshal(aoRaw, &ao) != nil {
continue
}
classRaw, ok := ao.Properties["class"]
if !ok {
continue
}
// Pattern A: class.allOf[N] where allOf[N].enum = ["<class>"]
var classDef struct {
AllOf []json.RawMessage `json:"allOf"`
Enum json.RawMessage `json:"enum"`
}
if json.Unmarshal(classRaw, &classDef) != nil {
continue
}
for _, innerRaw := range classDef.AllOf {
var inner struct {
Enum json.RawMessage `json:"enum"`
}
if json.Unmarshal(innerRaw, &inner) == nil {
if v := classEnumFromRaw(inner.Enum); v != "" {
r.Classes[v] = true
}
}
}
// Pattern B: class.enum = ["<class>"] (direct, e.g. Table-default)
if v := classEnumFromRaw(classDef.Enum); v != "" {
r.Classes[v] = true
}
}
}

// `row` and `draggable` are client-side layout wrappers used by the renderer
// (control-cdu) but defined outside the server-side swagger spec.
r.Classes["row"] = true
r.Classes["draggable"] = true

return r
}
102 changes: 102 additions & 0 deletions plugins/simulator/mcp-server/internal/cduschema/schema_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
package cduschema

import (
"sort"
"testing"
)

func TestGetRules(t *testing.T) {
r := GetRules()

// Spot-check a sample of expected classes derived from the swagger.
for _, want := range []string{"button", "edit", "label", "select", "table", "row", "draggable"} {
if !r.Classes[want] {
t.Errorf("expected class %q to be present", want)
}
}

classes := make([]string, 0, len(r.Classes))
for c := range r.Classes {
classes = append(classes, c)
}
sort.Strings(classes)
t.Logf("extracted classes (%d): %v", len(classes), classes)

if !r.GridType["one_column"] || !r.GridType["two_column"] {
t.Error("expected one_column and two_column grid types")
}
if !r.Visibility["visible"] || !r.Visibility["hidden"] || !r.Visibility["disabled"] {
t.Error("expected visibility values visible|hidden|disabled")
}
if !r.SectionType["body"] || !r.SectionType["modal"] {
t.Error("expected section types body and modal")
}
}

func TestValidatePageConfig_Valid(t *testing.T) {
config := `{
"grid": {"type": "one_column", "components": {"center": ["main"]}},
"forms": [
{
"id": "main",
"sections": [
{
"id": "body",
"type": "body",
"content": [
{"id": "lbl", "class": "label", "value": "Hello"},
{"id": "btn", "class": "button", "title": "Submit", "type": "default"}
]
}
]
}
]
}`
errs := ValidateFile("pages/index/config", config)
if len(errs) != 0 {
t.Errorf("expected no errors, got: %v", errs)
}
}

func TestValidatePageConfig_Errors(t *testing.T) {
config := `{
"grid": {"type": "bad_type"},
"forms": [
{
"sections": [
{
"type": "invalid_section",
"content": [
{"id": "x", "class": "unknownClass"},
{"class": "label"}
]
}
]
}
]
}`
errs := ValidateFile("pages/index/config", config)
if len(errs) == 0 {
t.Fatal("expected validation errors, got none")
}
t.Logf("errors (%d):", len(errs))
for _, e := range errs {
t.Logf(" %s", e)
}
// Should catch: bad grid type, missing form id, bad section type, unknown class, missing item id
if len(errs) < 4 {
t.Errorf("expected at least 4 errors, got %d", len(errs))
}
}

func TestValidateLocale(t *testing.T) {
valid := `{"hello": {"en": "Hello", "uk": "Привіт"}}`
if errs := ValidateFile("locale", valid); len(errs) != 0 {
t.Errorf("expected no errors: %v", errs)
}

invalid := `{"hello": "bad value"}`
if errs := ValidateFile("locale", invalid); len(errs) == 0 {
t.Error("expected error for non-object locale value")
}
}
Loading
Loading