diff --git a/README.md b/README.md index f77bc46..5c9b9b0 100644 --- a/README.md +++ b/README.md @@ -27,11 +27,15 @@ judgment-pack version judgment-pack spec validate judgment-pack spec test-conformance [suite] judgment-pack spec schema +judgment-pack mcp ``` The namespace is `judgment-pack spec`, not `judgment-pack jps`. JPS remains the name of the specification and the prefix of its provisional diagnostic codes. +The `mcp` command serves the same offline operations to a Model Context Protocol client over stdio, +so an agent can validate documents as a tool call; see [docs/agent-testing.md](docs/agent-testing.md). + ## Install a tagged release Download the archive for your operating system and architecture from diff --git a/docs/adr/0003-mcp-integration-and-testing-surface.md b/docs/adr/0003-mcp-integration-and-testing-surface.md index 7e228d3..7ffdcbb 100644 --- a/docs/adr/0003-mcp-integration-and-testing-surface.md +++ b/docs/adr/0003-mcp-integration-and-testing-surface.md @@ -1,13 +1,13 @@ --- -status: proposed +status: accepted date: 2026-07-24 deciders: Brian Jin --- # MCP is the runtime's integration and testing surface -> **Stub.** The direction and the constraints below are settled; the server itself is not built. -> Acceptance is gated on the Phase 0 review described under "Decision outcome". +> **Accepted.** Phase 0 passed and Phase 1 is built: `internal/mcp` is a hand-rolled stdio MCP +> server wired as the `judgment-pack mcp` subcommand. ## Context and problem statement @@ -36,9 +36,9 @@ Chosen direction: **A**, reached in two phases so the premise is proven before c - **Phase 0 (proven first, no Go): option C as a warm-up.** An agent in the reference client shells out to `judgment-pack spec validate` to pressure-test whether the runtime's diagnostics are good enough to close an authoring loop. Review the result, *then* decide Phase 1. -- **Phase 1 (pending review): the MCP server.** Fill the `internal/mcp` seam as a `judgment-pack - mcp` subcommand over stdio, wrapping the existing engine (`validate`, `test_conformance`, - `get_schema`, `describe_runtime`) and evaluating nothing. +- **Phase 1 (built): the MCP server.** `internal/mcp` is a `judgment-pack mcp` subcommand over + stdio, wrapping the existing engine as the `validate`, `test_conformance`, `get_schema`, and + `describe_runtime` tools, and evaluating nothing. Settled constraints, regardless of phase: @@ -48,8 +48,14 @@ Settled constraints, regardless of phase: - **Examples:** read from a `judgment-pack-spec` checkout; the runtime does not vendor example packs, because the specification owns them. -Deferred: the MCP Go library choice (official `go-sdk` vs. `mark3labs/mcp-go`) — decided at -implementation time, not now. +Implementation: **hand-rolled, no SDK.** Both Go MCP SDKs (`modelcontextprotocol/go-sdk`, +`mark3labs/mcp-go`) require a Go 1.25 toolchain and pull heavy dependencies — including `oauth2`, +which a keyless offline server never uses — conflicting with this runtime's minimal, offline, +Go 1.21 posture. The MCP surface here is small (four read-only tools over stdio), so the server +speaks MCP's newline-delimited JSON-RPC directly and adds zero dependencies. An SDK belongs in a +future commercial, authenticated API/MCP service (a separate layer per +[0002](0002-language-plurality-at-the-wire.md) and the repository boundary), not in the public +offline runtime. ### Consequences @@ -57,7 +63,7 @@ implementation time, not now. - Good, because it realizes [0002](0002-language-plurality-at-the-wire.md): all-language access from one Go wire surface. - Bad, because testing depends on a third-party client's behavior and setup. -- Revisit / promote to `accepted` after the Phase 0 review decides Phase 1. +- Revisit if a commercial, authenticated MCP/API surface is needed; that is a separate service, not this runtime (see the implementation note above). ## More information diff --git a/docs/agent-testing.md b/docs/agent-testing.md index 84a43af..b77124a 100644 --- a/docs/agent-testing.md +++ b/docs/agent-testing.md @@ -12,8 +12,9 @@ generic Draft 2020-12 validator rather than an agent. Why the runtime, rather th the integration and testing surface is recorded in [ADR-0003](adr/0003-mcp-integration-and-testing-surface.md). -Today the agent drives the command-line binary directly. A first-class MCP surface is future work -(ADR-0003); the loop below needs only the CLI. +The agent drives the command-line binary directly below. The runtime also ships an MCP server +(`judgment-pack mcp`, see ADR-0003) exposing the same operations as tools to any MCP client; the +CLI loop here needs neither an SDK nor a running server. ## What you need diff --git a/internal/cli/app.go b/internal/cli/app.go index 32b82ce..8f06542 100644 --- a/internal/cli/app.go +++ b/internal/cli/app.go @@ -16,6 +16,7 @@ import ( "github.com/Judgment-Pack/judgment-pack-runtime/internal/describe" "github.com/Judgment-Pack/judgment-pack-runtime/internal/display" "github.com/Judgment-Pack/judgment-pack-runtime/internal/fssecure" + "github.com/Judgment-Pack/judgment-pack-runtime/internal/mcp" "github.com/Judgment-Pack/judgment-pack-runtime/internal/result" "github.com/Judgment-Pack/judgment-pack-runtime/internal/validation" ) @@ -89,7 +90,7 @@ func (a *App) rootCommand() *cobra.Command { root.SetVersionTemplate("judgment-pack {{.Version}}\n") root.CompletionOptions.DisableDefaultCmd = true root.PersistentFlags().BoolVar(&a.pretty, "pretty", false, "indent JSON output") - root.AddCommand(a.versionCommand(), a.specCommand()) + root.AddCommand(a.versionCommand(), a.specCommand(), a.mcpCommand()) return root } @@ -107,6 +108,22 @@ func (a *App) specCommand() *cobra.Command { return spec } +func (a *App) mcpCommand() *cobra.Command { + return &cobra.Command{ + Use: "mcp", + Short: "Serve the offline validator to an MCP client over stdio", + Long: "Run a Model Context Protocol server on standard input and output. It exposes the offline validation, conformance, and description operations as MCP tools; it holds no credential, opens no network connection, and evaluates nothing.", + Args: cobra.NoArgs, + RunE: func(_ *cobra.Command, _ []string) error { + server := mcp.NewServer(a.engine, a.runner) + if err := server.Serve(a.in, a.out, a.errOut); err != nil { + return &handledExit{code: result.ExitIO} + } + return nil + }, + } +} + func (a *App) versionCommand() *cobra.Command { format := "human" command := &cobra.Command{ diff --git a/internal/cli/app_test.go b/internal/cli/app_test.go index 21b005e..4cd333d 100644 --- a/internal/cli/app_test.go +++ b/internal/cli/app_test.go @@ -268,3 +268,19 @@ func diagnosticCode(t *testing.T, jsonOutput string) string { } return output.Diagnostics[0].Code } + +func TestMCPSubcommandRespondsToInitialize(t *testing.T) { + input := `{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18"}}` + "\n" + code, stdout, stderr := runTest(t, []string{"mcp"}, input) + if code != 0 || stderr != "" { + t.Fatalf("exit=%d stderr=%q", code, stderr) + } + var response map[string]any + if err := json.Unmarshal([]byte(strings.TrimSpace(stdout)), &response); err != nil { + t.Fatalf("undecodable response %q: %v", stdout, err) + } + result, ok := response["result"].(map[string]any) + if !ok || result["protocolVersion"] != "2025-06-18" { + t.Fatalf("unexpected initialize response: %#v", response) + } +} diff --git a/internal/mcp/doc.go b/internal/mcp/doc.go index 621ce72..4d600c3 100644 --- a/internal/mcp/doc.go +++ b/internal/mcp/doc.go @@ -1,13 +1,13 @@ -// Package mcp will expose this runtime over the Model Context Protocol. +// Package mcp exposes this runtime over the Model Context Protocol. // -// It is a transport adapter only: it maps MCP tool and resource calls onto the -// existing validation, conformance, and describe packages and returns their -// versioned results. It evaluates no condition, resolves no outcome, and adds -// no judgment behavior of its own -- an MCP client reaches exactly the same -// core the CLI reaches, over JSON-RPC instead of argv. +// It is a transport adapter only: it maps MCP tool calls onto the existing +// validation, conformance, and describe packages and returns their versioned +// results. It evaluates no condition, resolves no outcome, opens no network +// connection, and holds no credential -- an MCP client reaches exactly the same +// offline core the CLI reaches, over JSON-RPC on stdio instead of argv. // -// The adapter is intended to run as an "mcp" subcommand of the single -// judgment-pack binary rather than as a separate program. -// -// This package is a scaffold and currently has no implementation. +// The server speaks MCP's newline-delimited JSON-RPC 2.0 stdio framing directly, +// without a third-party SDK, to keep the runtime's dependency set minimal and +// its build free of a newer-Go-toolchain requirement. It runs as the "mcp" +// subcommand of the single judgment-pack binary. package mcp diff --git a/internal/mcp/server.go b/internal/mcp/server.go new file mode 100644 index 0000000..e9b2749 --- /dev/null +++ b/internal/mcp/server.go @@ -0,0 +1,148 @@ +package mcp + +import ( + "bufio" + "bytes" + "encoding/json" + "fmt" + "io" + + "github.com/Judgment-Pack/judgment-pack-runtime/internal/conformance" + "github.com/Judgment-Pack/judgment-pack-runtime/internal/result" + "github.com/Judgment-Pack/judgment-pack-runtime/internal/validation" +) + +// protocolVersion is the MCP revision this server implements. When a client +// requests a version, initialize echoes it back, since this server's tool-only +// surface is version-agnostic; absent one, it answers with this value. +const protocolVersion = "2025-06-18" + +// maxMessageBytes bounds one JSON-RPC line. It sits above the 10 MiB document +// limit so a validate call carrying a maximal document still fits, while +// refusing an unbounded line from a misbehaving client. +const maxMessageBytes = 16 * 1024 * 1024 + +const ( + codeParse = -32700 + codeMethodNotFound = -32601 + codeInvalidParams = -32602 +) + +// Server maps MCP tool calls onto the offline validation core. It holds no +// network connection and no credential. +type Server struct { + engine *validation.Engine + runner *conformance.Runner +} + +// NewServer builds a server over an already-constructed engine and runner. +func NewServer(engine *validation.Engine, runner *conformance.Runner) *Server { + return &Server{engine: engine, runner: runner} +} + +type rpcRequest struct { + JSONRPC string `json:"jsonrpc"` + ID json.RawMessage `json:"id,omitempty"` + Method string `json:"method"` + Params json.RawMessage `json:"params,omitempty"` +} + +type rpcError struct { + Code int `json:"code"` + Message string `json:"message"` +} + +// Serve reads newline-delimited JSON-RPC messages from in, dispatches each, and +// writes one response line per request to out. Notifications receive no +// response. Diagnostic text goes to logw so it never mixes with protocol output +// on out. Serve returns when in reaches EOF. +func (s *Server) Serve(in io.Reader, out, logw io.Writer) error { + scanner := bufio.NewScanner(in) + scanner.Buffer(make([]byte, 0, 64*1024), maxMessageBytes) + encoder := json.NewEncoder(out) + encoder.SetEscapeHTML(false) + + for scanner.Scan() { + line := bytes.TrimSpace(scanner.Bytes()) + if len(line) == 0 { + continue + } + var request rpcRequest + if err := json.Unmarshal(line, &request); err != nil { + writeMessage(encoder, logw, map[string]any{"jsonrpc": "2.0", "id": nil, "error": &rpcError{Code: codeParse, Message: "Message is not valid JSON."}}) + continue + } + if response, ok := s.handle(&request); ok { + writeMessage(encoder, logw, response) + } + } + if err := scanner.Err(); err != nil { + fmt.Fprintln(logw, "mcp: input error:", err) + return err + } + return nil +} + +func writeMessage(encoder *json.Encoder, logw io.Writer, response map[string]any) { + if err := encoder.Encode(response); err != nil { + fmt.Fprintln(logw, "mcp: write error:", err) + } +} + +// handle routes one message. The bool is false for notifications, which never +// receive a response. +func (s *Server) handle(request *rpcRequest) (map[string]any, bool) { + switch request.Method { + case "notifications/initialized": + return nil, false + case "initialize": + return s.reply(request, s.initializeResult(request.Params), nil) + case "ping": + return s.reply(request, map[string]any{}, nil) + case "tools/list": + return s.reply(request, map[string]any{"tools": toolDefinitions()}, nil) + case "tools/call": + res, rerr := s.callTool(request.Params) + return s.reply(request, res, rerr) + default: + if len(request.ID) == 0 { + return nil, false // ignore unknown notifications + } + return s.reply(request, nil, &rpcError{Code: codeMethodNotFound, Message: "Unknown method: " + request.Method}) + } +} + +// reply builds a response for a request, or signals no response for a +// notification (a message with no id). +func (s *Server) reply(request *rpcRequest, res any, rerr *rpcError) (map[string]any, bool) { + if len(request.ID) == 0 { + return nil, false + } + response := map[string]any{"jsonrpc": "2.0", "id": request.ID} + if rerr != nil { + response["error"] = rerr + } else { + response["result"] = res + } + return response, true +} + +func (s *Server) initializeResult(rawParams json.RawMessage) map[string]any { + version := protocolVersion + var params struct { + ProtocolVersion string `json:"protocolVersion"` + } + if len(rawParams) > 0 { + if err := json.Unmarshal(rawParams, ¶ms); err == nil && params.ProtocolVersion != "" { + version = params.ProtocolVersion + } + } + return map[string]any{ + "protocolVersion": version, + "capabilities": map[string]any{"tools": map[string]any{}}, + "serverInfo": map[string]any{ + "name": result.CLIName, + "version": result.CLIVersion, + }, + } +} diff --git a/internal/mcp/server_test.go b/internal/mcp/server_test.go new file mode 100644 index 0000000..3228c55 --- /dev/null +++ b/internal/mcp/server_test.go @@ -0,0 +1,128 @@ +package mcp + +import ( + "bytes" + "encoding/json" + "strings" + "testing" + + "github.com/Judgment-Pack/judgment-pack-runtime/internal/artifacts" + "github.com/Judgment-Pack/judgment-pack-runtime/internal/conformance" + "github.com/Judgment-Pack/judgment-pack-runtime/internal/validation" +) + +func message(t *testing.T, id int, method string, params any) string { + t.Helper() + msg := map[string]any{"jsonrpc": "2.0", "method": method} + if id >= 0 { + msg["id"] = id + } + if params != nil { + msg["params"] = params + } + data, err := json.Marshal(msg) + if err != nil { + t.Fatal(err) + } + return string(data) + "\n" +} + +func runServer(t *testing.T, input string) []map[string]any { + t.Helper() + engine, err := validation.NewEngine() + if err != nil { + t.Fatal(err) + } + server := NewServer(engine, conformance.NewRunner(engine)) + var out, logw bytes.Buffer + if err := server.Serve(strings.NewReader(input), &out, &logw); err != nil { + t.Fatalf("serve: %v (log %q)", err, logw.String()) + } + var responses []map[string]any + for _, line := range strings.Split(strings.TrimSpace(out.String()), "\n") { + if line == "" { + continue + } + var decoded map[string]any + if err := json.Unmarshal([]byte(line), &decoded); err != nil { + t.Fatalf("undecodable response line %q: %v", line, err) + } + responses = append(responses, decoded) + } + return responses +} + +func TestServerLifecycleToolsAndValidate(t *testing.T) { + set, err := artifacts.Load(artifacts.DraftVersion) + if err != nil { + t.Fatal(err) + } + validDoc, err := set.Case("valid/minimal-literal.json") + if err != nil { + t.Fatal(err) + } + + input := strings.Join([]string{ + message(t, 1, "initialize", map[string]any{"protocolVersion": "2025-06-18"}), + message(t, -1, "notifications/initialized", nil), // notification: no response + message(t, 2, "tools/list", nil), + message(t, 3, "tools/call", map[string]any{"name": "validate", "arguments": map[string]any{"document": string(validDoc)}}), + message(t, 4, "tools/call", map[string]any{"name": "validate", "arguments": map[string]any{"document": `{"specVersion":"0.1.0-draft"}`}}), + message(t, 5, "no/such/method", nil), + }, "") + + responses := runServer(t, input) + if len(responses) != 5 { + t.Fatalf("expected 5 responses (the notification produces none), got %d: %#v", len(responses), responses) + } + + // initialize + initResult, ok := responses[0]["result"].(map[string]any) + if !ok || initResult["protocolVersion"] != "2025-06-18" { + t.Fatalf("initialize result: %#v", responses[0]) + } + if serverInfo := initResult["serverInfo"].(map[string]any); serverInfo["name"] != "judgment-pack" { + t.Fatalf("serverInfo: %#v", serverInfo) + } + if _, hasTools := initResult["capabilities"].(map[string]any)["tools"]; !hasTools { + t.Fatalf("capabilities should advertise tools: %#v", initResult["capabilities"]) + } + + // tools/list + tools := responses[1]["result"].(map[string]any)["tools"].([]any) + if len(tools) != 4 { + t.Fatalf("expected 4 tools, got %d", len(tools)) + } + + // validate (valid document) + validCall := responses[2]["result"].(map[string]any) + if validCall["isError"] != false { + t.Fatalf("valid document should not be a tool error: %#v", validCall) + } + if structured := validCall["structuredContent"].(map[string]any); structured["status"] != "valid" { + t.Fatalf("valid document status: %v", structured["status"]) + } + + // validate (invalid document) is still a successful call reporting "invalid" + invalidCall := responses[3]["result"].(map[string]any) + if invalidCall["isError"] != false { + t.Fatalf("invalid document is a successful call, not a tool error: %#v", invalidCall) + } + if structured := invalidCall["structuredContent"].(map[string]any); structured["status"] != "invalid" { + t.Fatalf("invalid document status: %v", structured["status"]) + } + + // unknown method -> JSON-RPC method-not-found + rpcErr, ok := responses[4]["error"].(map[string]any) + if !ok || rpcErr["code"].(float64) != codeMethodNotFound { + t.Fatalf("unknown method should be a method-not-found error: %#v", responses[4]) + } +} + +func TestValidateToolRequiresDocument(t *testing.T) { + responses := runServer(t, message(t, 1, "tools/call", map[string]any{"name": "validate", "arguments": map[string]any{}})) + result := responses[0]["result"].(map[string]any) + if result["isError"] != true { + t.Fatalf("a missing document should be an in-band tool error: %#v", result) + } +} diff --git a/internal/mcp/tools.go b/internal/mcp/tools.go new file mode 100644 index 0000000..7e8672a --- /dev/null +++ b/internal/mcp/tools.go @@ -0,0 +1,194 @@ +package mcp + +import ( + "encoding/json" + + "github.com/Judgment-Pack/judgment-pack-runtime/internal/artifacts" + "github.com/Judgment-Pack/judgment-pack-runtime/internal/carrier" + "github.com/Judgment-Pack/judgment-pack-runtime/internal/describe" + "github.com/Judgment-Pack/judgment-pack-runtime/internal/validation" +) + +// toolDefinitions is the tools/list payload. Every tool wraps a read-only core +// operation and evaluates nothing. +func toolDefinitions() []map[string]any { + return []map[string]any{ + { + "name": "validate", + "description": "Validate one JPS document for carrier, structural, and semantic conformance. It does not evaluate rules, choose an outcome, or authorize anything.", + "inputSchema": map[string]any{ + "type": "object", + "additionalProperties": false, + "required": []string{"document"}, + "properties": map[string]any{ + "document": map[string]any{"type": "string", "description": "The JPS document to validate, as JSON text."}, + "through": map[string]any{"type": "string", "enum": []string{"carrier", "structural", "semantic"}, "description": "Last validation layer to run; defaults to semantic."}, + }, + }, + }, + { + "name": "test_conformance", + "description": "Run a version-pinned JPS conformance corpus; the bundled corpus by default.", + "inputSchema": map[string]any{ + "type": "object", + "additionalProperties": false, + "properties": map[string]any{ + "suite": map[string]any{"type": "string", "description": "Optional path to a local suite directory or manifest.json; omit for the bundled corpus."}, + "spec_version": map[string]any{"type": "string", "description": "Optional exact JPS version; defaults to the bundled draft."}, + }, + }, + }, + { + "name": "get_schema", + "description": "Return the exact bundled JPS JSON Schema for a specification version, with its digest and byte size.", + "inputSchema": map[string]any{ + "type": "object", + "additionalProperties": false, + "properties": map[string]any{ + "spec_version": map[string]any{"type": "string", "description": "Optional exact JPS version; defaults to the bundled draft."}, + }, + }, + }, + { + "name": "describe_runtime", + "description": "Report this runtime's version, the specification versions it supports, and the provenance of its bundled artifacts.", + "inputSchema": map[string]any{ + "type": "object", + "additionalProperties": false, + "properties": map[string]any{}, + }, + }, + } +} + +func (s *Server) callTool(rawParams json.RawMessage) (any, *rpcError) { + var params struct { + Name string `json:"name"` + Arguments json.RawMessage `json:"arguments"` + } + if err := json.Unmarshal(rawParams, ¶ms); err != nil { + return nil, &rpcError{Code: codeInvalidParams, Message: "Invalid tools/call parameters."} + } + switch params.Name { + case "validate": + return s.toolValidate(params.Arguments), nil + case "test_conformance": + return s.toolTestConformance(params.Arguments), nil + case "get_schema": + return s.toolGetSchema(params.Arguments), nil + case "describe_runtime": + return s.toolDescribeRuntime(), nil + default: + return nil, &rpcError{Code: codeInvalidParams, Message: "Unknown tool: " + params.Name} + } +} + +func (s *Server) toolValidate(rawArgs json.RawMessage) any { + var args struct { + Document string `json:"document"` + Through string `json:"through"` + } + if err := json.Unmarshal(rawArgs, &args); err != nil { + return toolError(`The "validate" arguments must be an object with a string "document".`) + } + if args.Document == "" { + return toolError(`The "document" argument is required: pass the JPS document as JSON text.`) + } + through := args.Through + if through == "" { + through = "semantic" + } + if through != "carrier" && through != "structural" && through != "semantic" { + return toolError(`"through" must be carrier, structural, or semantic.`) + } + output, operational := s.engine.Validate([]byte(args.Document), validation.Options{Through: through, Limits: carrier.DefaultLimits()}) + if operational != nil { + return toolError(operational.Message) + } + return toolResult(output) +} + +func (s *Server) toolTestConformance(rawArgs json.RawMessage) any { + var args struct { + Suite string `json:"suite"` + SpecVersion string `json:"spec_version"` + } + if len(rawArgs) > 0 { + if err := json.Unmarshal(rawArgs, &args); err != nil { + return toolError(`Invalid "test_conformance" arguments.`) + } + } + output, operational := s.runner.Run(args.Suite, args.SpecVersion) + if operational != nil { + return toolError(operational.Message) + } + return toolResult(output) +} + +func (s *Server) toolGetSchema(rawArgs json.RawMessage) any { + var args struct { + SpecVersion string `json:"spec_version"` + } + if len(rawArgs) > 0 { + if err := json.Unmarshal(rawArgs, &args); err != nil { + return toolError(`Invalid "get_schema" arguments.`) + } + } + version := args.SpecVersion + if version == "" { + version = artifacts.DraftVersion + } + set, err := artifacts.Load(version) + if err != nil { + return toolError("The exact JPS specification version is not bundled with this runtime.") + } + schemaBytes, err := set.Schema() + if err != nil { + return toolError("The bundled schema is unavailable.") + } + meta, err := describe.Schema(set, version, "mcp get_schema", schemaBytes) + if err != nil { + return toolError("The bundled schema metadata is invalid.") + } + return map[string]any{ + "content": []map[string]any{{"type": "text", "text": string(schemaBytes)}}, + "structuredContent": meta, + "isError": false, + } +} + +func (s *Server) toolDescribeRuntime() any { + set, err := artifacts.Load(artifacts.DraftVersion) + if err != nil { + return toolError("The bundled artifact metadata is unavailable.") + } + return toolResult(describe.Runtime(set, "mcp describe_runtime")) +} + +// toolResult wraps a versioned core payload as an MCP tool result: the payload +// as structured content, and its JSON as text for a client that reads only +// text. A reported "invalid" document is a successful call, not a tool error. +func toolResult(structured any) map[string]any { + return map[string]any{ + "content": []map[string]any{{"type": "text", "text": jsonText(structured)}}, + "structuredContent": structured, + "isError": false, + } +} + +// toolError reports a failure to execute the tool itself -- bad arguments or an +// operational failure -- in band so the calling model can react. +func toolError(message string) map[string]any { + return map[string]any{ + "content": []map[string]any{{"type": "text", "text": message}}, + "isError": true, + } +} + +func jsonText(value any) string { + data, err := json.Marshal(value) + if err != nil { + return "{}" + } + return string(data) +}