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
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -22,3 +22,6 @@ dump.rdb
# Kiro
.env
.kiro

# macOS
.DS_Store
12 changes: 12 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -202,6 +202,18 @@
- **Smart Form visibility placeholders rejected by `pushSmartForm`.** Page configs may use a pure
`{{viewModelKey}}` placeholder for form, section, and rendered-item `visibility`; validation now
accepts that server-resolved form while still rejecting malformed or embedded placeholders.
- **`appGetPage` / `appSendForm` could not carry a page `query`, making the platform's own session
pattern untestable.** A Smart Form is stateless: a `302` answers `{nextPage, query}` and the next
page reads it as `body.query.*`, which is how apps carry a session token across navigation.
Neither runtime tool accepted it, so a logged-in page could not be rendered at all — driving one
produced a cold page that looked like a backend bug. Both `appGetPage` and `appSendForm` gain
`query`, flattened into the URL query string exactly as the renderer sends it — including on the
`appSendForm` POST, whose handler reads the query off the URL and forwards it to the process as
`body.query` (a body-borne `query` is overwritten there and never arrives). New `InQueryMap`
param kind in `internal/tools/op.go` does
the flattening — plain `InQuery` would have sent the whole object as one opaque value, silently
dropping the session; it rejects a non-object and skips blank keys / nil values (a nil would
otherwise render as the literal `"null"`).
- **Telemetry: unsynchronized `telemetryEmail` read/write.** The opt-in email was stored in a plain
`var string`, written by `AskForEmailOnce` (after `login`) and read by `Middleware` on every tool
call — safe under the current single-threaded stdio transport, but a data race under `go test
Expand Down
26 changes: 26 additions & 0 deletions plugins/simulator/mcp-server/internal/tools/op.go
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,17 @@ const (
InBody ParamIn = "body" // a named field in the JSON request body
InBodyRoot ParamIn = "body_root" // this single param IS the entire request body
InLocal ParamIn = "local" // consumed by Resolve only; never sent to the API
// InQueryMap is one object argument whose entries are FLATTENED into the query
// string — `{"token":"abc","page":2}` becomes `?token=abc&page=2`. Use it for
// an open-ended bag of query keys the tool cannot enumerate up front, where
// InQuery would wrongly send the whole object as a single value. The Smart Form
// page routes need it: the renderer calls
// `GET /pages/{acc}/{ref}/{env}/{page}?<query>` with whatever the previous
// step's `302 {nextPage, query}` handed it, so the keys are app-defined. The
// matching POST (/send) takes it on the URL too — its handler forwards
// `req.query` to the process as `body.query`, overwriting anything a caller
// put in the JSON body — so both use InQueryMap, not InBody.
InQueryMap ParamIn = "query_map"
// InPathBody sends one value to BOTH the path segment AND a body field — for
// backends that take the same value in both slots (e.g. the pages /send route
// reads `page` from the path on GET but from the body on POST). Expressed as a
Expand Down Expand Up @@ -249,6 +260,21 @@ func makeHandlerCtxAware(c *apiclient.Client, op Operation, adjust func(context.
}
}
query.Set(wire, toString(val))
case InQueryMap:
// One object argument, flattened: each entry becomes its own query
// key. Skip entries with an empty key or a nil value — a nil would
// render as the literal "null" and a blank key is not addressable.
m, ok := val.(map[string]any)
if !ok {
return mcp.NewToolResultError(fmt.Sprintf(
"[Error] parameter %q must be an object of query keys, got %T", p.Name, val)), nil
}
for k, v := range m {
if k == "" || v == nil {
continue
}
query.Set(k, toString(v))
}
case InBody:
body[wire] = val
case InBodyRoot:
Expand Down
18 changes: 16 additions & 2 deletions plugins/simulator/mcp-server/internal/tools/smartforms.go
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,10 @@ var smartFormOps = []Operation{
{Name: "ref", In: InPath, Type: "string", Required: true, Desc: "The Smart Form's ref (its identity in the `scripts` system form). Resolve from the App Catalog or by searching the scripts form."},
{Name: "envTitle", In: InPath, Type: "string", Required: true, Desc: "Environment to serve: `production` (live) or `develop` (editable).", Enum: []string{"production", "develop"}},
{Name: "page", In: InPath, Type: "string", Required: true, Desc: "Page id to render. Use `index` for the app's landing page; follow `nextPage` / `pageId` from prior responses for subsequent pages."},
{Name: "query", In: InQueryMap, Type: "object", Desc: "Query parameters for this page, as {key: value} — flattened into the URL exactly as the renderer sends them. " +
"PASS BACK the `query` object from the previous step's 302 response (`{nextPage, query}`): apps routinely carry per-session state there " +
"(a token, a record id), and the page's /get reads it as `body.query.*`. Omitting it renders the page as if the user had opened it cold — " +
"which is a valid test, but it will NOT reproduce a logged-in page and its backend call may fail or return empty. Example: {\"token\":\"…\",\"cardCode\":\"…\"}."},
},
},
{
Expand All @@ -54,8 +58,18 @@ var smartFormOps = []Operation{
{Name: "formId", In: InBody, Type: "string", Required: true, Desc: "Id of the form being submitted (from `forms[].id` on the page)."},
{Name: "sectionId", In: InBody, Type: "string", Required: true, Desc: "Id of the section the submitted form belongs to (from `forms[].sections[].id`). Required by the backend."},
{Name: "data", In: InBody, Type: "object", Required: true, Desc: "Collected values of the form's value-bearing items, keyed by item id, e.g. {\"counterparty\":\"Acme\",\"value\":50000}. Use {} if the button submits no field values."},
{Name: "buttonId", In: InBody, Type: "string", Desc: "Optional id of the button that triggered the submit (from a `button` item). Omit for auto-submit / submit-on-change."},
{Name: "buttonData", In: InBody, Type: "object", Desc: "Optional extra payload carried by the button (e.g. a menu choice or auto-submit counter)."},
{Name: "buttonId", In: InBody, Type: "string", Desc: "Optional id of the button that triggered the submit (from a `button` item). " +
"Also the id of a `submitOnChange` FIELD when the submit was triggered by a value change rather than a click — the backend dispatches on this either way."},
{Name: "buttonData", In: InBody, Type: "object", Desc: "Optional extra payload carried by the button (e.g. a menu choice or auto-submit counter). " +
"Only `select` populates it on a submitOnChange event; `radio`/`check`/`toggle`/`edit` send `{}` exactly like a button click, so the changed value is read from `data`, not here."},
// The /send route takes `query` on the URL, NOT in the body: the
// handler does `body: { ...body, query, context }` with `query` =
// req.query, so a body-borne `query` is overwritten by the (empty)
// URL query. Same transport as appGetPage; the Corezoid process
// still receives it as `body.query.*`.
{Name: "query", In: InQueryMap, Type: "object", Desc: "Query parameters currently on the page, as {key: value} — pass back the `query` you rendered the page with (see appGetPage). " +
"Flattened into the URL exactly as the renderer sends it; the handler forwards it to the process as `body.query.*`, and many apps read per-session state " +
"(a token, a record id) from there as a fallback when a form carries none."},
},
},
}
113 changes: 112 additions & 1 deletion plugins/simulator/mcp-server/internal/tools/smartforms_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package tools

import (
"encoding/json"
"net/url"
"testing"

"github.com/mark3labs/mcp-go/mcp"
Expand Down Expand Up @@ -72,7 +73,7 @@ func TestSmartFormRuntimeOps(t *testing.T) {
t.Run("appSendForm sends page in BOTH path and body", func(t *testing.T) {
c, rec := setup(t)
res := call(t, c, opByName(t, "appSendForm"), map[string]any{
"ref": "smart-contract",
"ref": "smart-contract",
"envTitle": "production",
"page": "terms",
"formId": "f_terms",
Expand Down Expand Up @@ -109,6 +110,116 @@ func TestSmartFormRuntimeOps(t *testing.T) {
}
})

// A Smart Form carries per-session state (a token, a record id) in the page
// `query`: a 302 answers {nextPage, query} and the next page's /get reads it as
// body.query.*. The keys are app-defined, so the tool takes one object and
// InQueryMap flattens it — an InQuery param would have sent the whole object as
// a single value and the session would silently never arrive.
t.Run("appGetPage flattens the query object into the URL", func(t *testing.T) {
c, rec := setup(t)
res := call(t, c, opByName(t, "appGetPage"), map[string]any{
"ref": "chudo-market",
"envTitle": "develop",
"page": "history",
"query": map[string]any{"token": "tok 1", "cardCode": "777", "page": float64(2)},
})
if res.IsError {
t.Fatalf("appGetPage: unexpected error result: %+v", res.Content)
}
if want := "/pages/WS/chudo-market/develop/history"; rec.path != want {
t.Errorf("path = %s, want %s", rec.path, want)
}
q, err := url.ParseQuery(rec.query)
if err != nil {
t.Fatalf("parse query %q: %v", rec.query, err)
}
for k, want := range map[string]string{"token": "tok 1", "cardCode": "777", "page": "2"} {
if got := q.Get(k); got != want {
t.Errorf("query[%s] = %q, want %q (raw: %q)", k, got, want, rec.query)
}
}
// The object itself must NOT appear as one opaque value.
if q.Get("query") != "" {
t.Errorf("query object leaked as a single %q key: %q", "query", rec.query)
}
})

t.Run("appGetPage rejects a non-object query", func(t *testing.T) {
c, _ := setup(t)
res := call(t, c, opByName(t, "appGetPage"), map[string]any{
"ref": "chudo-market",
"envTitle": "develop",
"page": "index",
"query": "token=abc",
})
if !res.IsError {
t.Errorf("expected an error result when query is a string, not an object")
}
})

t.Run("appGetPage skips blank keys and nil values", func(t *testing.T) {
c, rec := setup(t)
res := call(t, c, opByName(t, "appGetPage"), map[string]any{
"ref": "chudo-market",
"envTitle": "develop",
"page": "index",
"query": map[string]any{"": "dropped", "token": nil, "keep": "yes"},
})
if res.IsError {
t.Fatalf("appGetPage: unexpected error result: %+v", res.Content)
}
q, _ := url.ParseQuery(rec.query)
if q.Get("keep") != "yes" {
t.Errorf("query[keep] = %q, want %q", q.Get("keep"), "yes")
}
// A nil would otherwise render as the literal string "null".
if _, present := q["token"]; present {
t.Errorf("nil value should be omitted, got %q", rec.query)
}
if len(q) != 1 {
t.Errorf("expected exactly 1 query key, got %d (%q)", len(q), rec.query)
}
})

// /send takes the query on the URL, not in the body: the handler builds
// `{ ...body, query, context }` with `query` = req.query, so a body-borne
// `query` is clobbered by the (empty) URL query and the session is dropped.
t.Run("appSendForm carries query in the URL, not the body", func(t *testing.T) {
c, rec := setup(t)
res := call(t, c, opByName(t, "appSendForm"), map[string]any{
"ref": "chudo-market",
"envTitle": "develop",
"page": "stores",
"formId": "geo",
"sectionId": "body",
"buttonId": "find_btn",
"data": map[string]any{"lat": "50.0466"},
"query": map[string]any{"token": "tok"},
})
if res.IsError {
t.Fatalf("appSendForm: unexpected error result: %+v", res.Content)
}
q, err := url.ParseQuery(rec.query)
if err != nil {
t.Fatalf("parse query %q: %v", rec.query, err)
}
if got := q.Get("token"); got != "tok" {
t.Errorf("query[token] = %q, want %q (raw: %q)", got, "tok", rec.query)
}
if q.Get("query") != "" {
t.Errorf("query object leaked as a single %q key: %q", "query", rec.query)
}
body, ok := rec.body.(map[string]any)
if !ok {
t.Fatalf("expected object body, got %T", rec.body)
}
// A body-borne `query` would be silently overwritten by req.query, so it
// must NOT be sent there — a stale/duplicate key would only mislead.
if _, present := body["query"]; present {
t.Errorf("body must not carry `query` (the handler overwrites it with req.query): %v", body)
}
})

t.Run("appSendForm requires formId/sectionId/data (buttonId optional)", func(t *testing.T) {
c, _ := setup(t)
// Omit the required body fields — the handler should refuse before any call.
Expand Down
33 changes: 31 additions & 2 deletions plugins/simulator/skills/simulator-smart-forms-runtime/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -41,8 +41,8 @@ and report the result.
| Step | Tool(s) |
|---|---|
| Discover the app | `filterActors` (over the `scripts` system form) → match intent against each actor's `title` + `description` (tags / `getRelatedActors` to scope); or `getActorByRef` when the user names it |
| Render a page | **`appGetPage`** (accId, ref, envTitle, page) |
| Submit a form | **`appSendForm`** (accId, ref, envTitle, page, formId, buttonId, data) |
| Render a page | **`appGetPage`** (accId, ref, envTitle, page, **query**) |
| Submit a form | **`appSendForm`** (accId, ref, envTitle, page, formId, buttonId, data, **query**) |
| Read an attached document | `readAttachment` |
| Report a result | `buildLink` (deep-link to a created actor/record) |

Expand Down Expand Up @@ -73,11 +73,40 @@ Smart Form**. Nothing about the app is hard-coded; you interpret what the page r
continue on the same page (more fields, or a result).
205 → re-render: page = appGetPage(... resp.pageId ...) — a fresh (maybe different) page.
302 → navigate to resp.nextPage (internal page) or report resp.nextPage (external URL).
CARRY resp.query FORWARD: page = appGetPage(..., resp.nextPage, query=resp.query)
g. Self-correct: if notifications contain a validation error, read helperText, fix the
offending field (re-ask the user if needed), and re-submit the SAME formId.
4. Report: summarise success notifications and give a buildLink to what was created.
```

### Carry the `query` across navigation — otherwise the session is lost

A `302` answers `{nextPage, query}`, and **that `query` is how a Smart Form carries per-session
state**: the page protocol is stateless, hidden carrier fields survive a submit but not a
navigation, so a login handler typically returns the session in `query` and every later page reads
it as `body.query.*`.

So: pass `resp.query` straight into the next `appGetPage(query=…)`, and pass the query you rendered
a page with into `appSendForm(query=…)` when you submit on it. Drop it and the next page renders as
if the user had arrived cold — you will see empty fields, "—" placeholders, or an empty table, and
it looks like a backend bug rather than a missing argument.

```
resp = appSendForm(..., page="index", buttonId="login_btn", data={…})
# → {code: 302, data: {nextPage: "home", query: {token: "…", cardCode: "…"}}}

page = appGetPage(..., page="home", query={"token": "…", "cardCode": "…"}) # ✅ logged in
page = appGetPage(..., page="home") # ❌ renders cold
```

Both tools put the object in the URL query string (`?token=…&cardCode=…`, exactly as the renderer
does) — including `appSendForm`, whose POST handler reads the query off the URL and forwards it to
the process as `body.query`. You pass the same shape either way.

> A session token in the query is visible in the page URL. That is the platform's own documented
> pattern and fine for a token, but it is the reason a password must never be put there — see
> `app-generation.md` §4.1a.

**Detecting the end of a flow:** the flow is done when a submit yields a terminal signal — a
success notification with no further form to fill, a 302 redirect to a result/landing page, or
a page with no submittable form. Don't loop forever; if a page repeats unchanged after a
Expand Down
Loading