From 1a3ffd22bf4e084c3deaafe8faf4bc810568a4b6 Mon Sep 17 00:00:00 2001 From: Daniyal Rana Date: Thu, 4 Jun 2026 15:30:54 -0700 Subject: [PATCH 01/27] feat(mcp): add DSX Exchange MCP server Signed-off-by: Daniyal Rana --- .github/workflows/ci.yml | 90 +- Makefile | 16 +- README.md | 2 + THIRD_PARTY_LICENSES.csv | 6 + .../regenerate-third-party-licenses.sh | 1 + mcp/dsx-exchange-mcp/.gitignore | 11 + mcp/dsx-exchange-mcp/Architecture.md | 662 ++ mcp/dsx-exchange-mcp/Dockerfile | 39 + mcp/dsx-exchange-mcp/Makefile | 42 + mcp/dsx-exchange-mcp/README.md | 200 + .../cmd/dsx-exchange-mcp/main.go | 120 + .../deploy/helm/dsx-exchange-mcp/Chart.yaml | 9 + .../templates/deployment.yaml | 146 + .../helm/dsx-exchange-mcp/templates/pdb.yaml | 16 + .../dsx-exchange-mcp/templates/service.yaml | 18 + .../values.deployed-bus.example.yaml | 19 + .../deploy/helm/dsx-exchange-mcp/values.yaml | 79 + .../docs/dsx-exchange-mcp-sdd.md | 982 +++ .../docs/local-llm-mcp-eval.md | 109 + .../docs/long-running-subscriptions-ux.md | 132 + .../docs/mcp-schema-prompt-eval-results.csv | 8 + .../docs/mcp-tasks-vs-explicit-async-tools.md | 147 + .../docs/schema-tool-question-bank.md | 74 + .../docs/sdd-discussion-notes.md | 549 ++ .../v1-background-watch-benchmark-plan.md | 208 + .../docs/watch-state-tradeoff-note.md | 180 + mcp/dsx-exchange-mcp/go.mod | 21 + mcp/dsx-exchange-mcp/go.sum | 32 + mcp/dsx-exchange-mcp/internal/auth/context.go | 60 + .../internal/auth/context_test.go | 50 + .../internal/metrics/metrics.go | 230 + .../internal/mqttbus/client.go | 617 ++ .../internal/mqttbus/client_test.go | 107 + .../internal/mqttbus/e2e_test.go | 85 + .../internal/schemaindex/index.go | 759 ++ .../internal/schemaindex/index_test.go | 96 + .../internal/server/e2e_test.go | 361 + .../internal/server/llm_eval_test.go | 542 ++ .../internal/server/resources.go | 71 + .../internal/server/server.go | 49 + .../testdata/tool_call_expectations.json | 229 + mcp/dsx-exchange-mcp/internal/server/tools.go | 457 ++ .../internal/server/tools_test.go | 441 + mcp/dsx-exchange-mcp/internal/server/watch.go | 678 ++ .../internal/server/watch_test.go | 139 + .../internal/server/watch_tools.go | 263 + .../internal/specs/data/.gitkeep | 3 + mcp/dsx-exchange-mcp/internal/specs/specs.go | 72 + mcp/dsx-exchange-mcp/schemas/.gitignore | 10 + mcp/dsx-exchange-mcp/schemas/README.md | 30 + .../schemas/asyncapi/bms/bms.yaml | 7224 +++++++++++++++++ .../asyncapi/launch-layer/notifications.yaml | 2 + .../mission-control/leak-response.yaml | 2 + .../asyncapi/monitoring/break-fix.yaml | 2 + .../schemas/asyncapi/nico/nico.yaml | 1796 ++++ .../schemas/asyncapi/nke/node-readiness.yaml | 2 + .../power-management/power-management.yaml | 825 ++ .../asyncapi/spiffe-exchange/pub-keysets.yaml | 117 + .../asyncapi/tenant/scheduler-events.yaml | 2 + .../schemas/cloud-events-example.yaml | 107 + mcp/dsx-exchange-mcp/schemas/embed.go | 12 + .../eclipse/paho.mqtt.golang/.gitignore | 36 + .../paho.mqtt.golang/CODE_OF_CONDUCT.md | 93 + .../eclipse/paho.mqtt.golang/CONTRIBUTING.md | 56 + .../eclipse/paho.mqtt.golang/LICENSE | 294 + .../eclipse/paho.mqtt.golang/NOTICE.md | 77 + .../eclipse/paho.mqtt.golang/README.md | 198 + .../eclipse/paho.mqtt.golang/SECURITY.md | 13 + .../eclipse/paho.mqtt.golang/backoff.go | 104 + .../eclipse/paho.mqtt.golang/client.go | 1265 +++ .../eclipse/paho.mqtt.golang/components.go | 36 + .../eclipse/paho.mqtt.golang/connnotf.go | 79 + .../eclipse/paho.mqtt.golang/edl-v10 | 15 + .../eclipse/paho.mqtt.golang/epl-v20 | 277 + .../eclipse/paho.mqtt.golang/filestore.go | 266 + .../eclipse/paho.mqtt.golang/memstore.go | 142 + .../paho.mqtt.golang/memstore_ordered.go | 166 + .../eclipse/paho.mqtt.golang/message.go | 131 + .../eclipse/paho.mqtt.golang/messageids.go | 200 + .../eclipse/paho.mqtt.golang/net.go | 469 ++ .../eclipse/paho.mqtt.golang/netconn.go | 101 + .../eclipse/paho.mqtt.golang/oops.go | 25 + .../eclipse/paho.mqtt.golang/options.go | 471 ++ .../paho.mqtt.golang/options_reader.go | 186 + .../paho.mqtt.golang/packets/connack.go | 68 + .../paho.mqtt.golang/packets/connect.go | 171 + .../paho.mqtt.golang/packets/disconnect.go | 50 + .../paho.mqtt.golang/packets/packets.go | 377 + .../paho.mqtt.golang/packets/pingreq.go | 50 + .../paho.mqtt.golang/packets/pingresp.go | 50 + .../paho.mqtt.golang/packets/puback.go | 58 + .../paho.mqtt.golang/packets/pubcomp.go | 58 + .../paho.mqtt.golang/packets/publish.go | 99 + .../paho.mqtt.golang/packets/pubrec.go | 58 + .../paho.mqtt.golang/packets/pubrel.go | 58 + .../paho.mqtt.golang/packets/suback.go | 73 + .../paho.mqtt.golang/packets/subscribe.go | 85 + .../paho.mqtt.golang/packets/unsuback.go | 58 + .../paho.mqtt.golang/packets/unsubscribe.go | 72 + .../eclipse/paho.mqtt.golang/ping.go | 78 + .../eclipse/paho.mqtt.golang/router.go | 214 + .../eclipse/paho.mqtt.golang/status.go | 296 + .../eclipse/paho.mqtt.golang/store.go | 140 + .../eclipse/paho.mqtt.golang/token.go | 222 + .../eclipse/paho.mqtt.golang/topic.go | 90 + .../eclipse/paho.mqtt.golang/trace.go | 44 + .../eclipse/paho.mqtt.golang/websocket.go | 132 + .../github.com/google/jsonschema-go/LICENSE | 21 + .../jsonschema-go/jsonschema/annotations.go | 76 + .../google/jsonschema-go/jsonschema/doc.go | 115 + .../google/jsonschema-go/jsonschema/infer.go | 400 + .../jsonschema-go/jsonschema/json_pointer.go | 160 + .../jsonschema-go/jsonschema/resolve.go | 589 ++ .../google/jsonschema-go/jsonschema/schema.go | 642 ++ .../google/jsonschema-go/jsonschema/util.go | 463 ++ .../jsonschema-go/jsonschema/validate.go | 905 +++ .../github.com/gorilla/websocket/.gitignore | 25 + .../github.com/gorilla/websocket/AUTHORS | 9 + .../github.com/gorilla/websocket/LICENSE | 22 + .../github.com/gorilla/websocket/README.md | 33 + .../github.com/gorilla/websocket/client.go | 434 + .../gorilla/websocket/compression.go | 148 + .../github.com/gorilla/websocket/conn.go | 1238 +++ .../github.com/gorilla/websocket/doc.go | 227 + .../github.com/gorilla/websocket/join.go | 42 + .../github.com/gorilla/websocket/json.go | 60 + .../github.com/gorilla/websocket/mask.go | 55 + .../github.com/gorilla/websocket/mask_safe.go | 16 + .../github.com/gorilla/websocket/prepared.go | 102 + .../github.com/gorilla/websocket/proxy.go | 77 + .../github.com/gorilla/websocket/server.go | 365 + .../gorilla/websocket/tls_handshake.go | 21 + .../gorilla/websocket/tls_handshake_116.go | 21 + .../github.com/gorilla/websocket/util.go | 298 + .../gorilla/websocket/x_net_proxy.go | 473 ++ .../modelcontextprotocol/go-sdk/LICENSE | 216 + .../modelcontextprotocol/go-sdk/auth/auth.go | 170 + .../go-sdk/auth/authorization_code.go | 548 ++ .../go-sdk/auth/client.go | 42 + .../go-sdk/auth/client_private.go | 135 + .../go-sdk/internal/json/json.go | 19 + .../go-sdk/internal/jsonrpc2/conn.go | 842 ++ .../go-sdk/internal/jsonrpc2/frame.go | 208 + .../go-sdk/internal/jsonrpc2/jsonrpc2.go | 121 + .../go-sdk/internal/jsonrpc2/messages.go | 242 + .../go-sdk/internal/jsonrpc2/net.go | 138 + .../go-sdk/internal/jsonrpc2/serve.go | 330 + .../go-sdk/internal/jsonrpc2/wire.go | 97 + .../go-sdk/internal/mcpgodebug/mcpgodebug.go | 52 + .../go-sdk/internal/util/net.go | 26 + .../go-sdk/internal/util/util.go | 44 + .../go-sdk/internal/xcontext/xcontext.go | 23 + .../go-sdk/jsonrpc/jsonrpc.go | 56 + .../modelcontextprotocol/go-sdk/mcp/client.go | 1182 +++ .../modelcontextprotocol/go-sdk/mcp/cmd.go | 108 + .../go-sdk/mcp/content.go | 410 + .../modelcontextprotocol/go-sdk/mcp/event.go | 436 + .../go-sdk/mcp/features.go | 114 + .../go-sdk/mcp/logging.go | 201 + .../modelcontextprotocol/go-sdk/mcp/mcp.go | 88 + .../modelcontextprotocol/go-sdk/mcp/prompt.go | 17 + .../go-sdk/mcp/protocol.go | 1622 ++++ .../go-sdk/mcp/requests.go | 39 + .../go-sdk/mcp/resource.go | 181 + .../go-sdk/mcp/schema_cache.go | 69 + .../modelcontextprotocol/go-sdk/mcp/server.go | 1595 ++++ .../go-sdk/mcp/session.go | 29 + .../modelcontextprotocol/go-sdk/mcp/shared.go | 611 ++ .../modelcontextprotocol/go-sdk/mcp/sse.go | 489 ++ .../go-sdk/mcp/streamable.go | 2188 +++++ .../go-sdk/mcp/streamable_client.go | 226 + .../go-sdk/mcp/streamable_server.go | 160 + .../modelcontextprotocol/go-sdk/mcp/tool.go | 140 + .../go-sdk/mcp/transport.go | 660 ++ .../modelcontextprotocol/go-sdk/mcp/util.go | 30 + .../go-sdk/oauthex/auth_meta.go | 198 + .../go-sdk/oauthex/dcr.go | 263 + .../go-sdk/oauthex/oauth2.go | 80 + .../go-sdk/oauthex/oauthex.go | 6 + .../go-sdk/oauthex/resource_meta.go | 280 + .../go-sdk/oauthex/resource_meta_public.go | 105 + .../vendor/github.com/segmentio/asm/LICENSE | 21 + .../github.com/segmentio/asm/ascii/ascii.go | 53 + .../segmentio/asm/ascii/equal_fold.go | 30 + .../segmentio/asm/ascii/equal_fold_amd64.go | 13 + .../segmentio/asm/ascii/equal_fold_amd64.s | 304 + .../segmentio/asm/ascii/equal_fold_default.go | 60 + .../github.com/segmentio/asm/ascii/valid.go | 18 + .../segmentio/asm/ascii/valid_amd64.go | 9 + .../segmentio/asm/ascii/valid_amd64.s | 132 + .../segmentio/asm/ascii/valid_default.go | 48 + .../segmentio/asm/ascii/valid_print.go | 18 + .../segmentio/asm/ascii/valid_print_amd64.go | 9 + .../segmentio/asm/ascii/valid_print_amd64.s | 185 + .../asm/ascii/valid_print_default.go | 46 + .../github.com/segmentio/asm/base64/base64.go | 67 + .../segmentio/asm/base64/base64_amd64.go | 160 + .../segmentio/asm/base64/base64_default.go | 14 + .../segmentio/asm/base64/decode_amd64.go | 10 + .../segmentio/asm/base64/decode_amd64.s | 144 + .../segmentio/asm/base64/encode_amd64.go | 8 + .../segmentio/asm/base64/encode_amd64.s | 88 + .../github.com/segmentio/asm/cpu/arm/arm.go | 80 + .../segmentio/asm/cpu/arm64/arm64.go | 74 + .../github.com/segmentio/asm/cpu/cpu.go | 22 + .../segmentio/asm/cpu/cpuid/cpuid.go | 32 + .../github.com/segmentio/asm/cpu/x86/x86.go | 76 + .../asm/internal/unsafebytes/unsafebytes.go | 20 + .../github.com/segmentio/asm/keyset/keyset.go | 40 + .../segmentio/asm/keyset/keyset_amd64.go | 10 + .../segmentio/asm/keyset/keyset_amd64.s | 108 + .../segmentio/asm/keyset/keyset_arm64.go | 8 + .../segmentio/asm/keyset/keyset_arm64.s | 143 + .../segmentio/asm/keyset/keyset_default.go | 19 + .../github.com/segmentio/encoding/LICENSE | 21 + .../segmentio/encoding/ascii/equal_fold.go | 40 + .../segmentio/encoding/ascii/valid.go | 26 + .../segmentio/encoding/ascii/valid_print.go | 26 + .../segmentio/encoding/iso8601/parse.go | 185 + .../segmentio/encoding/iso8601/valid.go | 179 + .../segmentio/encoding/json/README.md | 76 + .../segmentio/encoding/json/codec.go | 1240 +++ .../segmentio/encoding/json/decode.go | 1532 ++++ .../segmentio/encoding/json/encode.go | 970 +++ .../github.com/segmentio/encoding/json/int.go | 98 + .../segmentio/encoding/json/json.go | 594 ++ .../segmentio/encoding/json/parse.go | 781 ++ .../segmentio/encoding/json/reflect.go | 20 + .../encoding/json/reflect_optimize.go | 30 + .../segmentio/encoding/json/string.go | 89 + .../segmentio/encoding/json/token.go | 426 + .../yosida95/uritemplate/v3/LICENSE | 25 + .../yosida95/uritemplate/v3/README.rst | 46 + .../yosida95/uritemplate/v3/compile.go | 224 + .../yosida95/uritemplate/v3/equals.go | 53 + .../yosida95/uritemplate/v3/error.go | 16 + .../yosida95/uritemplate/v3/escape.go | 190 + .../yosida95/uritemplate/v3/expression.go | 173 + .../yosida95/uritemplate/v3/machine.go | 23 + .../yosida95/uritemplate/v3/match.go | 213 + .../yosida95/uritemplate/v3/parse.go | 277 + .../yosida95/uritemplate/v3/prog.go | 130 + .../yosida95/uritemplate/v3/uritemplate.go | 116 + .../yosida95/uritemplate/v3/value.go | 216 + .../vendor/golang.org/x/net/LICENSE | 27 + .../vendor/golang.org/x/net/PATENTS | 22 + .../golang.org/x/net/internal/socks/client.go | 168 + .../golang.org/x/net/internal/socks/socks.go | 317 + .../vendor/golang.org/x/net/proxy/dial.go | 54 + .../vendor/golang.org/x/net/proxy/direct.go | 31 + .../vendor/golang.org/x/net/proxy/per_host.go | 153 + .../vendor/golang.org/x/net/proxy/proxy.go | 149 + .../vendor/golang.org/x/net/proxy/socks5.go | 42 + .../vendor/golang.org/x/oauth2/.travis.yml | 13 + .../golang.org/x/oauth2/CONTRIBUTING.md | 26 + .../vendor/golang.org/x/oauth2/LICENSE | 27 + .../vendor/golang.org/x/oauth2/README.md | 35 + .../vendor/golang.org/x/oauth2/deviceauth.go | 227 + .../golang.org/x/oauth2/internal/doc.go | 6 + .../golang.org/x/oauth2/internal/oauth2.go | 37 + .../golang.org/x/oauth2/internal/token.go | 356 + .../golang.org/x/oauth2/internal/transport.go | 28 + .../vendor/golang.org/x/oauth2/oauth2.go | 423 + .../vendor/golang.org/x/oauth2/pkce.go | 69 + .../vendor/golang.org/x/oauth2/token.go | 213 + .../vendor/golang.org/x/oauth2/transport.go | 75 + .../vendor/golang.org/x/sync/LICENSE | 27 + .../vendor/golang.org/x/sync/PATENTS | 22 + .../golang.org/x/sync/semaphore/semaphore.go | 160 + .../vendor/golang.org/x/sys/LICENSE | 27 + .../vendor/golang.org/x/sys/PATENTS | 22 + .../golang.org/x/sys/cpu/asm_aix_ppc64.s | 17 + .../golang.org/x/sys/cpu/asm_darwin_x86_gc.s | 17 + .../vendor/golang.org/x/sys/cpu/byteorder.go | 66 + .../vendor/golang.org/x/sys/cpu/cpu.go | 338 + .../vendor/golang.org/x/sys/cpu/cpu_aix.go | 33 + .../vendor/golang.org/x/sys/cpu/cpu_arm.go | 73 + .../vendor/golang.org/x/sys/cpu/cpu_arm64.go | 194 + .../vendor/golang.org/x/sys/cpu/cpu_arm64.s | 35 + .../golang.org/x/sys/cpu/cpu_darwin_x86.go | 61 + .../golang.org/x/sys/cpu/cpu_gc_arm64.go | 12 + .../golang.org/x/sys/cpu/cpu_gc_s390x.go | 21 + .../vendor/golang.org/x/sys/cpu/cpu_gc_x86.go | 15 + .../vendor/golang.org/x/sys/cpu/cpu_gc_x86.s | 26 + .../golang.org/x/sys/cpu/cpu_gccgo_arm64.go | 11 + .../golang.org/x/sys/cpu/cpu_gccgo_s390x.go | 22 + .../golang.org/x/sys/cpu/cpu_gccgo_x86.c | 37 + .../golang.org/x/sys/cpu/cpu_gccgo_x86.go | 25 + .../vendor/golang.org/x/sys/cpu/cpu_linux.go | 15 + .../golang.org/x/sys/cpu/cpu_linux_arm.go | 39 + .../golang.org/x/sys/cpu/cpu_linux_arm64.go | 120 + .../golang.org/x/sys/cpu/cpu_linux_loong64.go | 22 + .../golang.org/x/sys/cpu/cpu_linux_mips64x.go | 22 + .../golang.org/x/sys/cpu/cpu_linux_noinit.go | 9 + .../golang.org/x/sys/cpu/cpu_linux_ppc64x.go | 30 + .../golang.org/x/sys/cpu/cpu_linux_riscv64.go | 160 + .../golang.org/x/sys/cpu/cpu_linux_s390x.go | 40 + .../golang.org/x/sys/cpu/cpu_loong64.go | 50 + .../vendor/golang.org/x/sys/cpu/cpu_loong64.s | 13 + .../golang.org/x/sys/cpu/cpu_mips64x.go | 15 + .../vendor/golang.org/x/sys/cpu/cpu_mipsx.go | 11 + .../golang.org/x/sys/cpu/cpu_netbsd_arm64.go | 173 + .../golang.org/x/sys/cpu/cpu_openbsd_arm64.go | 65 + .../golang.org/x/sys/cpu/cpu_openbsd_arm64.s | 11 + .../golang.org/x/sys/cpu/cpu_other_arm.go | 9 + .../golang.org/x/sys/cpu/cpu_other_arm64.go | 9 + .../golang.org/x/sys/cpu/cpu_other_mips64x.go | 11 + .../golang.org/x/sys/cpu/cpu_other_ppc64x.go | 12 + .../golang.org/x/sys/cpu/cpu_other_riscv64.go | 11 + .../golang.org/x/sys/cpu/cpu_other_x86.go | 11 + .../vendor/golang.org/x/sys/cpu/cpu_ppc64x.go | 16 + .../golang.org/x/sys/cpu/cpu_riscv64.go | 32 + .../vendor/golang.org/x/sys/cpu/cpu_s390x.go | 172 + .../vendor/golang.org/x/sys/cpu/cpu_s390x.s | 57 + .../vendor/golang.org/x/sys/cpu/cpu_wasm.go | 17 + .../vendor/golang.org/x/sys/cpu/cpu_x86.go | 236 + .../vendor/golang.org/x/sys/cpu/cpu_zos.go | 10 + .../golang.org/x/sys/cpu/cpu_zos_s390x.go | 25 + .../vendor/golang.org/x/sys/cpu/endian_big.go | 10 + .../golang.org/x/sys/cpu/endian_little.go | 10 + .../golang.org/x/sys/cpu/hwcap_linux.go | 71 + .../vendor/golang.org/x/sys/cpu/parse.go | 43 + .../x/sys/cpu/proc_cpuinfo_linux.go | 53 + .../golang.org/x/sys/cpu/runtime_auxv.go | 16 + .../x/sys/cpu/runtime_auxv_go121.go | 18 + .../golang.org/x/sys/cpu/syscall_aix_gccgo.go | 26 + .../x/sys/cpu/syscall_aix_ppc64_gc.go | 35 + .../x/sys/cpu/syscall_darwin_x86_gc.go | 98 + .../vendor/gopkg.in/yaml.v3/LICENSE | 50 + .../vendor/gopkg.in/yaml.v3/NOTICE | 13 + .../vendor/gopkg.in/yaml.v3/README.md | 150 + .../vendor/gopkg.in/yaml.v3/apic.go | 747 ++ .../vendor/gopkg.in/yaml.v3/decode.go | 1000 +++ .../vendor/gopkg.in/yaml.v3/emitterc.go | 2020 +++++ .../vendor/gopkg.in/yaml.v3/encode.go | 577 ++ .../vendor/gopkg.in/yaml.v3/parserc.go | 1258 +++ .../vendor/gopkg.in/yaml.v3/readerc.go | 434 + .../vendor/gopkg.in/yaml.v3/resolve.go | 326 + .../vendor/gopkg.in/yaml.v3/scannerc.go | 3038 +++++++ .../vendor/gopkg.in/yaml.v3/sorter.go | 134 + .../vendor/gopkg.in/yaml.v3/writerc.go | 48 + .../vendor/gopkg.in/yaml.v3/yaml.go | 698 ++ .../vendor/gopkg.in/yaml.v3/yamlh.go | 807 ++ .../vendor/gopkg.in/yaml.v3/yamlprivateh.go | 198 + mcp/dsx-exchange-mcp/vendor/modules.txt | 57 + 345 files changed, 75751 insertions(+), 2 deletions(-) create mode 100644 mcp/dsx-exchange-mcp/.gitignore create mode 100644 mcp/dsx-exchange-mcp/Architecture.md create mode 100644 mcp/dsx-exchange-mcp/Dockerfile create mode 100644 mcp/dsx-exchange-mcp/Makefile create mode 100644 mcp/dsx-exchange-mcp/README.md create mode 100644 mcp/dsx-exchange-mcp/cmd/dsx-exchange-mcp/main.go create mode 100644 mcp/dsx-exchange-mcp/deploy/helm/dsx-exchange-mcp/Chart.yaml create mode 100644 mcp/dsx-exchange-mcp/deploy/helm/dsx-exchange-mcp/templates/deployment.yaml create mode 100644 mcp/dsx-exchange-mcp/deploy/helm/dsx-exchange-mcp/templates/pdb.yaml create mode 100644 mcp/dsx-exchange-mcp/deploy/helm/dsx-exchange-mcp/templates/service.yaml create mode 100644 mcp/dsx-exchange-mcp/deploy/helm/dsx-exchange-mcp/values.deployed-bus.example.yaml create mode 100644 mcp/dsx-exchange-mcp/deploy/helm/dsx-exchange-mcp/values.yaml create mode 100644 mcp/dsx-exchange-mcp/docs/dsx-exchange-mcp-sdd.md create mode 100644 mcp/dsx-exchange-mcp/docs/local-llm-mcp-eval.md create mode 100644 mcp/dsx-exchange-mcp/docs/long-running-subscriptions-ux.md create mode 100644 mcp/dsx-exchange-mcp/docs/mcp-schema-prompt-eval-results.csv create mode 100644 mcp/dsx-exchange-mcp/docs/mcp-tasks-vs-explicit-async-tools.md create mode 100644 mcp/dsx-exchange-mcp/docs/schema-tool-question-bank.md create mode 100644 mcp/dsx-exchange-mcp/docs/sdd-discussion-notes.md create mode 100644 mcp/dsx-exchange-mcp/docs/v1-background-watch-benchmark-plan.md create mode 100644 mcp/dsx-exchange-mcp/docs/watch-state-tradeoff-note.md create mode 100644 mcp/dsx-exchange-mcp/go.mod create mode 100644 mcp/dsx-exchange-mcp/go.sum create mode 100644 mcp/dsx-exchange-mcp/internal/auth/context.go create mode 100644 mcp/dsx-exchange-mcp/internal/auth/context_test.go create mode 100644 mcp/dsx-exchange-mcp/internal/metrics/metrics.go create mode 100644 mcp/dsx-exchange-mcp/internal/mqttbus/client.go create mode 100644 mcp/dsx-exchange-mcp/internal/mqttbus/client_test.go create mode 100644 mcp/dsx-exchange-mcp/internal/mqttbus/e2e_test.go create mode 100644 mcp/dsx-exchange-mcp/internal/schemaindex/index.go create mode 100644 mcp/dsx-exchange-mcp/internal/schemaindex/index_test.go create mode 100644 mcp/dsx-exchange-mcp/internal/server/e2e_test.go create mode 100644 mcp/dsx-exchange-mcp/internal/server/llm_eval_test.go create mode 100644 mcp/dsx-exchange-mcp/internal/server/resources.go create mode 100644 mcp/dsx-exchange-mcp/internal/server/server.go create mode 100644 mcp/dsx-exchange-mcp/internal/server/testdata/tool_call_expectations.json create mode 100644 mcp/dsx-exchange-mcp/internal/server/tools.go create mode 100644 mcp/dsx-exchange-mcp/internal/server/tools_test.go create mode 100644 mcp/dsx-exchange-mcp/internal/server/watch.go create mode 100644 mcp/dsx-exchange-mcp/internal/server/watch_test.go create mode 100644 mcp/dsx-exchange-mcp/internal/server/watch_tools.go create mode 100644 mcp/dsx-exchange-mcp/internal/specs/data/.gitkeep create mode 100644 mcp/dsx-exchange-mcp/internal/specs/specs.go create mode 100644 mcp/dsx-exchange-mcp/schemas/.gitignore create mode 100644 mcp/dsx-exchange-mcp/schemas/README.md create mode 100644 mcp/dsx-exchange-mcp/schemas/asyncapi/bms/bms.yaml create mode 100644 mcp/dsx-exchange-mcp/schemas/asyncapi/launch-layer/notifications.yaml create mode 100644 mcp/dsx-exchange-mcp/schemas/asyncapi/mission-control/leak-response.yaml create mode 100644 mcp/dsx-exchange-mcp/schemas/asyncapi/monitoring/break-fix.yaml create mode 100644 mcp/dsx-exchange-mcp/schemas/asyncapi/nico/nico.yaml create mode 100644 mcp/dsx-exchange-mcp/schemas/asyncapi/nke/node-readiness.yaml create mode 100644 mcp/dsx-exchange-mcp/schemas/asyncapi/power-management/power-management.yaml create mode 100644 mcp/dsx-exchange-mcp/schemas/asyncapi/spiffe-exchange/pub-keysets.yaml create mode 100644 mcp/dsx-exchange-mcp/schemas/asyncapi/tenant/scheduler-events.yaml create mode 100644 mcp/dsx-exchange-mcp/schemas/cloud-events-example.yaml create mode 100644 mcp/dsx-exchange-mcp/schemas/embed.go create mode 100644 mcp/dsx-exchange-mcp/vendor/github.com/eclipse/paho.mqtt.golang/.gitignore create mode 100644 mcp/dsx-exchange-mcp/vendor/github.com/eclipse/paho.mqtt.golang/CODE_OF_CONDUCT.md create mode 100644 mcp/dsx-exchange-mcp/vendor/github.com/eclipse/paho.mqtt.golang/CONTRIBUTING.md create mode 100644 mcp/dsx-exchange-mcp/vendor/github.com/eclipse/paho.mqtt.golang/LICENSE create mode 100644 mcp/dsx-exchange-mcp/vendor/github.com/eclipse/paho.mqtt.golang/NOTICE.md create mode 100644 mcp/dsx-exchange-mcp/vendor/github.com/eclipse/paho.mqtt.golang/README.md create mode 100644 mcp/dsx-exchange-mcp/vendor/github.com/eclipse/paho.mqtt.golang/SECURITY.md create mode 100644 mcp/dsx-exchange-mcp/vendor/github.com/eclipse/paho.mqtt.golang/backoff.go create mode 100644 mcp/dsx-exchange-mcp/vendor/github.com/eclipse/paho.mqtt.golang/client.go create mode 100644 mcp/dsx-exchange-mcp/vendor/github.com/eclipse/paho.mqtt.golang/components.go create mode 100644 mcp/dsx-exchange-mcp/vendor/github.com/eclipse/paho.mqtt.golang/connnotf.go create mode 100644 mcp/dsx-exchange-mcp/vendor/github.com/eclipse/paho.mqtt.golang/edl-v10 create mode 100644 mcp/dsx-exchange-mcp/vendor/github.com/eclipse/paho.mqtt.golang/epl-v20 create mode 100644 mcp/dsx-exchange-mcp/vendor/github.com/eclipse/paho.mqtt.golang/filestore.go create mode 100644 mcp/dsx-exchange-mcp/vendor/github.com/eclipse/paho.mqtt.golang/memstore.go create mode 100644 mcp/dsx-exchange-mcp/vendor/github.com/eclipse/paho.mqtt.golang/memstore_ordered.go create mode 100644 mcp/dsx-exchange-mcp/vendor/github.com/eclipse/paho.mqtt.golang/message.go create mode 100644 mcp/dsx-exchange-mcp/vendor/github.com/eclipse/paho.mqtt.golang/messageids.go create mode 100644 mcp/dsx-exchange-mcp/vendor/github.com/eclipse/paho.mqtt.golang/net.go create mode 100644 mcp/dsx-exchange-mcp/vendor/github.com/eclipse/paho.mqtt.golang/netconn.go create mode 100644 mcp/dsx-exchange-mcp/vendor/github.com/eclipse/paho.mqtt.golang/oops.go create mode 100644 mcp/dsx-exchange-mcp/vendor/github.com/eclipse/paho.mqtt.golang/options.go create mode 100644 mcp/dsx-exchange-mcp/vendor/github.com/eclipse/paho.mqtt.golang/options_reader.go create mode 100644 mcp/dsx-exchange-mcp/vendor/github.com/eclipse/paho.mqtt.golang/packets/connack.go create mode 100644 mcp/dsx-exchange-mcp/vendor/github.com/eclipse/paho.mqtt.golang/packets/connect.go create mode 100644 mcp/dsx-exchange-mcp/vendor/github.com/eclipse/paho.mqtt.golang/packets/disconnect.go create mode 100644 mcp/dsx-exchange-mcp/vendor/github.com/eclipse/paho.mqtt.golang/packets/packets.go create mode 100644 mcp/dsx-exchange-mcp/vendor/github.com/eclipse/paho.mqtt.golang/packets/pingreq.go create mode 100644 mcp/dsx-exchange-mcp/vendor/github.com/eclipse/paho.mqtt.golang/packets/pingresp.go create mode 100644 mcp/dsx-exchange-mcp/vendor/github.com/eclipse/paho.mqtt.golang/packets/puback.go create mode 100644 mcp/dsx-exchange-mcp/vendor/github.com/eclipse/paho.mqtt.golang/packets/pubcomp.go create mode 100644 mcp/dsx-exchange-mcp/vendor/github.com/eclipse/paho.mqtt.golang/packets/publish.go create mode 100644 mcp/dsx-exchange-mcp/vendor/github.com/eclipse/paho.mqtt.golang/packets/pubrec.go create mode 100644 mcp/dsx-exchange-mcp/vendor/github.com/eclipse/paho.mqtt.golang/packets/pubrel.go create mode 100644 mcp/dsx-exchange-mcp/vendor/github.com/eclipse/paho.mqtt.golang/packets/suback.go create mode 100644 mcp/dsx-exchange-mcp/vendor/github.com/eclipse/paho.mqtt.golang/packets/subscribe.go create mode 100644 mcp/dsx-exchange-mcp/vendor/github.com/eclipse/paho.mqtt.golang/packets/unsuback.go create mode 100644 mcp/dsx-exchange-mcp/vendor/github.com/eclipse/paho.mqtt.golang/packets/unsubscribe.go create mode 100644 mcp/dsx-exchange-mcp/vendor/github.com/eclipse/paho.mqtt.golang/ping.go create mode 100644 mcp/dsx-exchange-mcp/vendor/github.com/eclipse/paho.mqtt.golang/router.go create mode 100644 mcp/dsx-exchange-mcp/vendor/github.com/eclipse/paho.mqtt.golang/status.go create mode 100644 mcp/dsx-exchange-mcp/vendor/github.com/eclipse/paho.mqtt.golang/store.go create mode 100644 mcp/dsx-exchange-mcp/vendor/github.com/eclipse/paho.mqtt.golang/token.go create mode 100644 mcp/dsx-exchange-mcp/vendor/github.com/eclipse/paho.mqtt.golang/topic.go create mode 100644 mcp/dsx-exchange-mcp/vendor/github.com/eclipse/paho.mqtt.golang/trace.go create mode 100644 mcp/dsx-exchange-mcp/vendor/github.com/eclipse/paho.mqtt.golang/websocket.go create mode 100644 mcp/dsx-exchange-mcp/vendor/github.com/google/jsonschema-go/LICENSE create mode 100644 mcp/dsx-exchange-mcp/vendor/github.com/google/jsonschema-go/jsonschema/annotations.go create mode 100644 mcp/dsx-exchange-mcp/vendor/github.com/google/jsonschema-go/jsonschema/doc.go create mode 100644 mcp/dsx-exchange-mcp/vendor/github.com/google/jsonschema-go/jsonschema/infer.go create mode 100644 mcp/dsx-exchange-mcp/vendor/github.com/google/jsonschema-go/jsonschema/json_pointer.go create mode 100644 mcp/dsx-exchange-mcp/vendor/github.com/google/jsonschema-go/jsonschema/resolve.go create mode 100644 mcp/dsx-exchange-mcp/vendor/github.com/google/jsonschema-go/jsonschema/schema.go create mode 100644 mcp/dsx-exchange-mcp/vendor/github.com/google/jsonschema-go/jsonschema/util.go create mode 100644 mcp/dsx-exchange-mcp/vendor/github.com/google/jsonschema-go/jsonschema/validate.go create mode 100644 mcp/dsx-exchange-mcp/vendor/github.com/gorilla/websocket/.gitignore create mode 100644 mcp/dsx-exchange-mcp/vendor/github.com/gorilla/websocket/AUTHORS create mode 100644 mcp/dsx-exchange-mcp/vendor/github.com/gorilla/websocket/LICENSE create mode 100644 mcp/dsx-exchange-mcp/vendor/github.com/gorilla/websocket/README.md create mode 100644 mcp/dsx-exchange-mcp/vendor/github.com/gorilla/websocket/client.go create mode 100644 mcp/dsx-exchange-mcp/vendor/github.com/gorilla/websocket/compression.go create mode 100644 mcp/dsx-exchange-mcp/vendor/github.com/gorilla/websocket/conn.go create mode 100644 mcp/dsx-exchange-mcp/vendor/github.com/gorilla/websocket/doc.go create mode 100644 mcp/dsx-exchange-mcp/vendor/github.com/gorilla/websocket/join.go create mode 100644 mcp/dsx-exchange-mcp/vendor/github.com/gorilla/websocket/json.go create mode 100644 mcp/dsx-exchange-mcp/vendor/github.com/gorilla/websocket/mask.go create mode 100644 mcp/dsx-exchange-mcp/vendor/github.com/gorilla/websocket/mask_safe.go create mode 100644 mcp/dsx-exchange-mcp/vendor/github.com/gorilla/websocket/prepared.go create mode 100644 mcp/dsx-exchange-mcp/vendor/github.com/gorilla/websocket/proxy.go create mode 100644 mcp/dsx-exchange-mcp/vendor/github.com/gorilla/websocket/server.go create mode 100644 mcp/dsx-exchange-mcp/vendor/github.com/gorilla/websocket/tls_handshake.go create mode 100644 mcp/dsx-exchange-mcp/vendor/github.com/gorilla/websocket/tls_handshake_116.go create mode 100644 mcp/dsx-exchange-mcp/vendor/github.com/gorilla/websocket/util.go create mode 100644 mcp/dsx-exchange-mcp/vendor/github.com/gorilla/websocket/x_net_proxy.go create mode 100644 mcp/dsx-exchange-mcp/vendor/github.com/modelcontextprotocol/go-sdk/LICENSE create mode 100644 mcp/dsx-exchange-mcp/vendor/github.com/modelcontextprotocol/go-sdk/auth/auth.go create mode 100644 mcp/dsx-exchange-mcp/vendor/github.com/modelcontextprotocol/go-sdk/auth/authorization_code.go create mode 100644 mcp/dsx-exchange-mcp/vendor/github.com/modelcontextprotocol/go-sdk/auth/client.go create mode 100644 mcp/dsx-exchange-mcp/vendor/github.com/modelcontextprotocol/go-sdk/auth/client_private.go create mode 100644 mcp/dsx-exchange-mcp/vendor/github.com/modelcontextprotocol/go-sdk/internal/json/json.go create mode 100644 mcp/dsx-exchange-mcp/vendor/github.com/modelcontextprotocol/go-sdk/internal/jsonrpc2/conn.go create mode 100644 mcp/dsx-exchange-mcp/vendor/github.com/modelcontextprotocol/go-sdk/internal/jsonrpc2/frame.go create mode 100644 mcp/dsx-exchange-mcp/vendor/github.com/modelcontextprotocol/go-sdk/internal/jsonrpc2/jsonrpc2.go create mode 100644 mcp/dsx-exchange-mcp/vendor/github.com/modelcontextprotocol/go-sdk/internal/jsonrpc2/messages.go create mode 100644 mcp/dsx-exchange-mcp/vendor/github.com/modelcontextprotocol/go-sdk/internal/jsonrpc2/net.go create mode 100644 mcp/dsx-exchange-mcp/vendor/github.com/modelcontextprotocol/go-sdk/internal/jsonrpc2/serve.go create mode 100644 mcp/dsx-exchange-mcp/vendor/github.com/modelcontextprotocol/go-sdk/internal/jsonrpc2/wire.go create mode 100644 mcp/dsx-exchange-mcp/vendor/github.com/modelcontextprotocol/go-sdk/internal/mcpgodebug/mcpgodebug.go create mode 100644 mcp/dsx-exchange-mcp/vendor/github.com/modelcontextprotocol/go-sdk/internal/util/net.go create mode 100644 mcp/dsx-exchange-mcp/vendor/github.com/modelcontextprotocol/go-sdk/internal/util/util.go create mode 100644 mcp/dsx-exchange-mcp/vendor/github.com/modelcontextprotocol/go-sdk/internal/xcontext/xcontext.go create mode 100644 mcp/dsx-exchange-mcp/vendor/github.com/modelcontextprotocol/go-sdk/jsonrpc/jsonrpc.go create mode 100644 mcp/dsx-exchange-mcp/vendor/github.com/modelcontextprotocol/go-sdk/mcp/client.go create mode 100644 mcp/dsx-exchange-mcp/vendor/github.com/modelcontextprotocol/go-sdk/mcp/cmd.go create mode 100644 mcp/dsx-exchange-mcp/vendor/github.com/modelcontextprotocol/go-sdk/mcp/content.go create mode 100644 mcp/dsx-exchange-mcp/vendor/github.com/modelcontextprotocol/go-sdk/mcp/event.go create mode 100644 mcp/dsx-exchange-mcp/vendor/github.com/modelcontextprotocol/go-sdk/mcp/features.go create mode 100644 mcp/dsx-exchange-mcp/vendor/github.com/modelcontextprotocol/go-sdk/mcp/logging.go create mode 100644 mcp/dsx-exchange-mcp/vendor/github.com/modelcontextprotocol/go-sdk/mcp/mcp.go create mode 100644 mcp/dsx-exchange-mcp/vendor/github.com/modelcontextprotocol/go-sdk/mcp/prompt.go create mode 100644 mcp/dsx-exchange-mcp/vendor/github.com/modelcontextprotocol/go-sdk/mcp/protocol.go create mode 100644 mcp/dsx-exchange-mcp/vendor/github.com/modelcontextprotocol/go-sdk/mcp/requests.go create mode 100644 mcp/dsx-exchange-mcp/vendor/github.com/modelcontextprotocol/go-sdk/mcp/resource.go create mode 100644 mcp/dsx-exchange-mcp/vendor/github.com/modelcontextprotocol/go-sdk/mcp/schema_cache.go create mode 100644 mcp/dsx-exchange-mcp/vendor/github.com/modelcontextprotocol/go-sdk/mcp/server.go create mode 100644 mcp/dsx-exchange-mcp/vendor/github.com/modelcontextprotocol/go-sdk/mcp/session.go create mode 100644 mcp/dsx-exchange-mcp/vendor/github.com/modelcontextprotocol/go-sdk/mcp/shared.go create mode 100644 mcp/dsx-exchange-mcp/vendor/github.com/modelcontextprotocol/go-sdk/mcp/sse.go create mode 100644 mcp/dsx-exchange-mcp/vendor/github.com/modelcontextprotocol/go-sdk/mcp/streamable.go create mode 100644 mcp/dsx-exchange-mcp/vendor/github.com/modelcontextprotocol/go-sdk/mcp/streamable_client.go create mode 100644 mcp/dsx-exchange-mcp/vendor/github.com/modelcontextprotocol/go-sdk/mcp/streamable_server.go create mode 100644 mcp/dsx-exchange-mcp/vendor/github.com/modelcontextprotocol/go-sdk/mcp/tool.go create mode 100644 mcp/dsx-exchange-mcp/vendor/github.com/modelcontextprotocol/go-sdk/mcp/transport.go create mode 100644 mcp/dsx-exchange-mcp/vendor/github.com/modelcontextprotocol/go-sdk/mcp/util.go create mode 100644 mcp/dsx-exchange-mcp/vendor/github.com/modelcontextprotocol/go-sdk/oauthex/auth_meta.go create mode 100644 mcp/dsx-exchange-mcp/vendor/github.com/modelcontextprotocol/go-sdk/oauthex/dcr.go create mode 100644 mcp/dsx-exchange-mcp/vendor/github.com/modelcontextprotocol/go-sdk/oauthex/oauth2.go create mode 100644 mcp/dsx-exchange-mcp/vendor/github.com/modelcontextprotocol/go-sdk/oauthex/oauthex.go create mode 100644 mcp/dsx-exchange-mcp/vendor/github.com/modelcontextprotocol/go-sdk/oauthex/resource_meta.go create mode 100644 mcp/dsx-exchange-mcp/vendor/github.com/modelcontextprotocol/go-sdk/oauthex/resource_meta_public.go create mode 100644 mcp/dsx-exchange-mcp/vendor/github.com/segmentio/asm/LICENSE create mode 100644 mcp/dsx-exchange-mcp/vendor/github.com/segmentio/asm/ascii/ascii.go create mode 100644 mcp/dsx-exchange-mcp/vendor/github.com/segmentio/asm/ascii/equal_fold.go create mode 100644 mcp/dsx-exchange-mcp/vendor/github.com/segmentio/asm/ascii/equal_fold_amd64.go create mode 100644 mcp/dsx-exchange-mcp/vendor/github.com/segmentio/asm/ascii/equal_fold_amd64.s create mode 100644 mcp/dsx-exchange-mcp/vendor/github.com/segmentio/asm/ascii/equal_fold_default.go create mode 100644 mcp/dsx-exchange-mcp/vendor/github.com/segmentio/asm/ascii/valid.go create mode 100644 mcp/dsx-exchange-mcp/vendor/github.com/segmentio/asm/ascii/valid_amd64.go create mode 100644 mcp/dsx-exchange-mcp/vendor/github.com/segmentio/asm/ascii/valid_amd64.s create mode 100644 mcp/dsx-exchange-mcp/vendor/github.com/segmentio/asm/ascii/valid_default.go create mode 100644 mcp/dsx-exchange-mcp/vendor/github.com/segmentio/asm/ascii/valid_print.go create mode 100644 mcp/dsx-exchange-mcp/vendor/github.com/segmentio/asm/ascii/valid_print_amd64.go create mode 100644 mcp/dsx-exchange-mcp/vendor/github.com/segmentio/asm/ascii/valid_print_amd64.s create mode 100644 mcp/dsx-exchange-mcp/vendor/github.com/segmentio/asm/ascii/valid_print_default.go create mode 100644 mcp/dsx-exchange-mcp/vendor/github.com/segmentio/asm/base64/base64.go create mode 100644 mcp/dsx-exchange-mcp/vendor/github.com/segmentio/asm/base64/base64_amd64.go create mode 100644 mcp/dsx-exchange-mcp/vendor/github.com/segmentio/asm/base64/base64_default.go create mode 100644 mcp/dsx-exchange-mcp/vendor/github.com/segmentio/asm/base64/decode_amd64.go create mode 100644 mcp/dsx-exchange-mcp/vendor/github.com/segmentio/asm/base64/decode_amd64.s create mode 100644 mcp/dsx-exchange-mcp/vendor/github.com/segmentio/asm/base64/encode_amd64.go create mode 100644 mcp/dsx-exchange-mcp/vendor/github.com/segmentio/asm/base64/encode_amd64.s create mode 100644 mcp/dsx-exchange-mcp/vendor/github.com/segmentio/asm/cpu/arm/arm.go create mode 100644 mcp/dsx-exchange-mcp/vendor/github.com/segmentio/asm/cpu/arm64/arm64.go create mode 100644 mcp/dsx-exchange-mcp/vendor/github.com/segmentio/asm/cpu/cpu.go create mode 100644 mcp/dsx-exchange-mcp/vendor/github.com/segmentio/asm/cpu/cpuid/cpuid.go create mode 100644 mcp/dsx-exchange-mcp/vendor/github.com/segmentio/asm/cpu/x86/x86.go create mode 100644 mcp/dsx-exchange-mcp/vendor/github.com/segmentio/asm/internal/unsafebytes/unsafebytes.go create mode 100644 mcp/dsx-exchange-mcp/vendor/github.com/segmentio/asm/keyset/keyset.go create mode 100644 mcp/dsx-exchange-mcp/vendor/github.com/segmentio/asm/keyset/keyset_amd64.go create mode 100644 mcp/dsx-exchange-mcp/vendor/github.com/segmentio/asm/keyset/keyset_amd64.s create mode 100644 mcp/dsx-exchange-mcp/vendor/github.com/segmentio/asm/keyset/keyset_arm64.go create mode 100644 mcp/dsx-exchange-mcp/vendor/github.com/segmentio/asm/keyset/keyset_arm64.s create mode 100644 mcp/dsx-exchange-mcp/vendor/github.com/segmentio/asm/keyset/keyset_default.go create mode 100644 mcp/dsx-exchange-mcp/vendor/github.com/segmentio/encoding/LICENSE create mode 100644 mcp/dsx-exchange-mcp/vendor/github.com/segmentio/encoding/ascii/equal_fold.go create mode 100644 mcp/dsx-exchange-mcp/vendor/github.com/segmentio/encoding/ascii/valid.go create mode 100644 mcp/dsx-exchange-mcp/vendor/github.com/segmentio/encoding/ascii/valid_print.go create mode 100644 mcp/dsx-exchange-mcp/vendor/github.com/segmentio/encoding/iso8601/parse.go create mode 100644 mcp/dsx-exchange-mcp/vendor/github.com/segmentio/encoding/iso8601/valid.go create mode 100644 mcp/dsx-exchange-mcp/vendor/github.com/segmentio/encoding/json/README.md create mode 100644 mcp/dsx-exchange-mcp/vendor/github.com/segmentio/encoding/json/codec.go create mode 100644 mcp/dsx-exchange-mcp/vendor/github.com/segmentio/encoding/json/decode.go create mode 100644 mcp/dsx-exchange-mcp/vendor/github.com/segmentio/encoding/json/encode.go create mode 100644 mcp/dsx-exchange-mcp/vendor/github.com/segmentio/encoding/json/int.go create mode 100644 mcp/dsx-exchange-mcp/vendor/github.com/segmentio/encoding/json/json.go create mode 100644 mcp/dsx-exchange-mcp/vendor/github.com/segmentio/encoding/json/parse.go create mode 100644 mcp/dsx-exchange-mcp/vendor/github.com/segmentio/encoding/json/reflect.go create mode 100644 mcp/dsx-exchange-mcp/vendor/github.com/segmentio/encoding/json/reflect_optimize.go create mode 100644 mcp/dsx-exchange-mcp/vendor/github.com/segmentio/encoding/json/string.go create mode 100644 mcp/dsx-exchange-mcp/vendor/github.com/segmentio/encoding/json/token.go create mode 100644 mcp/dsx-exchange-mcp/vendor/github.com/yosida95/uritemplate/v3/LICENSE create mode 100644 mcp/dsx-exchange-mcp/vendor/github.com/yosida95/uritemplate/v3/README.rst create mode 100644 mcp/dsx-exchange-mcp/vendor/github.com/yosida95/uritemplate/v3/compile.go create mode 100644 mcp/dsx-exchange-mcp/vendor/github.com/yosida95/uritemplate/v3/equals.go create mode 100644 mcp/dsx-exchange-mcp/vendor/github.com/yosida95/uritemplate/v3/error.go create mode 100644 mcp/dsx-exchange-mcp/vendor/github.com/yosida95/uritemplate/v3/escape.go create mode 100644 mcp/dsx-exchange-mcp/vendor/github.com/yosida95/uritemplate/v3/expression.go create mode 100644 mcp/dsx-exchange-mcp/vendor/github.com/yosida95/uritemplate/v3/machine.go create mode 100644 mcp/dsx-exchange-mcp/vendor/github.com/yosida95/uritemplate/v3/match.go create mode 100644 mcp/dsx-exchange-mcp/vendor/github.com/yosida95/uritemplate/v3/parse.go create mode 100644 mcp/dsx-exchange-mcp/vendor/github.com/yosida95/uritemplate/v3/prog.go create mode 100644 mcp/dsx-exchange-mcp/vendor/github.com/yosida95/uritemplate/v3/uritemplate.go create mode 100644 mcp/dsx-exchange-mcp/vendor/github.com/yosida95/uritemplate/v3/value.go create mode 100644 mcp/dsx-exchange-mcp/vendor/golang.org/x/net/LICENSE create mode 100644 mcp/dsx-exchange-mcp/vendor/golang.org/x/net/PATENTS create mode 100644 mcp/dsx-exchange-mcp/vendor/golang.org/x/net/internal/socks/client.go create mode 100644 mcp/dsx-exchange-mcp/vendor/golang.org/x/net/internal/socks/socks.go create mode 100644 mcp/dsx-exchange-mcp/vendor/golang.org/x/net/proxy/dial.go create mode 100644 mcp/dsx-exchange-mcp/vendor/golang.org/x/net/proxy/direct.go create mode 100644 mcp/dsx-exchange-mcp/vendor/golang.org/x/net/proxy/per_host.go create mode 100644 mcp/dsx-exchange-mcp/vendor/golang.org/x/net/proxy/proxy.go create mode 100644 mcp/dsx-exchange-mcp/vendor/golang.org/x/net/proxy/socks5.go create mode 100644 mcp/dsx-exchange-mcp/vendor/golang.org/x/oauth2/.travis.yml create mode 100644 mcp/dsx-exchange-mcp/vendor/golang.org/x/oauth2/CONTRIBUTING.md create mode 100644 mcp/dsx-exchange-mcp/vendor/golang.org/x/oauth2/LICENSE create mode 100644 mcp/dsx-exchange-mcp/vendor/golang.org/x/oauth2/README.md create mode 100644 mcp/dsx-exchange-mcp/vendor/golang.org/x/oauth2/deviceauth.go create mode 100644 mcp/dsx-exchange-mcp/vendor/golang.org/x/oauth2/internal/doc.go create mode 100644 mcp/dsx-exchange-mcp/vendor/golang.org/x/oauth2/internal/oauth2.go create mode 100644 mcp/dsx-exchange-mcp/vendor/golang.org/x/oauth2/internal/token.go create mode 100644 mcp/dsx-exchange-mcp/vendor/golang.org/x/oauth2/internal/transport.go create mode 100644 mcp/dsx-exchange-mcp/vendor/golang.org/x/oauth2/oauth2.go create mode 100644 mcp/dsx-exchange-mcp/vendor/golang.org/x/oauth2/pkce.go create mode 100644 mcp/dsx-exchange-mcp/vendor/golang.org/x/oauth2/token.go create mode 100644 mcp/dsx-exchange-mcp/vendor/golang.org/x/oauth2/transport.go create mode 100644 mcp/dsx-exchange-mcp/vendor/golang.org/x/sync/LICENSE create mode 100644 mcp/dsx-exchange-mcp/vendor/golang.org/x/sync/PATENTS create mode 100644 mcp/dsx-exchange-mcp/vendor/golang.org/x/sync/semaphore/semaphore.go create mode 100644 mcp/dsx-exchange-mcp/vendor/golang.org/x/sys/LICENSE create mode 100644 mcp/dsx-exchange-mcp/vendor/golang.org/x/sys/PATENTS create mode 100644 mcp/dsx-exchange-mcp/vendor/golang.org/x/sys/cpu/asm_aix_ppc64.s create mode 100644 mcp/dsx-exchange-mcp/vendor/golang.org/x/sys/cpu/asm_darwin_x86_gc.s create mode 100644 mcp/dsx-exchange-mcp/vendor/golang.org/x/sys/cpu/byteorder.go create mode 100644 mcp/dsx-exchange-mcp/vendor/golang.org/x/sys/cpu/cpu.go create mode 100644 mcp/dsx-exchange-mcp/vendor/golang.org/x/sys/cpu/cpu_aix.go create mode 100644 mcp/dsx-exchange-mcp/vendor/golang.org/x/sys/cpu/cpu_arm.go create mode 100644 mcp/dsx-exchange-mcp/vendor/golang.org/x/sys/cpu/cpu_arm64.go create mode 100644 mcp/dsx-exchange-mcp/vendor/golang.org/x/sys/cpu/cpu_arm64.s create mode 100644 mcp/dsx-exchange-mcp/vendor/golang.org/x/sys/cpu/cpu_darwin_x86.go create mode 100644 mcp/dsx-exchange-mcp/vendor/golang.org/x/sys/cpu/cpu_gc_arm64.go create mode 100644 mcp/dsx-exchange-mcp/vendor/golang.org/x/sys/cpu/cpu_gc_s390x.go create mode 100644 mcp/dsx-exchange-mcp/vendor/golang.org/x/sys/cpu/cpu_gc_x86.go create mode 100644 mcp/dsx-exchange-mcp/vendor/golang.org/x/sys/cpu/cpu_gc_x86.s create mode 100644 mcp/dsx-exchange-mcp/vendor/golang.org/x/sys/cpu/cpu_gccgo_arm64.go create mode 100644 mcp/dsx-exchange-mcp/vendor/golang.org/x/sys/cpu/cpu_gccgo_s390x.go create mode 100644 mcp/dsx-exchange-mcp/vendor/golang.org/x/sys/cpu/cpu_gccgo_x86.c create mode 100644 mcp/dsx-exchange-mcp/vendor/golang.org/x/sys/cpu/cpu_gccgo_x86.go create mode 100644 mcp/dsx-exchange-mcp/vendor/golang.org/x/sys/cpu/cpu_linux.go create mode 100644 mcp/dsx-exchange-mcp/vendor/golang.org/x/sys/cpu/cpu_linux_arm.go create mode 100644 mcp/dsx-exchange-mcp/vendor/golang.org/x/sys/cpu/cpu_linux_arm64.go create mode 100644 mcp/dsx-exchange-mcp/vendor/golang.org/x/sys/cpu/cpu_linux_loong64.go create mode 100644 mcp/dsx-exchange-mcp/vendor/golang.org/x/sys/cpu/cpu_linux_mips64x.go create mode 100644 mcp/dsx-exchange-mcp/vendor/golang.org/x/sys/cpu/cpu_linux_noinit.go create mode 100644 mcp/dsx-exchange-mcp/vendor/golang.org/x/sys/cpu/cpu_linux_ppc64x.go create mode 100644 mcp/dsx-exchange-mcp/vendor/golang.org/x/sys/cpu/cpu_linux_riscv64.go create mode 100644 mcp/dsx-exchange-mcp/vendor/golang.org/x/sys/cpu/cpu_linux_s390x.go create mode 100644 mcp/dsx-exchange-mcp/vendor/golang.org/x/sys/cpu/cpu_loong64.go create mode 100644 mcp/dsx-exchange-mcp/vendor/golang.org/x/sys/cpu/cpu_loong64.s create mode 100644 mcp/dsx-exchange-mcp/vendor/golang.org/x/sys/cpu/cpu_mips64x.go create mode 100644 mcp/dsx-exchange-mcp/vendor/golang.org/x/sys/cpu/cpu_mipsx.go create mode 100644 mcp/dsx-exchange-mcp/vendor/golang.org/x/sys/cpu/cpu_netbsd_arm64.go create mode 100644 mcp/dsx-exchange-mcp/vendor/golang.org/x/sys/cpu/cpu_openbsd_arm64.go create mode 100644 mcp/dsx-exchange-mcp/vendor/golang.org/x/sys/cpu/cpu_openbsd_arm64.s create mode 100644 mcp/dsx-exchange-mcp/vendor/golang.org/x/sys/cpu/cpu_other_arm.go create mode 100644 mcp/dsx-exchange-mcp/vendor/golang.org/x/sys/cpu/cpu_other_arm64.go create mode 100644 mcp/dsx-exchange-mcp/vendor/golang.org/x/sys/cpu/cpu_other_mips64x.go create mode 100644 mcp/dsx-exchange-mcp/vendor/golang.org/x/sys/cpu/cpu_other_ppc64x.go create mode 100644 mcp/dsx-exchange-mcp/vendor/golang.org/x/sys/cpu/cpu_other_riscv64.go create mode 100644 mcp/dsx-exchange-mcp/vendor/golang.org/x/sys/cpu/cpu_other_x86.go create mode 100644 mcp/dsx-exchange-mcp/vendor/golang.org/x/sys/cpu/cpu_ppc64x.go create mode 100644 mcp/dsx-exchange-mcp/vendor/golang.org/x/sys/cpu/cpu_riscv64.go create mode 100644 mcp/dsx-exchange-mcp/vendor/golang.org/x/sys/cpu/cpu_s390x.go create mode 100644 mcp/dsx-exchange-mcp/vendor/golang.org/x/sys/cpu/cpu_s390x.s create mode 100644 mcp/dsx-exchange-mcp/vendor/golang.org/x/sys/cpu/cpu_wasm.go create mode 100644 mcp/dsx-exchange-mcp/vendor/golang.org/x/sys/cpu/cpu_x86.go create mode 100644 mcp/dsx-exchange-mcp/vendor/golang.org/x/sys/cpu/cpu_zos.go create mode 100644 mcp/dsx-exchange-mcp/vendor/golang.org/x/sys/cpu/cpu_zos_s390x.go create mode 100644 mcp/dsx-exchange-mcp/vendor/golang.org/x/sys/cpu/endian_big.go create mode 100644 mcp/dsx-exchange-mcp/vendor/golang.org/x/sys/cpu/endian_little.go create mode 100644 mcp/dsx-exchange-mcp/vendor/golang.org/x/sys/cpu/hwcap_linux.go create mode 100644 mcp/dsx-exchange-mcp/vendor/golang.org/x/sys/cpu/parse.go create mode 100644 mcp/dsx-exchange-mcp/vendor/golang.org/x/sys/cpu/proc_cpuinfo_linux.go create mode 100644 mcp/dsx-exchange-mcp/vendor/golang.org/x/sys/cpu/runtime_auxv.go create mode 100644 mcp/dsx-exchange-mcp/vendor/golang.org/x/sys/cpu/runtime_auxv_go121.go create mode 100644 mcp/dsx-exchange-mcp/vendor/golang.org/x/sys/cpu/syscall_aix_gccgo.go create mode 100644 mcp/dsx-exchange-mcp/vendor/golang.org/x/sys/cpu/syscall_aix_ppc64_gc.go create mode 100644 mcp/dsx-exchange-mcp/vendor/golang.org/x/sys/cpu/syscall_darwin_x86_gc.go create mode 100644 mcp/dsx-exchange-mcp/vendor/gopkg.in/yaml.v3/LICENSE create mode 100644 mcp/dsx-exchange-mcp/vendor/gopkg.in/yaml.v3/NOTICE create mode 100644 mcp/dsx-exchange-mcp/vendor/gopkg.in/yaml.v3/README.md create mode 100644 mcp/dsx-exchange-mcp/vendor/gopkg.in/yaml.v3/apic.go create mode 100644 mcp/dsx-exchange-mcp/vendor/gopkg.in/yaml.v3/decode.go create mode 100644 mcp/dsx-exchange-mcp/vendor/gopkg.in/yaml.v3/emitterc.go create mode 100644 mcp/dsx-exchange-mcp/vendor/gopkg.in/yaml.v3/encode.go create mode 100644 mcp/dsx-exchange-mcp/vendor/gopkg.in/yaml.v3/parserc.go create mode 100644 mcp/dsx-exchange-mcp/vendor/gopkg.in/yaml.v3/readerc.go create mode 100644 mcp/dsx-exchange-mcp/vendor/gopkg.in/yaml.v3/resolve.go create mode 100644 mcp/dsx-exchange-mcp/vendor/gopkg.in/yaml.v3/scannerc.go create mode 100644 mcp/dsx-exchange-mcp/vendor/gopkg.in/yaml.v3/sorter.go create mode 100644 mcp/dsx-exchange-mcp/vendor/gopkg.in/yaml.v3/writerc.go create mode 100644 mcp/dsx-exchange-mcp/vendor/gopkg.in/yaml.v3/yaml.go create mode 100644 mcp/dsx-exchange-mcp/vendor/gopkg.in/yaml.v3/yamlh.go create mode 100644 mcp/dsx-exchange-mcp/vendor/gopkg.in/yaml.v3/yamlprivateh.go create mode 100644 mcp/dsx-exchange-mcp/vendor/modules.txt diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2cd2735..071fce6 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -58,6 +58,17 @@ jobs: go-flags: "-mod=vendor" working-directory: auth-callout + go-lint-mcp: + name: Go Lint (mcp/dsx-exchange-mcp) + runs-on: linux-amd64-cpu4 + steps: + - uses: actions/checkout@v4 + - uses: NVIDIA/dsx-github-actions/.github/actions/go-lint@07b465c2147fcbda4836cb869263577ea4719273 # v1.16.1 + with: + go-version: "1.25.5" + go-flags: "-mod=vendor" + working-directory: mcp/dsx-exchange-mcp + unit-test: name: Unit Test (auth-callout) runs-on: linux-amd64-cpu4 @@ -99,6 +110,29 @@ jobs: working-directory: local/mqttbs run: go test ./... + unit-test-mcp: + name: Unit Test (mcp/dsx-exchange-mcp) + runs-on: linux-amd64-cpu4 + steps: + - uses: actions/checkout@v4 + - uses: NVIDIA/dsx-github-actions/.github/actions/go-test@07b465c2147fcbda4836cb869263577ea4719273 # v1.16.1 + with: + go-version: "1.25.5" + go-flags: "-mod=vendor" + test-flags: "-v -count=1 -timeout=10m" + working-directory: mcp/dsx-exchange-mcp + + + schema-sync-mcp: + name: Schema Sync (mcp/dsx-exchange-mcp) + runs-on: linux-amd64-cpu4 + steps: + - uses: actions/checkout@v4 + - name: Refresh MCP embedded schemas + run: make -C mcp/dsx-exchange-mcp sync-specs + - name: Verify MCP embedded schemas are current + run: git diff --exit-code -- mcp/dsx-exchange-mcp/schemas + third-party-licenses: name: Third-Party Licenses runs-on: linux-amd64-cpu4 @@ -108,7 +142,9 @@ jobs: with: go-version: "1.25.5" cache: true - cache-dependency-path: auth-callout/go.sum + cache-dependency-path: | + auth-callout/go.sum + mcp/dsx-exchange-mcp/go.sum - name: Regenerate THIRD_PARTY_LICENSES.csv via Makefile # Uses auth-callout/scripts/regenerate-third-party-licenses.sh which # filters local packages and applies multi-license overrides. @@ -133,6 +169,20 @@ jobs: platforms: linux/amd64 push: "false" + docker-build-mcp: + name: Docker Build (mcp/dsx-exchange-mcp) + runs-on: linux-amd64-cpu4 + steps: + - uses: actions/checkout@v4 + - uses: NVIDIA/dsx-github-actions/.github/actions/docker-build@07b465c2147fcbda4836cb869263577ea4719273 # v1.16.1 + with: + image: dsx-exchange-mcp + tags: ci-validation + context: mcp/dsx-exchange-mcp + dockerfile: mcp/dsx-exchange-mcp/Dockerfile + platforms: linux/amd64 + push: "false" + codeql-scan: name: CodeQL SAST runs-on: linux-amd64-cpu4 @@ -187,6 +237,24 @@ jobs: with: image: dsx-exchange-auth-callout:scan-validation + security-container-scan-mcp: + name: Container Scan (mcp/dsx-exchange-mcp) + needs: docker-build-mcp + runs-on: linux-amd64-cpu4 + steps: + - uses: actions/checkout@v4 + - uses: NVIDIA/dsx-github-actions/.github/actions/docker-build@07b465c2147fcbda4836cb869263577ea4719273 # v1.16.1 + with: + image: dsx-exchange-mcp + tags: scan-validation + context: mcp/dsx-exchange-mcp + dockerfile: mcp/dsx-exchange-mcp/Dockerfile + platforms: linux/amd64 + push: "false" + - uses: NVIDIA/dsx-github-actions/.github/actions/security-container-scan@07b465c2147fcbda4836cb869263577ea4719273 # v1.16.1 + with: + image: dsx-exchange-mcp:scan-validation + helm-validate-auth-callout: name: Helm Validate (auth-callout) runs-on: linux-amd64-cpu4 @@ -209,18 +277,32 @@ jobs: # Full dependency build happens on GitLab publish pipeline. template: "false" + helm-validate-mcp: + name: Helm Validate (mcp/dsx-exchange-mcp) + runs-on: linux-amd64-cpu4 + steps: + - uses: actions/checkout@v4 + - uses: NVIDIA/dsx-github-actions/.github/actions/helm-validate@07b465c2147fcbda4836cb869263577ea4719273 # v1.16.1 + with: + chart-path: mcp/dsx-exchange-mcp/deploy/helm/dsx-exchange-mcp + e2e-kind: name: E2E (Kind) needs: - license-headers - go-lint + - go-lint-mcp - unit-test - unit-test-local-mqtt - unit-test-local-mqttbs + - unit-test-mcp + - schema-sync-mcp - third-party-licenses - docker-build + - docker-build-mcp - helm-validate-auth-callout - helm-validate-nats-event-bus + - helm-validate-mcp uses: ./.github/workflows/e2e-kind.yml slack-notify: @@ -230,16 +312,22 @@ jobs: - commitlint - license-headers - go-lint + - go-lint-mcp - unit-test - unit-test-local-mqtt - unit-test-local-mqttbs + - unit-test-mcp + - schema-sync-mcp - third-party-licenses - docker-build + - docker-build-mcp - codeql-scan - trufflehog-scan - security-container-scan + - security-container-scan-mcp - helm-validate-auth-callout - helm-validate-nats-event-bus + - helm-validate-mcp - e2e-kind runs-on: linux-amd64-cpu4 environment: notifications diff --git a/Makefile b/Makefile index c26501f..a41a754 100644 --- a/Makefile +++ b/Makefile @@ -1,7 +1,7 @@ # Copyright 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. # SPDX-License-Identifier: Apache-2.0 -.PHONY: add-license-headers check check-license-headers clean-e2e dummy-bms help install-e2e-prereqs skaffold-dev test test-dev third-party-licenses +.PHONY: add-license-headers check check-license-headers clean-e2e dummy-bms help install-e2e-prereqs mcp-build mcp-lint mcp-sync-specs mcp-test skaffold-dev test test-dev third-party-licenses add-license-headers: ## Add SPDX license headers across repository sources bash scripts/license.sh fix @@ -14,6 +14,7 @@ check: ## Run static validation checks helm lint auth-callout/deploy helm template --dependency-update --repository-config local/helm/repositories.yaml nats-event-bus deploy/nats-event-bus >/dev/null helm lint deploy/nats-event-bus + helm lint mcp/dsx-exchange-mcp/deploy/helm/dsx-exchange-mcp clean-e2e: ## Delete local Kind clusters and generated e2e artifacts $(MAKE) -C local clean @@ -24,12 +25,25 @@ dummy-bms: ## Publish looping dummy BMS data to the local CSC MQTT broker install-e2e-prereqs: ## Install tools required by local Kind e2e workflows $(MAKE) -C local install-e2e-prereqs +mcp-build: ## Build the DSX Exchange MCP server + $(MAKE) -C mcp/dsx-exchange-mcp build + +mcp-lint: ## Run DSX Exchange MCP static checks + $(MAKE) -C mcp/dsx-exchange-mcp lint + +mcp-sync-specs: ## Refresh the DSX Exchange MCP embedded schema copy + $(MAKE) -C mcp/dsx-exchange-mcp sync-specs + +mcp-test: ## Run DSX Exchange MCP unit tests + $(MAKE) -C mcp/dsx-exchange-mcp test + test: ## Run the full validation suite $(MAKE) check $(MAKE) -C auth-callout test cd auth-callout/tests && go test -short ./... cd local/mqtt-client && go test ./pkg/... ./internal/... ./cmd/... cd local/mqttbs && go test ./... + $(MAKE) -C mcp/dsx-exchange-mcp test $(MAKE) -C local test test-dev: ## Run local e2e tests against an already running local stack diff --git a/README.md b/README.md index c18e687..95d48dc 100644 --- a/README.md +++ b/README.md @@ -12,6 +12,7 @@ DSX Exchange provides the repository pieces needed to describe, deploy, and vali - `auth-callout`: NATS auth callout service for OAuth2, mTLS, NKey, and no-auth profiles. - `deploy`: Helm chart for the NATS event bus deployment. - `local`: Kind-based local evaluation environment, Skaffold deployment, MQTT tests, and benchmark tooling. +- `mcp/dsx-exchange-mcp`: MCP server for DSX Exchange schemas, topic discovery, and read-only MQTT tools. The event bus itself is schema agnostic. Schemas document externally visible contracts; NATS and the auth callout enforce routing, federation, and authorization behavior. @@ -58,6 +59,7 @@ Run component-specific targets from the directory you are changing, and use ```bash make -C auth-callout test +make -C mcp/dsx-exchange-mcp test make check ``` diff --git a/THIRD_PARTY_LICENSES.csv b/THIRD_PARTY_LICENSES.csv index 50da0a3..264a14b 100644 --- a/THIRD_PARTY_LICENSES.csv +++ b/THIRD_PARTY_LICENSES.csv @@ -12,6 +12,7 @@ github.com/go-logr/logr,https://github.com/NVIDIA/dsx-exchange/blob/HEAD/auth-ca github.com/go-logr/stdr,https://github.com/NVIDIA/dsx-exchange/blob/HEAD/auth-callout/vendor/github.com/go-logr/stdr/LICENSE,Apache-2.0 github.com/go-viper/mapstructure/v2,https://github.com/NVIDIA/dsx-exchange/blob/HEAD/auth-callout/vendor/github.com/go-viper/mapstructure/v2/LICENSE,MIT github.com/golang-jwt/jwt/v5,https://github.com/NVIDIA/dsx-exchange/blob/HEAD/auth-callout/vendor/github.com/golang-jwt/jwt/v5/LICENSE,MIT +github.com/google/jsonschema-go/jsonschema,https://github.com/NVIDIA/dsx-exchange/blob/HEAD/mcp/dsx-exchange-mcp/vendor/github.com/google/jsonschema-go/LICENSE,MIT github.com/google/uuid,https://github.com/NVIDIA/dsx-exchange/blob/HEAD/auth-callout/vendor/github.com/google/uuid/LICENSE,BSD-3-Clause github.com/gorilla/handlers,https://github.com/NVIDIA/dsx-exchange/blob/HEAD/auth-callout/vendor/github.com/gorilla/handlers/LICENSE,BSD-3-Clause github.com/gorilla/mux,https://github.com/NVIDIA/dsx-exchange/blob/HEAD/auth-callout/vendor/github.com/gorilla/mux/LICENSE,BSD-3-Clause @@ -33,6 +34,7 @@ github.com/knadh/koanf/v2,https://github.com/NVIDIA/dsx-exchange/blob/HEAD/auth- github.com/minio/highwayhash,https://github.com/NVIDIA/dsx-exchange/blob/HEAD/auth-callout/vendor/github.com/minio/highwayhash/LICENSE,Apache-2.0 github.com/mitchellh/copystructure,https://github.com/NVIDIA/dsx-exchange/blob/HEAD/auth-callout/vendor/github.com/mitchellh/copystructure/LICENSE,MIT github.com/mitchellh/reflectwalk,https://github.com/NVIDIA/dsx-exchange/blob/HEAD/auth-callout/vendor/github.com/mitchellh/reflectwalk/LICENSE,MIT +github.com/modelcontextprotocol/go-sdk,https://github.com/NVIDIA/dsx-exchange/blob/HEAD/mcp/dsx-exchange-mcp/vendor/github.com/modelcontextprotocol/go-sdk/LICENSE,Apache-2.0 github.com/munnerz/goautoneg,https://github.com/NVIDIA/dsx-exchange/blob/HEAD/auth-callout/vendor/github.com/munnerz/goautoneg/LICENSE,BSD-3-Clause github.com/nats-io/jwt/v2,https://github.com/NVIDIA/dsx-exchange/blob/HEAD/auth-callout/vendor/github.com/nats-io/jwt/v2/LICENSE,Apache-2.0 github.com/nats-io/nats-server/v2,https://github.com/NVIDIA/dsx-exchange/blob/HEAD/auth-callout/vendor/github.com/nats-io/nats-server/v2/LICENSE,Apache-2.0 @@ -47,6 +49,8 @@ github.com/prometheus/common,https://github.com/NVIDIA/dsx-exchange/blob/HEAD/au github.com/prometheus/otlptranslator,https://github.com/NVIDIA/dsx-exchange/blob/HEAD/auth-callout/vendor/github.com/prometheus/otlptranslator/LICENSE,Apache-2.0 github.com/prometheus/procfs,https://github.com/NVIDIA/dsx-exchange/blob/HEAD/auth-callout/vendor/github.com/prometheus/procfs/LICENSE,Apache-2.0 github.com/santhosh-tekuri/jsonschema/v6,https://github.com/santhosh-tekuri/jsonschema/blob/v6.0.2/LICENSE,Apache-2.0 +github.com/segmentio/asm,https://github.com/NVIDIA/dsx-exchange/blob/HEAD/mcp/dsx-exchange-mcp/vendor/github.com/segmentio/asm/LICENSE,MIT +github.com/segmentio/encoding,https://github.com/NVIDIA/dsx-exchange/blob/HEAD/mcp/dsx-exchange-mcp/vendor/github.com/segmentio/encoding/LICENSE,MIT github.com/spf13/cobra,https://github.com/NVIDIA/dsx-exchange/blob/HEAD/auth-callout/vendor/github.com/spf13/cobra/LICENSE.txt,Apache-2.0 github.com/spf13/pflag,https://github.com/NVIDIA/dsx-exchange/blob/HEAD/auth-callout/vendor/github.com/spf13/pflag/LICENSE,BSD-3-Clause github.com/synadia-io/callout.go,https://github.com/NVIDIA/dsx-exchange/blob/HEAD/auth-callout/vendor/github.com/synadia-io/callout.go/LICENSE,Apache-2.0 @@ -54,6 +58,7 @@ github.com/uptrace/opentelemetry-go-extra/otelutil,https://github.com/NVIDIA/dsx github.com/uptrace/opentelemetry-go-extra/otelzap,https://github.com/NVIDIA/dsx-exchange/blob/HEAD/auth-callout/vendor/github.com/uptrace/opentelemetry-go-extra/otelzap/LICENSE,BSD-2-Clause github.com/valyala/fastrand,https://github.com/valyala/fastrand/blob/v1.1.0/LICENSE,MIT github.com/valyala/histogram,https://github.com/valyala/histogram/blob/v1.2.0/LICENSE,MIT +github.com/yosida95/uritemplate/v3,https://github.com/NVIDIA/dsx-exchange/blob/HEAD/mcp/dsx-exchange-mcp/vendor/github.com/yosida95/uritemplate/v3/LICENSE,BSD-3-Clause gitlab-master.nvidia.com/ncp/vmaas/services/pkg/nv-config,Unknown,Apache-2.0 gitlab-master.nvidia.com/ncp/vmaas/services/pkg/nv-config/internal/providers,Unknown,Apache-2.0 gitlab-master.nvidia.com/ncp/vmaas/services/pkg/nv-config/internal/watcher,Unknown,Apache-2.0 @@ -88,6 +93,7 @@ golang.org/x/net,https://github.com/NVIDIA/dsx-exchange/blob/HEAD/auth-callout/v golang.org/x/oauth2,https://cs.opensource.google/go/x/oauth2/+/v0.33.0:LICENSE,BSD-3-Clause golang.org/x/sync/semaphore,https://cs.opensource.google/go/x/sync/+/v0.17.0:LICENSE,BSD-3-Clause golang.org/x/sys,https://github.com/NVIDIA/dsx-exchange/blob/HEAD/auth-callout/vendor/golang.org/x/sys/LICENSE,BSD-3-Clause +golang.org/x/sys/cpu,https://github.com/NVIDIA/dsx-exchange/blob/HEAD/mcp/dsx-exchange-mcp/vendor/golang.org/x/sys/LICENSE,BSD-3-Clause golang.org/x/sys/unix,https://cs.opensource.google/go/x/sys/+/v0.37.0:LICENSE,BSD-3-Clause golang.org/x/text,https://github.com/NVIDIA/dsx-exchange/blob/HEAD/auth-callout/vendor/golang.org/x/text/LICENSE,BSD-3-Clause golang.org/x/time/rate,https://github.com/NVIDIA/dsx-exchange/blob/HEAD/auth-callout/vendor/golang.org/x/time/LICENSE,BSD-3-Clause diff --git a/auth-callout/scripts/regenerate-third-party-licenses.sh b/auth-callout/scripts/regenerate-third-party-licenses.sh index d47640e..fc6df6f 100755 --- a/auth-callout/scripts/regenerate-third-party-licenses.sh +++ b/auth-callout/scripts/regenerate-third-party-licenses.sh @@ -91,6 +91,7 @@ report_module() { report_module "$auth_dir" "-mod=vendor" report_module "$repo_dir/local/mqtt-client" "" report_module "$repo_dir/local/mqttbs" "" +report_module "$repo_dir/mcp/dsx-exchange-mcp" "-mod=vendor" if [[ -n "${DSX_LICENSE_VERBOSE:-}" && -s "$warnings" ]]; then cat "$warnings" >&2 diff --git a/mcp/dsx-exchange-mcp/.gitignore b/mcp/dsx-exchange-mcp/.gitignore new file mode 100644 index 0000000..db13740 --- /dev/null +++ b/mcp/dsx-exchange-mcp/.gitignore @@ -0,0 +1,11 @@ +/bin/ +*.test +*.out +.env +.idea/ +.vscode/ +.claude/ +context/ +/schema/ +/internal/specs/data/* +!/internal/specs/data/.gitkeep diff --git a/mcp/dsx-exchange-mcp/Architecture.md b/mcp/dsx-exchange-mcp/Architecture.md new file mode 100644 index 0000000..c5ef96c --- /dev/null +++ b/mcp/dsx-exchange-mcp/Architecture.md @@ -0,0 +1,662 @@ +# dsx-exchange-mcp Architecture + +This document is for a new developer trying to understand how the code works. It is intentionally code-centric: which files own which behavior, how a request flows through the service, and how this MCP server plugs into Agent Gateway / Latinum MCP Gateway. + +## Big Picture + +`dsx-exchange-mcp` is an MCP server that exposes DSX exchange data over MCP. + +At runtime it does three main things: + +1. Serves MCP over HTTP at `/mcp`. +2. Exposes embedded exchange specs as MCP resources. +3. Exposes schema exploration and bounded MQTT/NATS reads as MCP tools. + +In production it is expected to sit behind Gateway: + +```text +MCP client + -> Latinum / Agent Gateway + -> Kubernetes Service: dsx-exchange-mcp + -> dsx-exchange-mcp pod + -> MQTT/NATS broker +``` + +The MCP server does not implement topic authorization itself. The caller JWT is passed through Gateway, forwarded to this service, then used as the MQTT password when connecting to NATS/MQTT. NATS auth callout / ACLs enforce topic access. + +## Request Flow + +For an MCP tool call such as `dsx_exchange_subscribe`: + +```text +client + sends MCP request with JWT + | + v +gateway + validates identity + forwards Authorization: Bearer + forwards x-mcp-* identity headers + | + v +cmd/dsx-exchange-mcp/main.go + accepts HTTP /mcp + wraps handler with auth.Middleware + | + v +internal/auth/context.go + extracts bearer + identity headers into request context + | + v +internal/server/tools.go + validates MCP tool args + applies max message / duration limits + calls mqttbus.Collect(...) + | + v +internal/mqttbus/client.go + creates MQTT client + uses bearer token as MQTT password + subscribes to topic filter + collects bounded messages + | + v +internal/server/tools.go + records metrics + writes audit log + returns MCP result +``` + +For an MCP resource read, the flow stops inside `internal/specs`; no MQTT connection is opened. + +## File Map + +| Path | Responsibility | +| --- | --- | +| `cmd/dsx-exchange-mcp/main.go` | Process entrypoint. Reads env config, builds the MCP server, registers HTTP routes, starts `ListenAndServe`. | +| `internal/server/server.go` | Creates the MCP server instance and registers tools/resources. | +| `internal/server/tools.go` | Defines MCP tools, parses tool inputs, describes schema topics, enforces bounds, calls MQTT collection, emits audit logs and metrics. | +| `internal/server/resources.go` | Defines MCP resources backed by embedded DSX specs. | +| `internal/specs/specs.go` | Exposes raw spec resources from the embedded `schemas/` tree. | +| `internal/schemaindex/index.go` | Parses AsyncAPI channel/message/operation primitives into a topic catalogue for schema exploration tools. | +| `schemas/` | Generated copy of the monorepo root `schemas/`, embedded into the binary by `schemas/embed.go`. | +| `internal/mqttbus/client.go` | MQTT/NATS client logic: connect, subscribe, collect messages, classify broker errors. | +| `internal/auth/context.go` | Pulls Gateway-provided bearer and identity headers into Go context. | +| `internal/metrics/metrics.go` | In-process Prometheus text metrics endpoint. | +| `deploy/helm/dsx-exchange-mcp/templates/deployment.yaml` | Kubernetes Deployment: env vars, probes, security context, runtime class. | +| `deploy/helm/dsx-exchange-mcp/templates/service.yaml` | Kubernetes Service that Gateway discovers/routes to. | +| `deploy/helm/dsx-exchange-mcp/values.yaml` | Default deploy-time configuration. | + +## Process Startup + +The binary starts in `cmd/dsx-exchange-mcp/main.go`. + +```go +addr := envOr("MCP_ADDR", ":8080") +natsURL := envOr("NATS_URL", "tcp://nats:1883") +recorder := metrics.NewRecorder() +``` + +The entrypoint builds one `server.Config` from environment variables: + +```go +cfg := server.Config{ + MQTT: mqttbus.Config{ + BrokerURL: natsURL, + Username: envOr("MQTT_USERNAME", mqttbus.DefaultUsername), + }, + Metrics: recorder, + DefaultMaxMessages: intEnvOr("MCP_DEFAULT_MAX_MESSAGES", 100), + MaxMessages: intEnvOr("MCP_MAX_MESSAGES", 1000), + DefaultDurationS: intEnvOr("MCP_DEFAULT_MAX_DURATION_S", 30), + MaxDurationS: intEnvOr("MCP_MAX_DURATION_S", 30), +} +``` + +Then it creates the MCP server and attaches it to HTTP: + +```go +srv := server.Build(cfg) +handler := mcp.NewStreamableHTTPHandler(func(*http.Request) *mcp.Server { + return srv +}, nil) + +mux.Handle("/mcp", auth.Middleware(handler)) +mux.HandleFunc("/healthz/live", healthOK) +mux.HandleFunc("/healthz/ready", healthOK) +mux.Handle("/metrics", recorder.Handler()) +``` + +Important detail: this service uses MCP Streamable HTTP, but the current tools are bounded request/response calls. It does not currently maintain long-lived background subscriptions for clients. + +## MCP Server Construction + +`internal/server/server.go` owns MCP server creation. + +```go +srv := mcp.NewServer(&mcp.Implementation{ + Name: "dsx-exchange-mcp", + Version: "0.1.0", +}, nil) + +registerTools(srv, cfg) +registerResources(srv) +``` + +The same `*mcp.Server` is returned for each HTTP request: + +```go +// Build returns a singleton MCP server. The Streamable HTTP handler uses the +// same server for every request; per-request caller bearer tokens flow through +// context injected by auth.Middleware. +``` + +That means per-caller information must not be stored globally on the server object. Caller-specific data flows through `context.Context`. + +## Auth And JWT Passthrough + +`internal/auth/context.go` extracts identity from the incoming HTTP request. + +Gateway is expected to forward: + +| Header | Used for | +| --- | --- | +| `Authorization: Bearer ` | Delegated credential used as MQTT password. | +| `x-mcp-tenant` | Audit label. | +| `x-mcp-issuer` | Audit label. | +| `x-mcp-sub` | Audit label. | +| `x-mcp-spiffe-id` | Audit label. | + +The middleware: + +```go +caller := Caller{ + Bearer: bearerFromHeader(r.Header.Get("Authorization")), + Tenant: r.Header.Get("x-mcp-tenant"), + Issuer: r.Header.Get("x-mcp-issuer"), + Subject: r.Header.Get("x-mcp-sub"), + SpiffeID: r.Header.Get("x-mcp-spiffe-id"), +} +r = r.WithContext(context.WithValue(r.Context(), ctxKey{}, caller)) +``` + +The code comment describes the intended trust boundary: + +```go +// The raw bearer is used only as the delegated credential for the MQTT/NATS +// password. The x-mcp-* fields are audit labels emitted by gateway ext_authz. +``` + +So this service is not the main identity policy engine. It preserves identity for audit and delegates topic authorization to the broker by connecting with the caller token. + +## MCP Tools + +Tool registration lives in `internal/server/tools.go`. + +Current tools: + +| Tool | Purpose | +| --- | --- | +| `dsx_exchange_describe_topic` | Describe the AsyncAPI channel matching a topic filter, including payload shape, retained/live behavior, examples, and related metadata/value topics. | +| `dsx_exchange_subscribe` | Subscribe to a topic filter and collect a bounded batch of live messages. | +| `dsx_exchange_read_retained` | Subscribe briefly and return retained messages for a topic filter. | + +The subscribe tool is registered like this: + +```go +mcp.AddTool(srv, &mcp.Tool{ + Name: toolSubscribe, + Description: "Subscribe to DSX Exchange MQTT topics and return a bounded batch of messages.", +}, func(ctx context.Context, req *mcp.CallToolRequest, args subscribeArgs) (*mcp.CallToolResult, any, error) { + return collectTool(ctx, cfg, toolSubscribe, args.TopicFilter, args.MaxMessages, args.MaxDurationS, false) +}) +``` + +The two MQTT data tools eventually call `collectTool`, which: + +1. Reads caller identity from context. +2. Applies max message and max duration defaults. +3. Calls MQTT collection. +4. Converts the result into MCP content. +5. Emits metrics and audit logs. + +The MQTT call is direct: + +```go +res, err := mqttbus.Collect(ctx, cfg.MQTT, caller.Bearer, topicFilter, mqttbus.CollectOptions{ + MaxMessages: maxMessages, + MaxDuration: time.Duration(maxDurationS) * time.Second, + RetainedOnly: retainedOnly, +}) +``` + +If a tool fails, the service returns a structured MCP error result rather than a raw Go error: + +```go +return &mcp.CallToolResult{ + Content: []mcp.Content{&mcp.TextContent{Text: string(payload)}}, + IsError: true, +}, nil, nil +``` + +This matters for clients: the MCP transport request may succeed while the tool result itself is an error. + +## MCP Resources + +Resource registration lives in `internal/server/resources.go`. + +There is an index resource: + +```go +mcp.AddResource(srv, &mcp.Resource{ + URI: "dsx-exchange://specs/", + Name: "DSX Exchange spec index", + MIMEType: "application/json", + Description: "Index of embedded DSX Exchange topic specifications.", +}, readIndex) +``` + +And one resource per embedded domain: + +```go +uri := "dsx-exchange://specs/" + domain +mcp.AddResource(srv, &mcp.Resource{ + URI: uri, + Name: "DSX Exchange " + domain + " spec", + MIMEType: mimeTypeForSpec(domain), +}, readSpec(domain, uri)) +``` + +The embedded specs come from the repository-root `schemas/` package: + +```go +//go:embed README.md cloud-events-example.yaml asyncapi/*/*.yaml +var FS embed.FS +``` + +`make sync-specs` refreshes those files from the monorepo root `schemas/`: + +```make +sync-specs: + rm -rf schemas/asyncapi schemas/cloud-events-example.yaml schemas/README.md + mkdir -p schemas + cp -R $(SCHEMA_SRC)/. schemas/ +``` + +Resource calls are therefore local file reads from embedded data. They do not call NATS/MQTT. + +`dsx_exchange_describe_topic` also does not call NATS/MQTT. It reads the embedded AsyncAPI catalogue through `internal/schemaindex`. + +## MQTT/NATS Client Behavior + +The MQTT implementation is in `internal/mqttbus/client.go`. + +The default username is: + +```go +const DefaultUsername = "oauthtoken" +``` + +`Collect` requires a bearer token: + +```go +if strings.TrimSpace(bearer) == "" { + return CollectResult{}, &BusError{Code: ErrMissingBearer, Message: "missing caller bearer token"} +} +``` + +The MQTT client uses the caller bearer as the password: + +```go +opts := mqtt.NewClientOptions(). + AddBroker(cfg.BrokerURL). + SetClientID(fmt.Sprintf("dsx-exchange-mcp-%d", time.Now().UnixNano())). + SetUsername(username). + SetPassword(bearer). + SetCleanSession(true). + SetAutoReconnect(false) +``` + +Then it subscribes using the requested topic filter: + +```go +token := c.Subscribe(topicFilter, 0, nil) +``` + +The collection loop stops for bounded reasons: + +| Stop reason | Meaning | +| --- | --- | +| `max_messages` | Hit requested or configured message count. | +| `max_duration` | Hit requested or configured duration. | +| `retained_idle` | Retained-read mode saw no more retained messages for the idle window. | +| `max_result_bytes` | Payload would exceed configured response size. | +| `client_cancelled` | Request context was cancelled. | +| `completed` | Normal completion path. | + +Payload conversion is also handled here. UTF-8 payloads are returned as strings; non-UTF-8 payloads are base64 encoded: + +```go +if utf8.Valid(payload) { + msg.Payload = string(payload) + msg.PayloadEncoding = "utf-8" +} else { + msg.Payload = base64.StdEncoding.EncodeToString(payload) + msg.PayloadEncoding = "base64" +} +``` + +### Current Streaming Boundary + +`internal/mqttbus/client.go` also has a lower-level `Stream` function: + +```go +// Stream opens an MQTT subscription and invokes onMessage for every received +// message until the context is cancelled or a bound is reached. It is intended +// for async task workers that need to persist messages outside this package. +``` + +That is scaffolding for a future async/background watch design. It is not currently registered as an MCP tool. The current MQTT data tools collect bounded batches inside the request lifecycle. + +## Gateway Integration + +In production, MCP clients should normally talk to Gateway, not directly to the pod. + +The intended Gateway-facing shape is: + +```text +MCP client + -> Gateway /mcp + -> upstream route for dsx-exchange-mcp + -> Kubernetes Service dsx-exchange-mcp: + -> pod /mcp +``` + +This repo's Helm Service advertises the MCP port: + +```yaml +ports: + - name: {{ .Values.service.portName }} + port: {{ .Values.service.port }} + targetPort: http + protocol: TCP + appProtocol: agentgateway.dev/mcp +``` + +The important field is: + +```yaml +appProtocol: agentgateway.dev/mcp +``` + +That tells Gateway discovery that this service port speaks MCP. + +A Gateway upstream entry is expected to target this service by service name, namespace, labels, port, and pod selector. The README shows the shape: + +```yaml +upstreams: + - serviceName: dsx-exchange-mcp + portName: mcp + namespace: mcp-backends + serviceLabels: + app: dsx-exchange-mcp + port: 8080 + podSelector: + app: dsx-exchange-mcp +``` + +In multi-upstream Gateway deployments, tool names may be exposed with an upstream prefix. For example, the local tool `dsx_exchange_subscribe` may appear to an external client as something like: + +```text +dsx-exchange-mcp-mcp_dsx_exchange_subscribe +``` + +The exact external name depends on Gateway's upstream naming behavior. + +### JWT Passthrough Contract + +The service expects Gateway to forward the caller token: + +```text +Authorization: Bearer +``` + +The service does not exchange this token. It passes it to MQTT/NATS as the password: + +```text +Gateway-validated JWT + -> Authorization header to dsx-exchange-mcp + -> auth.Middleware stores bearer in context + -> mqttbus.Collect receives caller.Bearer + -> Paho MQTT SetPassword(bearer) + -> NATS auth callout / ACL policy +``` + +This gives a clean responsibility split: + +| Component | Responsibility | +| --- | --- | +| Gateway | Validate incoming identity, route MCP traffic, forward delegated identity. | +| `dsx-exchange-mcp` | Translate MCP resources/tools to local embedded specs and MQTT reads. | +| NATS/MQTT broker | Authenticate delegated token and enforce topic ACLs. | + +## Kubernetes Deployment + +The Helm chart under `deploy/helm/dsx-exchange-mcp` owns production deployment shape. + +Default values include two replicas and the NATS/MQTT endpoint: + +```yaml +replicaCount: 2 + +natsURL: tcp://nats.nats.svc:1883 + +mqtt: + username: oauthtoken + connectTimeoutSeconds: 5 + subscribeTimeoutSeconds: 5 + maxResultBytes: 1048576 +``` + +The Deployment maps those values into environment variables: + +```yaml +- name: MCP_ADDR + value: ":8080" +- name: NATS_URL + value: {{ .Values.natsURL | quote }} +- name: MQTT_USERNAME + value: {{ .Values.mqtt.username | quote }} +- name: MCP_MAX_MESSAGES + value: {{ .Values.limits.maxMessages | quote }} +- name: MCP_MAX_DURATION_S + value: {{ .Values.limits.maxDurationSeconds | quote }} +``` + +The chart also configures health probes: + +```yaml +livenessProbe: + httpGet: + path: /healthz/live + port: http +readinessProbe: + httpGet: + path: /healthz/ready + port: http +``` + +And a locked-down runtime profile: + +```yaml +securityContext: + runAsNonRoot: true + readOnlyRootFilesystem: true + capabilities: + drop: + - ALL +``` + +The default `values.yaml` also sets: + +```yaml +runtimeClassName: kata +``` + +That means pods are intended to run with the configured Kata runtime class in the target cluster. + +## Observability + +There are three observability paths in the current code. + +### Health + +`cmd/dsx-exchange-mcp/main.go` exposes: + +```text +/healthz/live +/healthz/ready +``` + +Both currently return HTTP 200 with body `ok`. + +### Metrics + +`internal/metrics/metrics.go` implements a lightweight Prometheus text endpoint mounted at: + +```text +/metrics +``` + +Current metrics include: + +```text +dsx_exchange_mcp_active_tool_calls +dsx_exchange_mcp_tool_calls_total{tool} +dsx_exchange_mcp_tool_errors_total{tool,code} +dsx_exchange_mcp_tool_duration_seconds_sum{tool} +dsx_exchange_mcp_mqtt_messages_collected_total{tool} +dsx_exchange_mcp_stopped_reasons_total{tool,reason} +``` + +The Helm chart enables scrape annotations by default: + +```yaml +metrics: + scrape: true + path: /metrics + port: http +``` + +### Audit Logs + +Every tool call emits an audit log from `internal/server/tools.go`: + +```go +slog.Info("mcp tool call", + "audit", true, + "tool", tool, + "caller_tenant", caller.Tenant, + "caller_issuer", caller.Issuer, + "caller_subject", caller.Subject, + "caller_spiffe_id", caller.SpiffeID, + "bearer_present", caller.Bearer != "", + "topic_filter", topicFilter, + "decision", decision, + "message_count", messageCount, + "stopped_reason", stoppedReason, + "error_code", errorCode, +) +``` + +These logs are where you correlate Gateway identity, requested topic filter, broker decision, result size, and error code. + +## Local Development + +Common Make targets: + +```make +build: + go build ./cmd/dsx-exchange-mcp + +run: sync-specs build + go run ./cmd/dsx-exchange-mcp + +test: + go test ./... +``` + + +## What To Change For Common Tasks + +### Add a new MCP tool + +Start in: + +```text +internal/server/tools.go +``` + +Add the tool registration next to the existing `mcp.AddTool` calls. If the tool touches MQTT, prefer adding focused behavior in `internal/mqttbus` rather than embedding client logic in the server layer. + +### Change topic validation or MQTT error handling + +Start in: + +```text +internal/mqttbus/client.go +``` + +This file owns topic filter validation, connection setup, subscribe behavior, message conversion, and broker error classification. + +### Add or change embedded specs + +Start with: + +```text +make sync-specs +``` + +Then inspect: + +```text +schemas/ +internal/specs/specs.go +internal/server/resources.go +internal/schemaindex/index.go +``` + +### Change Gateway-facing deployment metadata + +Start in: + +```text +deploy/helm/dsx-exchange-mcp/templates/service.yaml +deploy/helm/dsx-exchange-mcp/values.yaml +``` + +Gateway discovery depends on the Service name, labels, port name, and `appProtocol`. + +### Change runtime limits + +Start in: + +```text +deploy/helm/dsx-exchange-mcp/values.yaml +cmd/dsx-exchange-mcp/main.go +internal/server/tools.go +``` + +The chart sets deploy defaults, `main.go` reads env vars, and `tools.go` applies bounds per request. + +## Current Design Boundaries + +The current implementation is intentionally thin: + +1. It does not store durable watch state. +2. It does not maintain cross-pod subscription continuity. +3. It does not reimplement broker authorization. +4. It does not expose a long-lived async subscription API yet. +5. It does not persist MQTT messages outside the request. + +That means a pod restart can interrupt an in-flight bounded tool call. Clients should retry tool calls. If future UX requires long-lived background watches, the likely next code boundary is to build around `mqttbus.Stream` with an explicit task/watch model and a bounded external or broker-backed message store. diff --git a/mcp/dsx-exchange-mcp/Dockerfile b/mcp/dsx-exchange-mcp/Dockerfile new file mode 100644 index 0000000..91ff44f --- /dev/null +++ b/mcp/dsx-exchange-mcp/Dockerfile @@ -0,0 +1,39 @@ +# Copyright 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +ARG BUILDER_IMG=golang +ARG BUILDER_TAG=1.25.5 +ARG FINAL_IMG=gcr.io/distroless/static-debian12 +ARG FINAL_TAG=nonroot +ARG SERVICE_PORT=8080 +ARG LABEL_CREATED=unknown +ARG LABEL_REVISION=unknown +ARG LABEL_VERSION=dev + +FROM ${BUILDER_IMG}:${BUILDER_TAG} AS build +WORKDIR /src +COPY . . +RUN --mount=type=cache,target=/go/pkg/mod \ + --mount=type=cache,target=/root/.cache/go-build \ + CGO_ENABLED=0 GOOS=linux go build -trimpath -ldflags="-s -w" -mod=vendor -o /out/dsx-exchange-mcp ./cmd/dsx-exchange-mcp + +FROM ${FINAL_IMG}:${FINAL_TAG} + +ARG LABEL_VERSION +ARG LABEL_CREATED +ARG LABEL_REVISION + +LABEL org.opencontainers.image.created="${LABEL_CREATED}" \ + org.opencontainers.image.description="DSX Exchange MCP Server" \ + org.opencontainers.image.documentation="https://github.com/NVIDIA/dsx-exchange/tree/main/mcp/dsx-exchange-mcp" \ + org.opencontainers.image.licenses="Apache-2.0" \ + org.opencontainers.image.revision="${LABEL_REVISION}" \ + org.opencontainers.image.source="https://github.com/NVIDIA/dsx-exchange/tree/main/mcp/dsx-exchange-mcp" \ + org.opencontainers.image.title="dsx-exchange-mcp" \ + org.opencontainers.image.url="https://github.com/NVIDIA/dsx-exchange/tree/main/mcp/dsx-exchange-mcp" \ + org.opencontainers.image.vendor="NVIDIA" \ + org.opencontainers.image.version="${LABEL_VERSION}" +COPY --from=build /out/dsx-exchange-mcp /dsx-exchange-mcp +EXPOSE ${SERVICE_PORT} +USER nonroot:nonroot +ENTRYPOINT ["/dsx-exchange-mcp"] diff --git a/mcp/dsx-exchange-mcp/Makefile b/mcp/dsx-exchange-mcp/Makefile new file mode 100644 index 0000000..024d60f --- /dev/null +++ b/mcp/dsx-exchange-mcp/Makefile @@ -0,0 +1,42 @@ +# Copyright 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +BINARY := dsx-exchange-mcp +PKG := github.com/NVIDIA/dsx-exchange/mcp/dsx-exchange-mcp +SCHEMA_SRC ?= ../../schemas +GOFLAGS ?= -mod=vendor + +.PHONY: build run test tidy vendor lint sync-specs verify-specs image clean + +build: + go build $(GOFLAGS) -o bin/$(BINARY) ./cmd/$(BINARY) + +run: sync-specs build + ./bin/$(BINARY) + +test: + go test $(GOFLAGS) ./... + +tidy: + go mod tidy + +vendor: tidy + go mod vendor + +lint: + go vet $(GOFLAGS) $$(go list $(GOFLAGS) ./...) + +sync-specs: + @test -d $(SCHEMA_SRC) || (echo "schema tree not at $(SCHEMA_SRC); set SCHEMA_SRC"; exit 1) + rm -rf schemas/asyncapi schemas/cloud-events-example.yaml schemas/README.md + mkdir -p schemas + cp -R $(SCHEMA_SRC)/. schemas/ + +verify-specs: sync-specs + git diff --exit-code -- schemas + +image: + docker build -t $(BINARY):dev . + +clean: + rm -rf bin diff --git a/mcp/dsx-exchange-mcp/README.md b/mcp/dsx-exchange-mcp/README.md new file mode 100644 index 0000000..3232d20 --- /dev/null +++ b/mcp/dsx-exchange-mcp/README.md @@ -0,0 +1,200 @@ +# dsx-exchange-mcp + +MCP server that exposes the DSX Exchange AsyncAPI specs as Resources and a +read-only NATS-MQTT bridge as Tools. One server for all DSX Exchange domains. + +Runs as one of the upstream MCP servers behind the Latinum MCP Gateway +(agentgateway). + +## What it exposes + +**Resources** — AsyncAPI 3.1.0 specs, embedded at build time: +- `dsx-exchange://specs/` — index of available domains +- `dsx-exchange://specs/{domain}` — raw YAML for one domain + (e.g. `bms`, `nico`, `power-management`, `spiffe-exchange`) + +**Tools** — read-only MQTT against the DSX Event Bus: +- `dsx_exchange_describe_topic(topic_filter)` — parse embedded AsyncAPI specs + and describe the matching schema channel, payload shape, retained/live + behavior, examples, and related metadata/value topics. +- `dsx_exchange_subscribe(topic_filter, max_messages, max_duration_s)` — + subscribe and collect messages over a window. Use this for live values. +- `dsx_exchange_read_retained(topic_filter, max_messages)` — drain retained + messages currently held by the broker. Use this for metadata; BMS values are + not retained (republished on change every ~100 s). + +Topic filters use standard MQTT wildcards: `+` (single level), `#` (multi-level, +end of filter only). + +## Auth + +The server holds **no credentials of its own**. The caller's JWT flows through +end-to-end: + +1. The Latinum MCP Gateway validates the JWT via `ext_authz` and forwards + `Authorization: Bearer ` unchanged (SDD §2.4). +2. This server validates request shape and safety limits, but does not + duplicate DSX Exchange broker authZ policy in v1. +3. For tool calls, the same bearer is presented to NATS as the MQTT CONNECT + password (`username=oauthtoken`, `password=`). The NATS auth-callout + service validates it and enforces topic ACLs keyed on the OAuth2 identity. + +The DSX Exchange broker/auth-callout is the source of truth for token validity +and topic ACLs; this server maps broker failures into structured MCP errors. + +## Layout + +``` +cmd/dsx-exchange-mcp main, env wiring, HTTP listener +internal/auth bearer extraction + gateway identity context +internal/server MCP server, resource & tool registration +internal/specs raw AsyncAPI resources from embedded schemas +internal/schemaindex parsed AsyncAPI topic catalogue for schema tools +internal/mqttbus paho v3 client wrapper (OAuth2 password + TLS) +deploy/helm chart (kata runtime, readonly rootfs, drop ALL caps) +schemas/ generated embedded copy of monorepo root schemas/ +``` + +## Build & run + +```sh +make sync-specs # copies ../../schemas/ into ./schemas +make build +make run # listens on :8080, expects NATS at tcp://nats:1883 +``` + +Environment: + +| Var | Default | Notes | +| --- | --- | --- | +| `MCP_ADDR` | `:8080` | listener for `/mcp` (Streamable HTTP) | +| `NATS_URL` | `tcp://nats:1883` | MQTT 3.1.1 facade on the NATS broker | +| `MQTT_USERNAME` | `oauthtoken` | MQTT username for OAuth2 bearer auth | +| `MQTT_TLS_CA_FILE` | (unset) | optional root CA bundle for private broker CA | +| `MQTT_TLS_SERVER_NAME` | (unset) | optional TLS server name override | +| `MQTT_TLS_INSECURE_SKIP_VERIFY` | `false` | local-dev only; rejected by Helm unless acknowledged | +| `MCP_DEFAULT_MAX_MESSAGES` | `100` | default message cap per tool call | +| `MCP_MAX_MESSAGES` | `1000` | hard message cap per tool call | +| `MCP_DEFAULT_MAX_DURATION_S` | `30` | default subscribe window | +| `MCP_MAX_DURATION_S` | `30` | hard subscribe window cap | +| `MQTT_MAX_RESULT_BYTES` | `1048576` | max returned topic+payload bytes | +| `LOG_FORMAT` | `json` | structured logs | +| `OTEL_EXPORTER_OTLP_ENDPOINT` | (unset) | reserved for future OTLP push export; scrape `/metrics` today | + +Health and metrics endpoints are served on the same listener: + +- `/healthz/live` +- `/healthz/ready` +- `/metrics` — Prometheus-compatible process/tool metrics + +TLS trust is deployment configuration, not MCP tool input. For deployed-bus +tests or production, mount the broker root CA and set `MQTT_TLS_CA_FILE`; agents +only provide bearer credentials and tool arguments. The caller bearer is passed +to MQTT as `password=` with the configured `MQTT_USERNAME`; broker +OAuth and topic ACL enforcement remain broker-side. + +The public schema tree is copied from the monorepo root `schemas/` directory. Override the location with `SCHEMA_SRC=/path/to/schemas make sync-specs`. + +## Specs are pinned at build time + +`make sync-specs` copies the monorepo schema tree from `../../schemas` into `schemas/`, and `schemas/embed.go` +bakes it into the binary. The image is hermetic — no runtime fetch from GitLab. +Empty domain stubs are filtered out at startup so they don't surface as MCP +resources or schema tool matches. + +To update specs, re-run `sync-specs` against a refreshed schema checkout and +cut a new image. + +## Deploy + +Helm chart at `deploy/helm/dsx-exchange-mcp/`. Defaults match the DSX security +posture from the gateway SDD: `runtimeClassName: kata`, non-root, read-only +root filesystem, `drop: ["ALL"]`, two replicas, preferred pod anti-affinity, +and a PodDisruptionBudget. The Service exposes a single ClusterIP on the MCP +port with `appProtocol: agentgateway.dev/mcp`; the gateway's +`AgentgatewayBackend` points at it. Local kind deployments should override +`runtimeClassName: ""`. + +Example gateway upstream entry: + +```yaml +upstreams: + - serviceName: dsx-exchange-mcp + portName: mcp + namespace: mcp-backends + serviceLabels: + app: dsx-exchange-mcp + port: 8080 + podSelector: + app: dsx-exchange-mcp +``` + +The derived gateway target name is `dsx-exchange-mcp-mcp`, so tools are +prefixed as `dsx-exchange-mcp-mcp_dsx_exchange_subscribe` in multi-upstream +gateway deployments. + +## E2E against deployed bus + +Do not deploy a local NATS/event bus for this path. Gate deployed-bus tests +behind explicit environment variables and point to the shared dev bus: + +Stage 1 tests the MQTT bridge directly: + +```sh +RUN_EXCHANGE_E2E_DEPLOYED_BUS=1 \ +DSX_EXCHANGE_MQTT_URL=tls://event-bus-ytl-dev2.dev.dsx.nvidia.com:1883 \ +DSX_EXCHANGE_MQTT_USERNAME=oauth \ +DSX_EXCHANGE_MQTT_CA_FILE=/path/to/root-ca.crt \ +DSX_EXCHANGE_MQTT_SERVER_NAME=event-bus-ytl-dev2.dev.dsx.nvidia.com \ +DSX_EXCHANGE_E2E_BEARER="$TOKEN" \ +DSX_EXCHANGE_E2E_ALLOWED_TOPIC='...' \ +DSX_EXCHANGE_E2E_DENIED_TOPIC='...' \ +go test ./... +``` + +Stage 2 tests the MCP protocol path through either this server directly or the +gateway. Run this after the server is configured with the same deployed-bus +`NATS_URL`/TLS settings: + +```sh +RUN_EXCHANGE_MCP_E2E=1 \ +DSX_EXCHANGE_MCP_URL=http://localhost:8080/mcp \ +DSX_EXCHANGE_E2E_BEARER="$TOKEN" \ +DSX_EXCHANGE_E2E_ALLOWED_TOPIC='...' \ +DSX_EXCHANGE_E2E_DENIED_TOPIC='...' \ +go test ./internal/server -run TestStagedMCPE2EDeployedBus -count=1 -v +``` + +When running through the gateway, set `DSX_EXCHANGE_MCP_URL` to the gateway +`/mcp` endpoint. If the gateway prefixes tools, either let the test discover the +`*_dsx_exchange_subscribe` tool or set `DSX_EXCHANGE_E2E_TOOL_NAME` explicitly. + +Never commit bearer tokens, CA material, or topic names that are environment +specific or sensitive. + +## Local LLM prompt eval + +`TestLocalLLMMCPPromptEval` is an opt-in local harness that runs fixture prompts +through an OpenAI-compatible local LLM endpoint, executes emitted MCP tool calls, +logs the tool trace, and compares the model's final tool plan with +`internal/server/testdata/tool_call_expectations.json`. + +For the gateway path, set `DSX_EXCHANGE_MCP_URL` to the Latinum MCP Gateway +`/mcp` endpoint, for example `http://localhost:18180/mcp`. If it is unset, the +test starts an in-process MCP server. + +See `docs/local-llm-mcp-eval.md`. + +## Status + +Alpha. Populated specs load and surface as resources when synced into the +embedded bundle. The MQTT tools use paho v3 and pass OAuth2 bearer credentials +to the broker as `username=`, `password=`. Broker-side +auth-callout remains the source of truth for topic ACLs. + +## References + +- Latinum MCP Gateway SDD — `context/Latinum MCP Gateway - SDD (1).pdf` +- Schema repo — `gitlab-master.nvidia.com/ncp/dsx/event-bus/schema` +- MCP spec — https://modelcontextprotocol.io/specification/2025-06-18/ +- Go SDK — https://github.com/modelcontextprotocol/go-sdk diff --git a/mcp/dsx-exchange-mcp/cmd/dsx-exchange-mcp/main.go b/mcp/dsx-exchange-mcp/cmd/dsx-exchange-mcp/main.go new file mode 100644 index 0000000..6c0d10c --- /dev/null +++ b/mcp/dsx-exchange-mcp/cmd/dsx-exchange-mcp/main.go @@ -0,0 +1,120 @@ +// Copyright 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +package main + +import ( + "log/slog" + "net/http" + "os" + "strconv" + "time" + + "github.com/modelcontextprotocol/go-sdk/mcp" + + "github.com/NVIDIA/dsx-exchange/mcp/dsx-exchange-mcp/internal/auth" + "github.com/NVIDIA/dsx-exchange/mcp/dsx-exchange-mcp/internal/metrics" + "github.com/NVIDIA/dsx-exchange/mcp/dsx-exchange-mcp/internal/mqttbus" + "github.com/NVIDIA/dsx-exchange/mcp/dsx-exchange-mcp/internal/server" +) + +func main() { + logger := slog.New(slog.NewJSONHandler(os.Stdout, nil)) + slog.SetDefault(logger) + + addr := envOr("MCP_ADDR", ":8080") + natsURL := envOr("NATS_URL", "tcp://nats:1883") + recorder := metrics.NewRecorder() + + cfg := server.Config{ + MQTT: mqttbus.Config{ + BrokerURL: natsURL, + Username: envOr("MQTT_USERNAME", mqttbus.DefaultUsername), + TLS: mqttbus.TLSConfig{ + CAFile: os.Getenv("MQTT_TLS_CA_FILE"), + ServerName: os.Getenv("MQTT_TLS_SERVER_NAME"), + InsecureSkipVerify: envBool("MQTT_TLS_INSECURE_SKIP_VERIFY", false), + }, + ConnectTimeout: time.Duration(envInt("MQTT_CONNECT_TIMEOUT_S", 5)) * time.Second, + SubscribeTimeout: time.Duration(envInt("MQTT_SUBSCRIBE_TIMEOUT_S", 5)) * time.Second, + MaxResultBytes: envInt("MQTT_MAX_RESULT_BYTES", 1048576), + }, + Metrics: recorder, + DefaultMaxMessages: envInt("MCP_DEFAULT_MAX_MESSAGES", 100), + MaxMessages: envInt("MCP_MAX_MESSAGES", 1000), + DefaultDurationS: envInt("MCP_DEFAULT_MAX_DURATION_S", 30), + MaxDurationS: envInt("MCP_MAX_DURATION_S", 30), + WatchDefaultTTLS: envInt("MCP_WATCH_DEFAULT_TTL_S", 300), + WatchMaxTTLS: envInt("MCP_WATCH_MAX_TTL_S", 900), + WatchDefaultBufferMessages: envInt("MCP_WATCH_DEFAULT_BUFFER_MESSAGES", 100), + WatchMaxBufferMessages: envInt("MCP_WATCH_MAX_BUFFER_MESSAGES", 1000), + WatchDefaultBufferBytes: envInt("MCP_WATCH_DEFAULT_BUFFER_BYTES", 262144), + WatchMaxBufferBytes: envInt("MCP_WATCH_MAX_BUFFER_BYTES", 1048576), + WatchMaxPerSession: envInt("MCP_WATCH_MAX_PER_SESSION", 10), + WatchMaxPerPod: envInt("MCP_WATCH_MAX_PER_POD", 1000), + FindTopicsDefaultLimit: envInt("MCP_FIND_TOPICS_DEFAULT_LIMIT", 20), + FindTopicsMaxLimit: envInt("MCP_FIND_TOPICS_MAX_LIMIT", 100), + } + + srv := server.Build(cfg) + + handler := mcp.NewStreamableHTTPHandler( + func(*http.Request) *mcp.Server { return srv }, + nil, + ) + + mux := http.NewServeMux() + mux.Handle("/mcp", auth.Middleware(handler)) + mux.HandleFunc("/healthz/live", healthOK) + mux.HandleFunc("/healthz/ready", healthOK) + mux.Handle("/metrics", recorder.Handler()) + + logger.Info("dsx-exchange-mcp listening", + "addr", addr, + "nats", natsURL, + "mqtt_username", cfg.MQTT.Username, + "mqtt_tls_ca_configured", cfg.MQTT.TLS.CAFile != "", + "mqtt_tls_server_name", cfg.MQTT.TLS.ServerName, + "max_messages", cfg.MaxMessages, + "max_duration_s", cfg.MaxDurationS, + "watch_max_ttl_s", cfg.WatchMaxTTLS, + "watch_max_per_pod", cfg.WatchMaxPerPod, + ) + if err := http.ListenAndServe(addr, mux); err != nil { + logger.Error("server exited", "err", err) + os.Exit(1) + } +} + +func envOr(key, fallback string) string { + if v := os.Getenv(key); v != "" { + return v + } + return fallback +} + +func envInt(key string, fallback int) int { + if v := os.Getenv(key); v != "" { + n, err := strconv.Atoi(v) + if err == nil { + return n + } + slog.Warn("invalid integer env var; using fallback", "key", key, "value", v, "fallback", fallback) + } + return fallback +} + +func envBool(key string, fallback bool) bool { + if v := os.Getenv(key); v != "" { + b, err := strconv.ParseBool(v) + if err == nil { + return b + } + slog.Warn("invalid boolean env var; using fallback", "key", key, "value", v, "fallback", fallback) + } + return fallback +} + +func healthOK(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusNoContent) +} diff --git a/mcp/dsx-exchange-mcp/deploy/helm/dsx-exchange-mcp/Chart.yaml b/mcp/dsx-exchange-mcp/deploy/helm/dsx-exchange-mcp/Chart.yaml new file mode 100644 index 0000000..3840230 --- /dev/null +++ b/mcp/dsx-exchange-mcp/deploy/helm/dsx-exchange-mcp/Chart.yaml @@ -0,0 +1,9 @@ +# Copyright 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +apiVersion: v2 +name: dsx-exchange-mcp +description: MCP server exposing DSX Exchange AsyncAPI specs and a read-only NATS-MQTT bridge. +type: application +version: 0.1.0 +appVersion: "0.1.0" diff --git a/mcp/dsx-exchange-mcp/deploy/helm/dsx-exchange-mcp/templates/deployment.yaml b/mcp/dsx-exchange-mcp/deploy/helm/dsx-exchange-mcp/templates/deployment.yaml new file mode 100644 index 0000000..9b0bbac --- /dev/null +++ b/mcp/dsx-exchange-mcp/deploy/helm/dsx-exchange-mcp/templates/deployment.yaml @@ -0,0 +1,146 @@ +# Copyright 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +apiVersion: apps/v1 +kind: Deployment +metadata: + name: {{ .Release.Name }} + labels: + app: dsx-exchange-mcp +spec: + {{- if and .Values.mqtt.tls.insecureSkipVerify (not .Values.acceptInsecureMQTTTLS) }} + {{- fail "mqtt.tls.insecureSkipVerify=true disables broker certificate verification. Set acceptInsecureMQTTTLS=true only for local-dev, or configure mqtt.tls.caCertSecret/serverName." }} + {{- end }} + replicas: {{ .Values.replicaCount }} + selector: + matchLabels: + app: dsx-exchange-mcp + template: + metadata: + labels: + app: dsx-exchange-mcp + {{- if .Values.metrics.scrape }} + annotations: + prometheus.io/scrape: "true" + prometheus.io/port: "8080" + prometheus.io/path: "/metrics" + {{- end }} + spec: + {{- with .Values.runtimeClassName }} + runtimeClassName: {{ . | quote }} + {{- end }} + {{- with .Values.affinity }} + affinity: + {{- toYaml . | nindent 8 }} + {{- end }} + securityContext: + runAsNonRoot: true + runAsUser: 65532 + runAsGroup: 65532 + containers: + - name: server + image: "{{ .Values.image.repository }}:{{ .Values.image.tag }}" + imagePullPolicy: {{ .Values.image.pullPolicy }} + securityContext: + readOnlyRootFilesystem: true + allowPrivilegeEscalation: false + capabilities: + drop: ["ALL"] + ports: + - name: mcp + containerPort: 8080 + env: + - name: MCP_ADDR + value: ":8080" + - name: NATS_URL + value: {{ .Values.natsURL | quote }} + - name: MQTT_USERNAME + value: {{ .Values.mqtt.username | quote }} + - name: MQTT_CONNECT_TIMEOUT_S + value: {{ .Values.mqtt.connectTimeoutSeconds | int | quote }} + - name: MQTT_SUBSCRIBE_TIMEOUT_S + value: {{ .Values.mqtt.subscribeTimeoutSeconds | int | quote }} + - name: MQTT_MAX_RESULT_BYTES + value: {{ .Values.mqtt.maxResultBytes | int | quote }} + {{- if .Values.mqtt.tls.caCertSecret.name }} + - name: MQTT_TLS_CA_FILE + value: "/etc/dsx-exchange/ca/{{ .Values.mqtt.tls.caCertSecret.key }}" + {{- end }} + {{- if .Values.mqtt.tls.serverName }} + - name: MQTT_TLS_SERVER_NAME + value: {{ .Values.mqtt.tls.serverName | quote }} + {{- end }} + - name: MQTT_TLS_INSECURE_SKIP_VERIFY + value: {{ .Values.mqtt.tls.insecureSkipVerify | quote }} + - name: MCP_DEFAULT_MAX_MESSAGES + value: {{ .Values.limits.defaultMaxMessages | int | quote }} + - name: MCP_MAX_MESSAGES + value: {{ .Values.limits.maxMessages | int | quote }} + - name: MCP_DEFAULT_MAX_DURATION_S + value: {{ .Values.limits.defaultMaxDurationSeconds | int | quote }} + - name: MCP_MAX_DURATION_S + value: {{ .Values.limits.maxDurationSeconds | int | quote }} + - name: MCP_WATCH_DEFAULT_TTL_S + value: {{ .Values.limits.watch.defaultTTLSeconds | int | quote }} + - name: MCP_WATCH_MAX_TTL_S + value: {{ .Values.limits.watch.maxTTLSeconds | int | quote }} + - name: MCP_WATCH_DEFAULT_BUFFER_MESSAGES + value: {{ .Values.limits.watch.defaultBufferMessages | int | quote }} + - name: MCP_WATCH_MAX_BUFFER_MESSAGES + value: {{ .Values.limits.watch.maxBufferMessages | int | quote }} + - name: MCP_WATCH_DEFAULT_BUFFER_BYTES + value: {{ .Values.limits.watch.defaultBufferBytes | int | quote }} + - name: MCP_WATCH_MAX_BUFFER_BYTES + value: {{ .Values.limits.watch.maxBufferBytes | int | quote }} + - name: MCP_WATCH_MAX_PER_SESSION + value: {{ .Values.limits.watch.maxPerSession | int | quote }} + - name: MCP_WATCH_MAX_PER_POD + value: {{ .Values.limits.watch.maxPerPod | int | quote }} + - name: MCP_FIND_TOPICS_DEFAULT_LIMIT + value: {{ .Values.limits.findTopics.defaultLimit | int | quote }} + - name: MCP_FIND_TOPICS_MAX_LIMIT + value: {{ .Values.limits.findTopics.maxLimit | int | quote }} + - name: LOG_FORMAT + value: json + {{- if .Values.otel.exporterOtlpEndpoint }} + - name: OTEL_EXPORTER_OTLP_ENDPOINT + value: {{ .Values.otel.exporterOtlpEndpoint | quote }} + - name: OTEL_SERVICE_NAME + value: {{ .Values.otel.serviceName | quote }} + {{- end }} + startupProbe: + httpGet: + path: /healthz/live + port: mcp + initialDelaySeconds: 1 + periodSeconds: 2 + failureThreshold: 30 + livenessProbe: + httpGet: + path: /healthz/live + port: mcp + periodSeconds: 10 + failureThreshold: 3 + readinessProbe: + httpGet: + path: /healthz/ready + port: mcp + periodSeconds: 5 + failureThreshold: 2 + {{- if .Values.mqtt.tls.caCertSecret.name }} + volumeMounts: + - name: mqtt-ca + mountPath: /etc/dsx-exchange/ca + readOnly: true + {{- end }} + resources: + {{- toYaml .Values.resources | nindent 12 }} + {{- if .Values.mqtt.tls.caCertSecret.name }} + volumes: + - name: mqtt-ca + secret: + secretName: {{ .Values.mqtt.tls.caCertSecret.name | quote }} + items: + - key: {{ .Values.mqtt.tls.caCertSecret.key | quote }} + path: {{ .Values.mqtt.tls.caCertSecret.key | quote }} + {{- end }} diff --git a/mcp/dsx-exchange-mcp/deploy/helm/dsx-exchange-mcp/templates/pdb.yaml b/mcp/dsx-exchange-mcp/deploy/helm/dsx-exchange-mcp/templates/pdb.yaml new file mode 100644 index 0000000..badf795 --- /dev/null +++ b/mcp/dsx-exchange-mcp/deploy/helm/dsx-exchange-mcp/templates/pdb.yaml @@ -0,0 +1,16 @@ +# Copyright 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +{{- if .Values.podDisruptionBudget.enabled }} +apiVersion: policy/v1 +kind: PodDisruptionBudget +metadata: + name: {{ .Release.Name }} + labels: + app: dsx-exchange-mcp +spec: + minAvailable: {{ .Values.podDisruptionBudget.minAvailable }} + selector: + matchLabels: + app: dsx-exchange-mcp +{{- end }} diff --git a/mcp/dsx-exchange-mcp/deploy/helm/dsx-exchange-mcp/templates/service.yaml b/mcp/dsx-exchange-mcp/deploy/helm/dsx-exchange-mcp/templates/service.yaml new file mode 100644 index 0000000..ef7d176 --- /dev/null +++ b/mcp/dsx-exchange-mcp/deploy/helm/dsx-exchange-mcp/templates/service.yaml @@ -0,0 +1,18 @@ +# Copyright 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +apiVersion: v1 +kind: Service +metadata: + name: {{ .Release.Name }} + labels: + app: dsx-exchange-mcp +spec: + type: ClusterIP + selector: + app: dsx-exchange-mcp + ports: + - name: mcp + port: {{ .Values.service.port }} + targetPort: mcp + appProtocol: agentgateway.dev/mcp diff --git a/mcp/dsx-exchange-mcp/deploy/helm/dsx-exchange-mcp/values.deployed-bus.example.yaml b/mcp/dsx-exchange-mcp/deploy/helm/dsx-exchange-mcp/values.deployed-bus.example.yaml new file mode 100644 index 0000000..47bbf0c --- /dev/null +++ b/mcp/dsx-exchange-mcp/deploy/helm/dsx-exchange-mcp/values.deployed-bus.example.yaml @@ -0,0 +1,19 @@ +# Copyright 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +# Example overlay for gated e2e / dev testing against the deployed DSX Exchange +# bus. Do not commit real bearer tokens or CA material; create the referenced +# Secret out of band in the target namespace. + +natsURL: tls://event-bus-ytl-dev2.dev.dsx.nvidia.com:1883 + +runtimeClassName: "" + +mqtt: + username: oauth + tls: + caCertSecret: + name: dsx-exchange-broker-ca + key: ca.crt + serverName: event-bus-ytl-dev2.dev.dsx.nvidia.com + insecureSkipVerify: false diff --git a/mcp/dsx-exchange-mcp/deploy/helm/dsx-exchange-mcp/values.yaml b/mcp/dsx-exchange-mcp/deploy/helm/dsx-exchange-mcp/values.yaml new file mode 100644 index 0000000..68cdcc3 --- /dev/null +++ b/mcp/dsx-exchange-mcp/deploy/helm/dsx-exchange-mcp/values.yaml @@ -0,0 +1,79 @@ +# Copyright 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +image: + repository: dsx-exchange-mcp + tag: 0.1.0 + pullPolicy: IfNotPresent + +replicaCount: 2 + +service: + port: 8080 + +podDisruptionBudget: + enabled: true + minAvailable: 1 + +# Per Latinum MCP Gateway SDD: upstream MCP servers run with kata, nonroot, +# readonly rootfs, and emit JSON logs. OTel Collector can scrape /metrics. +runtimeClassName: kata + +natsURL: tcp://nats.nats.svc:1883 + +mqtt: + username: oauthtoken + connectTimeoutSeconds: 5 + subscribeTimeoutSeconds: 5 + maxResultBytes: 1048576 + tls: + caCertSecret: + name: "" + key: ca.crt + serverName: "" + insecureSkipVerify: false + +limits: + defaultMaxMessages: 100 + maxMessages: 1000 + defaultMaxDurationSeconds: 30 + maxDurationSeconds: 30 + watch: + defaultTTLSeconds: 300 + maxTTLSeconds: 900 + defaultBufferMessages: 100 + maxBufferMessages: 1000 + defaultBufferBytes: 262144 + maxBufferBytes: 1048576 + maxPerSession: 10 + maxPerPod: 1000 + findTopics: + defaultLimit: 20 + maxLimit: 100 + +metrics: + scrape: true + +acceptInsecureMQTTTLS: false + +affinity: + podAntiAffinity: + preferredDuringSchedulingIgnoredDuringExecution: + - weight: 100 + podAffinityTerm: + labelSelector: + matchLabels: + app: dsx-exchange-mcp + topologyKey: kubernetes.io/hostname + +resources: + requests: + cpu: 50m + memory: 64Mi + limits: + cpu: 500m + memory: 256Mi + +otel: + exporterOtlpEndpoint: "" + serviceName: dsx-exchange-mcp diff --git a/mcp/dsx-exchange-mcp/docs/dsx-exchange-mcp-sdd.md b/mcp/dsx-exchange-mcp/docs/dsx-exchange-mcp-sdd.md new file mode 100644 index 0000000..a9abee3 --- /dev/null +++ b/mcp/dsx-exchange-mcp/docs/dsx-exchange-mcp-sdd.md @@ -0,0 +1,982 @@ +# DSX Exchange MCP + +## Aggregated Requirements + +This section is intentionally placed before the normal SDD template sections so +reviewers can see the DSX Exchange MCP requirements in one place before reading +the design. + +### MCP Requirements From DSX Exchange PRD + +The DSX Exchange PRD defines MCP as a first-class Exchange interface. The PRD +contains duplicate entries for `MCP-13` and `MCP-14`; this SDD treats each +requirement once. + +| ID | Priority | Aggregated Requirement | +| :--- | :---: | :--- | +| MCP-1 | P0 | Provide one MCP endpoint where agents discover every tool they are authorized to use across topology, power, health, events, and logs. | +| MCP-2 | P0 | Expose MCP-compliant tool schemas with clear input types and response structures. | +| MCP-3 | P0 | Scope tool discovery to caller permissions so unauthorized tools do not appear. | +| MCP-4 | P0 | Support infrastructure topology queries for nodes, switches, racks, and physical relationships. | +| MCP-5 | P0 | Include cooling-to-compute relationships in topology results. | +| MCP-6 | P0 | Support pre-flight queries for power headroom, cooling capacity, and available compute before provisioning. | +| MCP-7 | P0 | Use the same real-time power telemetry source as MaxLPS / DSX LPS. | +| MCP-8 | P1 | Support resource health, availability, and utilization queries across compute, cooling, and power. | +| MCP-9 | P1 | Surface correlated anomalies such as CDU flow drops co-located with GPU temperature rise. | +| MCP-10 | P1 | Let agents subscribe to curated Exchange event topics through MCP tools, pre-filtered by domain and relevance. | +| MCP-11 | P1 | Surface BMS leak events with operational context such as rack ID, affected compute, and recommended action. | +| MCP-12 | P2 | Retrieve operational logs and correlated event context through one MCP workflow. | +| MCP-13 | P1 | Support asynchronous tasks with status, TTL, and persistence for long-running operations. | +| MCP-14 | P1 | Show long-running agent tasks in audit logs with status and initiating identity. | +| MCP-15 | P0 | Require NVIDIA-approved authentication and token validation before any MCP tool invocation. | +| MCP-16 | P0 | Validate that tokens were issued for the intended protected resource. | +| MCP-17 | P0 | Retrieve credentials from a centralized secret store and avoid local client-secret storage. | +| MCP-18 | P0 | Audit every MCP tool invocation with caller identity, timestamp, tool name, and input parameters. | +| MCP-19 | P1 | Return structured, actionable MCP errors. | +| MCP-20 | P1 | Support pagination or cursoring for large result sets. | +| MCP-21 | P1 | Provide mock or stub mode for the MCP Gateway and all Exchange tools. | +| MCP-22 | P1 | Use the same factory data model across Exchange events, APIs, and MCP tools. | +| MCP-23 | P1 | Provide a documented pattern and reference implementation for partner-built agents. | +| MCP-24 | P0 | Treat MCP as a first-class Exchange interface with reliability, auth, observability, and documentation equivalent to the MQTT layer. | +| MCP-25 | P0 | Resolve and document the relationship between read-only MCP Gateway and action-taking CLI/TUI MCP surfaces. | + +### Long-Running Subscription UX Requirements + +The long-running subscription UX note adds the following requirements for +background Exchange watches. + +| ID | Priority | Aggregated Requirement | +| :--- | :---: | :--- | +| LSUB-1 | P0 | Start a long-running Exchange subscription and return a `subscription_id` immediately. | +| LSUB-2 | P0 | Allow users to ask an agent to watch a topic or domain without knowing raw MQTT hierarchy. | +| LSUB-3 | P0 | Read buffered messages by cursor with bounded `max_messages` and `max_bytes`. | +| LSUB-4 | P0 | Summarize what happened since a watch started. | +| LSUB-5 | P0 | Report subscription status such as running, reconnecting, expired, denied, or buffer overflow. | +| LSUB-6 | P1 | Emit optional MCP / SSE notifications when messages arrive or status changes. | +| LSUB-7 | P1 | Compute aggregations over a background stream such as counts, latest values, min/max/avg, or grouping by topic/object type. | +| LSUB-8 | P1 | Dump bounded raw message batches as JSON or JSONL for debugging. | +| LSUB-9 | P1 | Export a subscription to approved observability sinks such as Flight Recorder or logs. | +| LSUB-10 | P0 | Audit every subscription start, read, aggregation, export, and stop with caller identity and arguments. | +| LSUB-11 | P0 | Stop subscriptions explicitly and expire idle or over-TTL subscriptions automatically. | +| LSUB-12 | P1 | Return structured errors for ACL denial, authentication failure, reconnect exhaustion, buffer overflow, and expired subscriptions. | + +### DSX Exchange PRD Requirements Applied To MCP + +The PRD frames Exchange as a shared communication layer for compute, network, +power, cooling, IT, OT, and agents. For the MCP interface this means: + +| Area | Applied MCP Requirement | +| :--- | :--- | +| Exchange Foundation | MCP must expose Exchange as one coherent integration surface, not as separate one-off integrations per system. Events, MCP tools, and APIs must share the same auth model, data model, governance, versioning, and deprecation posture. | +| MQTT Machine Integration | MCP tools must let agents observe BMS, DPS / power-management, grid, NICO, SPIFFE, and other Exchange domains through schema-derived topics without requiring raw MQTT client code. | +| Multi-Site Needs | MCP must preserve Exchange layer isolation and controlled federation. Agents must not use MCP to bypass MDC/VDC/VMS boundaries or site/hall/shard policy. | +| Identity and Security | Every MCP read, watch, export, and future action must be authenticated, authorized by tenant/project/caller identity, scoped in discovery, rate limited, revocable, and audited. | +| Partner Integration | MCP must expose partner-consumable contracts based on AsyncAPI schemas, with staging/mock paths for agent and integration development. | +| Operational Needs | MCP must expose health, readiness, Prometheus metrics, structured JSON logs, and actionable degradation signals for critical Exchange paths. | +| Sovereign Needs | MCP must support air-gapped operation with no runtime dependency on external documentation or schema fetches. | + +### Requirements Inherited From Latinum MCP Server SRD + +| Area | Applied Requirement | +| :--- | :--- | +| Protocol | Implement MCP Streamable HTTP for discovery, invocation, response handling, resources, tools, and server-to-client notifications where supported. | +| Discovery Consistency | A caller must not see tools or resources they are not authorized to invoke or read. Discovery must not be a leak channel. | +| Backend Authorization | The gateway performs coarse authorization and forwards the caller credential unchanged. The upstream server remains responsible for fine-grained authorization. | +| Async Operations | Support asynchronous patterns for long-running operations. For Exchange MCP, this primarily means background watches with status, TTL, audit, and bounded reads. | +| Performance | Keep discovery fast, bound tool execution, support at least 100 concurrent agent connections, and fit within per-tenant gateway rate limits. | +| Reliability | Provide liveness/readiness probes, graceful degradation when Exchange is unavailable, and HA deployment with pod anti-affinity. | +| Security | Use NVIDIA-approved auth, TLS at deployment boundaries, input validation, egress control, centralized secret management for server credentials, no local credential storage, and audit logging. | +| Operability | Deploy as Kubernetes-native workload, emit JSON logs and Prometheus-compatible metrics, support configuration by environment / ConfigMap, and support zero-downtime rolling upgrades where active in-memory watches permit it. | +| Testability | Provide automated tests, performance validation, and mock/stub mode for agent development. | + +### Requirements Inherited From Latinum MCP Gateway SDD + +| Area | Applied Requirement | +| :--- | :--- | +| Gateway Topology | `dsx-exchange-mcp` is an upstream MCP server behind the Latinum MCP Gateway, not a separate external endpoint for agents in production. | +| Bearer Passthrough | The gateway validates the caller JWT and forwards `Authorization: Bearer ` unchanged to the upstream. | +| Coarse + Fine Auth | Gateway CEL policy filters MCP items coarsely. `dsx-exchange-mcp` and the Exchange broker enforce fine-grained resource and topic authorization. | +| Stateful Sessions | Agentgateway stateful MCP session routing uses `Mcp-Session-Id` to keep follow-up requests pinned to the resolved upstream pod. | +| Selector Targets | Production upstream registration must use selector-based targets so session routing can pin to individual backend pods. | +| Aggregation Prefixes | In multi-upstream gateway deployments, tools are exposed with gateway target prefixes. The SDD must describe bare upstream names and gateway-prefixed names where relevant. | +| Failure Semantics | MCP-layer denials return JSON-RPC errors in SSE frames; auth/rate-limit/malformed requests may return HTTP errors before reaching the upstream. | +| Audit Correlation | Gateway and upstream logs correlate through timestamp, caller identity, and MCP session ID. | + +### Requirements Inherited From Latinum Event Bus SRD / SDD + +| Area | Applied Requirement | +| :--- | :--- | +| Protocol | DSX Exchange uses MQTT 3.1.1 as the client-facing protocol on NATS. | +| Auth | OAuth2 / SSA JWTs may be supplied in the MQTT password field. Auth methods on one connection are mutually exclusive. | +| Authorization | Publish and subscribe permissions are enforced by predefined topic/subject patterns with dynamic wildcard matching. | +| Source Of Truth | Broker/auth-callout enforcement is authoritative for MQTT access. MCP must not duplicate policy in a divergent way. | +| Federation | MCP must respect Exchange federation, topic prefixing, and layer isolation. It observes the topic space visible to the caller's broker account. | +| Persistence | Retained messages and QoS state are broker/JetStream concerns. MCP does not become a durable event database. | +| Schemas | The bus is schema agnostic, but DSX Exchange participants publish formal AsyncAPI specs for every exposed subject and payload. | +| Observability | Exchange components must emit stdout logs, Prometheus metrics, and health endpoints. | +| Performance | MCP must protect the broker from unbounded agent reads through caps, cursors, probe limits, and per-pod concurrency limits. | + +# Software Architecture & Design Document (PLC-L1 SADD v2018-02-07/NGC) + +## Revision History + +| Version | Date | Modified By | Description | +| :---: | :---: | :--- | :--- | +| 0.1 | May 18, 2026 | Codex | Initial DSX Exchange MCP SDD draft from PRD, SRD/SDD inputs, handoff notes, and long-running subscription UX. | + +## Stakeholder Approvals + +| Stakeholder Name | Role | Date | Approver Comments | +| :--- | :---: | :---: | :--- | +| TBD | pic | | | +| TBD | prod | | | +| TBD | arch | | | +| TBD | eng | | | +| TBD | qa | | | +| TBD | sec | | | + +# 1 Introduction + +## 1.1 Overview + +`dsx-exchange-mcp` is the DSX Exchange upstream MCP server for agents. It sits +behind the Latinum MCP Gateway and exposes Exchange schemas, topic discovery, +bounded reads, retained metadata reads, and long-running background watches over +DSX Exchange MQTT topics. + +The production north-star is: + +```text +Agent / MCP client + -> Latinum MCP Gateway /mcp + -> dsx-exchange-mcp upstream MCP server + -> DSX Exchange MQTT broker + -> NATS auth-callout and broker ACL enforcement +``` + +The MCP interface does not replace DSX Exchange as the system-to-system event +bus. It gives agents a safe, discoverable, audited way to observe Exchange data +and reason over factory state. Read-oriented MCP functionality is in scope for +this SDD. Action-taking CLI/TUI MCP integration is identified by the PRD as a +required architectural decision, but direct write tools are not introduced here +until each action surface has explicit topic, setpoint, authorization, and audit +constraints. + +This SDD distinguishes three Exchange MCP data paths: + +1. **Schema and topic discovery.** Agents read AsyncAPI-derived resources and + call helper tools that map user intent to authorized Exchange topics. +2. **Bounded reads.** Agents read retained metadata or collect live messages + for bounded windows with message, duration, and byte caps. +3. **Background watches.** Agents start long-running MQTT subscriptions that + continue in the background, buffer bounded data under a `subscription_id`, + and are consumed through cursor reads, summaries, aggregations, optional + notifications, and explicit stop/expiry. + +## 1.2 Assumptions, Constraints, Dependencies + +### Assumptions + +* Production callers use the Latinum MCP Gateway. Direct access to + `dsx-exchange-mcp` is for development and diagnostics. +* The gateway validates caller JWTs, applies coarse MCP item authorization, and + forwards the original bearer to upstream MCP servers. +* DSX Exchange broker/auth-callout remains authoritative for MQTT + authentication and topic ACLs. +* AsyncAPI specs are the canonical machine-readable Exchange schema source for + MCP topic discovery and helper tools. +* v1 background watches use pod-local, in-memory state owned by the + session-pinned `dsx-exchange-mcp` pod and are lost on pod restart or session + loss. +* v1 intentionally optimizes for a simple, bounded background-watch UX before + adding external durable watch infrastructure. Strict stateless compliance for + watches requires externalizing watch metadata, cursors, and buffers. + +### Constraints + +* MCP discovery must not expose tools, resources, schema domains, or channels + the caller is not authorized to use. +* The MCP server must not hold broad service credentials for user data access. +* The MCP server must not become a durable event database or unrestricted data + export path. +* Raw MQTT topic filters supplied by agents must be validated and bounded. +* Long-running watches must have TTL, idle expiry, buffer limits, and explicit + overflow policy. +* Streamable HTTP / SSE notifications are optional acceleration signals; they + are not the only reliable data consumption mechanism. + +### Dependencies + +| Dependency | Purpose | +| :--- | :--- | +| Latinum MCP Gateway | External MCP endpoint, caller authentication, coarse discovery filtering, rate limiting, bearer passthrough, stateful session routing. | +| DSX Exchange MQTT broker | MQTT 3.1.1 topic subscription, retained messages, broker-level ACL enforcement. | +| NATS auth-callout | Validates MQTT credentials and mints effective NATS permissions used by the broker. | +| DSX Exchange schema repository | AsyncAPI specs for BMS, power-management, NICO, SPIFFE exchange, and future domains. | +| Flight Recorder / observability stack | Approved export and incident-evidence destination for selected background watches. | +| Central secret store | Source of broker CA material or service-side credentials that are not caller credentials. | +| External watch state store, future | Valkey, Redis, JetStream, or another approved backend for durable watch metadata, cursors, and buffers when v1 pod-local state is insufficient. | + +## 1.3 Definitions, Acronyms, Abbreviations + +| Term | Definition | +| :--- | :--- | +| MCP | Model Context Protocol. JSON-RPC 2.0 over Streamable HTTP for agent tools, resources, prompts, and notifications. | +| Exchange | DSX Exchange, the AI factory shared communication layer over the Latinum Event Bus. | +| MQTT | MQTT 3.1.1, the DSX Exchange client-facing pub/sub protocol. | +| AsyncAPI | Schema format used by DSX Exchange to define MQTT topics, operations, messages, and payloads. | +| BMS | Building Management System. Publishes physical plant telemetry and metadata. | +| NICO | Managed host / bare-metal state system publishing lifecycle events. | +| SPIFFE | Secure Production Identity Framework For Everyone. Used for workload identity and key distribution. | +| ACL | Access control list. In this design, broker subscribe/publish permissions derived by auth-callout. | +| Background watch | A long-running MQTT subscription created through MCP, identified by `subscription_id`, buffered server-side, and read through bounded calls. | +| Cursor | Monotonic subscription buffer position used to read bounded batches without re-sending all messages. | +| Logical subscription | One MCP-created background watch with its own `subscription_id`, topic filter, cursor, buffer, status, TTL, and audit state. | +| MQTT client | One broker connection authenticated with one effective caller authorization context. A pod may run many MQTT clients. | +| SSE | Server-Sent Events, used by MCP Streamable HTTP for server-to-client response frames and optional notifications. | + +## 1.4 Reference Documents + +| Document | Location | +| :--- | :--- | +| DSX Exchange PRD | `docs/input/[WIP] DSX Exchange PRD.docx` | +| Latinum Event Bus SRD | `../latinum-event-bus-poc/docs/Latinum Event Bus SRD.md` | +| Latinum Event Bus SDD | `../latinum-event-bus-poc/docs/Latinum Event Bus SDD.md` | +| Latinum MCP Server SRD | `../dsx-mcp/Latinum MCP Server - SRD.md` | +| Latinum MCP Gateway SDD | `../dsx-mcp/Latinum MCP Gateway - SDD.md` | +| DSX MCP / Exchange MCP Handoff | `../DSX_MCP_HANDOFF.md` | +| DSX Exchange MCP Discussion Notes | `docs/sdd-discussion-notes.md` | +| Long-Running Subscription UX | `docs/long-running-subscriptions-ux.md` | +| DSX Exchange Schema README | `../schema/README.md` | +| AsyncAPI specs | `../schema/specs/*.yaml` | + +# 2 Architecture Details + +## 2.1 System Context + +`dsx-exchange-mcp` is an upstream MCP server. It is not exposed directly to +agents in the production profile. Agents authenticate to the Latinum MCP Gateway +and receive an aggregated tool/resource catalogue filtered by gateway policy. +When an Exchange tool is invoked, the gateway forwards the caller bearer to +`dsx-exchange-mcp`. The upstream server uses that bearer when connecting to the +Exchange MQTT broker. + +```plantuml +@startuml +!theme plain +skinparam componentStyle rectangle + +actor "Agent / MCP client" as client +component "Latinum MCP Gateway\n(agentgateway)" as gateway +component "dsx-exchange-mcp\n(upstream MCP)" as mcp +component "DSX Exchange MQTT broker\n(NATS MQTT facade)" as broker +component "NATS auth-callout" as auth +database "AsyncAPI bundle\n(build-time embed)" as specs +component "Flight Recorder /\nObservability sinks" as flight + +client --> gateway : MCP Streamable HTTP\nAuthorization: Bearer JWT +gateway --> mcp : MCP upstream request\nAuthorization preserved +mcp --> broker : MQTT CONNECT\nusername=oauth profile\npassword=caller JWT +broker --> auth : auth request +auth --> broker : effective permissions +mcp --> specs : read schema index +mcp --> flight : approved export only +@enduml +``` + +## 2.2 Request Flow For Bounded Reads + +```plantuml +@startuml +!theme plain +autonumber +actor Client +participant "Latinum MCP Gateway" as GW +participant "dsx-exchange-mcp" as MCP +participant "MQTT Broker" as MQTT +participant "auth-callout" as AUTH + +Client -> GW : tools/call dsx_exchange_read_retained\nAuthorization: Bearer JWT +GW -> GW : validate JWT, rate limit,\ncoarse MCP authorization +GW -> MCP : tools/call\nAuthorization: Bearer JWT +MCP -> MCP : validate args and limits +MCP -> MQTT : CONNECT username=\npassword= +MQTT -> AUTH : validate credential and derive ACLs +AUTH --> MQTT : permissions +MCP -> MQTT : SUBSCRIBE topic_filter +MQTT --> MCP : SUBACK allowed or denied +MCP -> MCP : collect bounded retained/live messages +MCP --> GW : structured MCP result or error +GW --> Client : SSE-framed MCP response +@enduml +``` + +The upstream server does not decide whether a caller is allowed to subscribe to +a topic by trusting claims alone. It asks the broker through the actual MQTT +connect/subscribe path, or through a future entitlement API backed by the same +permission manager. + +## 2.3 JWT Passthrough To MQTT + +The caller bearer follows this path: + +```text +Client Authorization header + -> Latinum MCP Gateway validation + -> gateway upstream Authorization passthrough + -> dsx-exchange-mcp request context + -> MQTT CONNECT password + -> NATS auth-callout validation and permission derivation +``` + +The MQTT username is deployment configuration. The bearer is never accepted as +an MCP tool argument and is never logged. `dsx-exchange-mcp` logs only whether a +bearer was present plus normalized caller identity fields supplied by the +gateway or derived from the validated context. + +This design keeps one authority for MQTT topic access. The gateway can hide or +block MCP items coarsely, but broker SUBACK denial remains the final control for +actual topic subscription. + +## 2.4 AsyncAPI Schema Access And Tool Generation + +The server embeds AsyncAPI specs at build time. At startup it parses each +non-empty domain spec into a schema access index: + +| Index Field | Purpose | +| :--- | :--- | +| Domain | `bms`, `power-management`, `nico`, `spiffe-exchange`, and future domains. | +| Channel name | Stable AsyncAPI channel key. | +| MQTT address | Raw channel address, including parameters such as `{pointType}` or `{tagPath}`. | +| MQTT filter examples | Agent-safe filters derived from parameters, enums, and documented examples. | +| NATS subject pattern | MQTT-to-NATS conversion used for entitlement intersection. | +| Operation direction | Whether the channel is for publish, subscribe, or both from a consumer viewpoint. | +| Message schema | Payload schema reference and content type. | +| Domain hints | Operational labels such as metadata, value, leak, power, keyset, state transition. | + +The index supports four MCP behaviors: + +1. Resource reads for authorized schema domains. +2. Topic-finding tools that map user intent to schema-derived filters. +3. Curated domain tools for BMS metadata, topology graph extraction, power + events, NICO state transitions, and SPIFFE public keysets. +4. Authorization-aware hiding of domains/channels the caller cannot subscribe + to. + +The initial domain interpretation is: + +| Domain | MCP Treatment | +| :--- | :--- | +| BMS | Parse Value / Metadata channel pairs. Prefer retained metadata reads before live value subscriptions. Build relationships from metadata fields such as object IDs, process area, served load IDs, rack/CDU/power/cooling relationships, and integration ownership. | +| Power Management | Parse CloudEvents channels for grid load target, power state status, breach alert, and enforcement outcomes. Provide topic discovery and event summaries; write actions are out of scope until control constraints are approved. | +| NICO | Parse managed host state topics and expose state transition watches, counts, and machine-scoped filters. | +| SPIFFE Exchange | Parse public keyset topics and expose read-only discovery of tenant/kid key distribution where authorized. | + +## 2.5 Schema Visibility And ACLs + +Schema visibility must match effective subscribe authorization. A caller should +not see a domain or channel that is not relevant to any topic they can read. + +### Tactical V1: Canonical Broker Probes + +When no entitlement API is available, the server can perform bounded MQTT +authorization probes using the caller bearer: + +```text +MQTT CONNECT with caller bearer +MQTT SUBSCRIBE canonical schema filter +observe SUBACK success or denial +cache decision briefly, no longer than token expiry +``` + +Example canonical probes: + +| Domain | Probe Filter | +| :--- | :--- | +| BMS metadata | `BMS/v1/PUB/Metadata/#` | +| BMS values | `BMS/v1/PUB/Value/#` | +| Power management | schema-derived grid/power-management subscribe prefixes | +| NICO | `nico/v1/machine/+/state` or deployment-approved equivalent | +| SPIFFE Exchange | `spiffe-exchange/v1/pub-keysets/tenant/+/kid/+` or tenant-scoped equivalent | + +Probe limits are mandatory: short connect and subscribe timeouts, low probe +counts per discovery request, fail-closed on ambiguous broker/network errors, +and metrics for probe latency and failures. + +The limitation is false negatives for narrow ACLs. If a caller can read only a +specific rack path, a broad domain probe may fail even though a subset of the +schema applies. That limitation is why canonical probes are tactical, not the +preferred production design. + +### Preferred Production: Entitlement API + +The preferred design is a read-only entitlement API backed by the same +permission manager used by auth-callout. It exposes the effective subscribe +decision or effective permissions without becoming a second source of truth. +The broker still enforces actual MQTT access. + +The entitlement API can support: + +```http +POST /v1/authorize +{ + "token": "", + "action": "mqtt.subscribe", + "topic_filter": "BMS/v1/PUB/Metadata/#" +} +``` + +or: + +```http +POST /v1/effective-permissions +{ + "token": "", + "protocol": "mqtt" +} +``` + +`dsx-exchange-mcp` maps effective MQTT/NATS permissions to schema capabilities +by intersecting ACL patterns with the AsyncAPI access index. Deny rules take +precedence. If a partial allow/deny combination cannot be represented safely, +the channel is hidden or exposed only through a narrower recommended filter. + +## 2.6 Long-Running Background Watches + +Long-running Exchange subscriptions are modeled as background watches. A normal +MCP `tools/call` is not held open forever. + +```plantuml +@startuml +!theme plain +autonumber +actor User +participant "Agent" as Agent +participant "Latinum MCP Gateway" as GW +participant "Session-pinned\ndsx-exchange-mcp pod" as MCP +participant "MQTT Broker" as MQTT + +User -> Agent : Watch BMS leak events for row 3 +Agent -> GW : start_subscription(intent/topic) +GW -> MCP : route by MCP session +MCP -> MQTT : CONNECT/SUBSCRIBE with caller bearer +MCP -> MCP : create subscription_id,\nring buffer, status +MCP --> Agent : subscription_id, cursor, TTL +MQTT --> MCP : matching messages +MCP -> MCP : buffer, summarize,\noptional notification +Agent -> GW : read_subscription(cursor) +GW -> MCP : same Mcp-Session-Id\npinned to owning pod +MCP --> Agent : bounded batch + next_cursor +User -> Agent : Stop watching +Agent -> GW : stop_subscription(subscription_id) +GW -> MCP : owning pod +MCP -> MQTT : unsubscribe if no longer referenced +@enduml +``` + +The reliable contract is cursor-based reads and server-side summaries over +bounded buffers. Streamable HTTP / SSE notifications are optional signals for +clients that support them: + +```json +{ + "subscription_id": "sub_123", + "event": "messages_available", + "count": 17, + "severity": "warning", + "summary": "Rack leak event observed for rack R12" +} +``` + +Notifications must not carry unbounded raw payloads. Clients that do not expose +notifications still work by polling `subscription_status` and +`read_subscription`. + +## 2.7 Session Pinning And Pod Ownership + +Background watches require stateful MCP sessions. Agentgateway selector-based +targets and `sessionRouting: Stateful` allow the gateway to resolve an upstream +pod during `initialize` and encode that pod binding in `Mcp-Session-Id`. +Follow-up calls with the same session ID are routed to the same +`dsx-exchange-mcp` pod. + +The v1 recommendation is pod-local state. This is not a global "one pod state" +object; it is three related object types in the owning pod: + +| Object | Stored Attributes | +| :--- | :--- | +| MCP session | `Mcp-Session-Id`, caller identity, authorization fingerprint, creation time, last-seen time, active subscription IDs, and session limits. | +| Logical subscription | `subscription_id`, owner session ID, owner auth fingerprint, topic filter, schema domain/channel, status, cursor range, ring buffer, summary/aggregation state, TTL, idle expiry, message/byte counters, drop counters, and last error. | +| MQTT client | Client ID, broker URL, TLS config fingerprint, MQTT username, effective auth-context key, token expiry, creation time, last-used time, active logical-subscription refcount, broker-subscription refcounts, connection status, reconnect count, and last error. | + +The ring buffer is held on the logical subscription, not on the MQTT client. A +single MQTT client can feed several logical subscriptions for the same effective +authorization context, and each logical subscription keeps its own cursor and +buffer. + +For v1, pod restart, eviction, or session loss terminates active watches. The +client must start a new subscription. Durable cross-pod recovery is future work. +If product requirements require survivable watches across pod restart, the +watch state must move to an external backend such as Valkey for metadata, +cursors, and bounded buffers, or to JetStream / Flight Recorder when durable +event replay is the desired behavior. + +## 2.8 MQTT Connection Grouping + +MQTT connections are authenticated at CONNECT time and carry the effective ACLs +derived from that credential. Sharing a connection across callers can leak +permissions. + +The safe rule is: + +> Never share MQTT connections across distinct effective caller authorization +> contexts. + +There is no global MQTT client per pod. There also is not necessarily one MQTT +client per MCP client. The recommended v1 target is pod-local MQTT client reuse +within one MCP session and one effective authorization context. A bounded tool +call and a background watch may use the same MQTT client when the session, +broker configuration, and effective authorization context match. + +The right pooling unit is: + +```text +MCP session ID + + effective MQTT authorization context + + broker URL / TLS config / MQTT username +``` + +For bounded read tools, a short-lived MQTT client per tool call remains a simple +fallback if implementation risk must be reduced. Production should prefer +session/auth-context reuse to avoid repeated TLS handshakes, MQTT CONNECTs, and +auth-callout evaluations during normal agent workflows. + +The pool key is conservative: + +| Pool Key Component | Reason | +| :--- | :--- | +| Broker URL and TLS config | Keeps broker/security endpoints separate. | +| MQTT username | OAuth profile can affect auth-callout handling. | +| Issuer, subject, authorized party | Captures caller identity. | +| Audience and scopes | Captures token intent. | +| Tenant or persona | Prevents cross-tenant and cross-persona sharing. | +| Token expiry bucket | Prevents use past credential lifetime. | +| Policy version or permissions hash | Required for safe reuse when available. | + +The MQTT client lifecycle is separate from the broker subscription lifecycle: + +| Operation | MQTT Client Behavior | Broker Subscription Behavior | +| :--- | :--- | :--- | +| Bounded read | Get or create the pooled client for the session/auth context. | Create a temporary broker subscription, collect within message/duration/byte limits, then unsubscribe. | +| Background watch | Get or create the pooled client for the session/auth context. | Create a persistent broker subscription, keep it active until stop, TTL, idle expiry, token expiry, or session loss. | + +One pooled MQTT connection may carry multiple broker subscriptions for the same +effective auth context. The server must demultiplex incoming MQTT messages into +temporary bounded-call collectors or persistent logical-subscription buffers. +Broker subscriptions are reference-counted so stopping one logical subscription +or completing one bounded call does not remove a broker subscription still +needed by another active operation. + +The default policy is to share one pooled MQTT client for bounded calls and +background watches within the same session/auth context. A deployment may +promote a watch to a dedicated MQTT client when configured thresholds indicate +isolation is safer, such as high message rate, broad wildcard filters, buffer +pressure, reconnect churn, or measurable latency impact on bounded calls. + +MQTT clients are closed when their active operation refcount reaches zero and +their idle TTL expires. Active operations include temporary bounded calls and +persistent background watches. Clients are also closed on token expiry, max +client lifetime, MCP session close/expiry, policy version change, revocation +signal, reconnect exhaustion, or pod drain. The idle TTL should be short enough +to avoid broker connection accumulation and long enough to avoid reconnect churn +during normal agent workflows. + +# 3 Design Details + +## 3.1 MCP API + +### 3.1.1 Resources + +| Resource | Description | +| :--- | :--- | +| `dsx-exchange://specs/` | Authorized index of visible Exchange AsyncAPI domains. | +| `dsx-exchange://specs/{domain}` | Authorized AsyncAPI spec or filtered schema view for a single domain. | +| `dsx-exchange://subscriptions/{subscription_id}` | Optional status/read resource for a background watch owned by the current session. | + +Resources returned through the gateway may be prefixed by the gateway target +name in multi-upstream deployments. The upstream server continues to expose the +bare `dsx-exchange://` URIs. + +### 3.1.2 Existing Bounded Tools + +| Tool | Purpose | Key Guardrails | +| :--- | :--- | :--- | +| `dsx_exchange_read_retained` | Read retained messages, especially BMS metadata. | Topic validation, auth passthrough, message cap, byte cap, retained idle timeout. | +| `dsx_exchange_subscribe` | Collect live messages for a bounded window. | Topic validation, auth passthrough, message cap, duration cap, byte cap. | + +These tools remain useful for short investigations and tests. They are not the +long-running watch interface. + +### 3.1.3 Schema And Topic Helper Tools + +| Tool | Purpose | +| :--- | :--- | +| `dsx_exchange_find_topics` | Return authorized schema-derived topic filters for a domain and intent such as BMS metadata, BMS values, NICO state, power breach, or SPIFFE keysets. | +| `dsx_exchange_describe_topic` | Explain a topic or channel: domain, expected payload, value/metadata role, examples, and related topics. | +| `dsx_exchange_bms_metadata_snapshot` | Read retained BMS metadata and return normalized point metadata with cursoring. | +| `dsx_exchange_build_bms_graph` | Build a best-effort relationship graph from authorized BMS metadata, including rack, CDU, process area, served load, and point relationships when present. | + +These helpers reduce the need for agents to invent topic paths. They are built +from AsyncAPI, not hard-coded outside the schema index. + +### 3.1.4 Background Watch Tools + +| Tool | Purpose | +| :--- | :--- | +| `dsx_exchange_start_subscription` | Start a background MQTT watch and return `subscription_id`, status, cursor, TTL, and limits. | +| `dsx_exchange_read_subscription` | Read a bounded raw or normalized message batch by cursor. | +| `dsx_exchange_subscription_status` | Return status, counters, buffer state, TTL, idle expiry, and last error. | +| `dsx_exchange_summarize_subscription` | Summarize what changed over a bounded window. | +| `dsx_exchange_aggregate_subscription` | Return counts, latest values, min/max/avg, or grouping by topic/object type where payload shape supports it. | +| `dsx_exchange_export_subscription` | Export bounded watch data to an approved sink such as Flight Recorder or logs. | +| `dsx_exchange_stop_subscription` | Stop a background watch and release broker subscriptions when no longer referenced. | + +`start_subscription` input includes either an explicit `topic_filter` or a +schema-derived selector such as `domain`, `intent`, `object_type`, `point_type`, +and `scope`. If both are provided, the explicit filter must still pass schema +and ACL checks. + +`read_subscription` output includes: + +```json +{ + "subscription_id": "sub_123", + "status": "running", + "messages": [], + "count": 0, + "next_cursor": "42", + "truncated": false, + "dropped_count": 0, + "buffer_watermark": { + "oldest_cursor": "21", + "newest_cursor": "42" + } +} +``` + +### 3.1.5 Error Contract + +All tool failures return structured MCP tool results with `isError=true` and a +JSON body: + +```json +{ + "error": { + "code": "topic_acl_denied", + "message": "mqtt subscribe denied by broker ACL", + "retryable": false + } +} +``` + +Required error codes include: + +| Code | Meaning | +| :--- | :--- | +| `missing_bearer` | Gateway did not pass caller credentials. | +| `invalid_argument` | Tool input failed validation. | +| `invalid_topic_filter` | MQTT filter syntax is invalid or unsafe. | +| `schema_not_visible` | Requested schema/domain/channel is not authorized for this caller. | +| `topic_acl_denied` | Broker denied subscribe. | +| `mqtt_auth_failed` | Broker/auth-callout rejected credential. | +| `bus_unavailable` | Broker or network path unavailable. | +| `subscription_not_found` | Subscription does not exist in this session. | +| `subscription_expired` | Subscription TTL or idle timeout expired. | +| `subscription_not_owner` | Caller/session does not own the subscription. | +| `buffer_overflow` | Data was dropped or subscription failed due to overflow policy. | +| `reconnect_exhausted` | MQTT reconnect attempts exceeded configured limit. | +| `export_denied` | Export destination or operation is not authorized. | + +## 3.2 Security Design + +### 3.2.1 Authentication + +External authentication is performed by the Latinum MCP Gateway. The gateway +rejects missing, expired, invalid-signature, unknown-issuer, or wrong-audience +tokens before the request reaches `dsx-exchange-mcp`. + +`dsx-exchange-mcp` requires the bearer to be present for all data-bearing tools. +It passes the bearer to MQTT as the password. Broker/auth-callout validates the +credential again and derives MQTT/NATS permissions. + +For production, the SDD requires protected-resource validation to be resolved: +the gateway and upstream must agree which token audience is valid for Exchange +MCP and whether token exchange is required before the same bearer can be used +against MQTT. + +### 3.2.2 Authorization + +Authorization has three layers: + +1. **Gateway coarse MCP authorization.** Controls which MCP tools/resources are + visible and callable. +2. **Exchange MCP fine-grained validation.** Validates arguments, schema + visibility, subscription ownership, export destination, and session binding. +3. **Broker ACL enforcement.** Auth-callout and NATS enforce actual MQTT + subscribe/publish permissions. + +All three layers must allow the request. A broker denial is returned as a +structured MCP error, not hidden as an empty result. + +### 3.2.3 Credential Handling + +Caller bearers are held only in memory for the request or active MQTT connection +lifetime. They are never accepted as tool arguments, persisted, exported, or +logged. TLS trust bundles and other server-side configuration come from +deployment configuration or a central secret store. + +If a background watch depends on a caller bearer that expires, the watch must +end at or before token expiry unless a supported token-refresh or token-exchange +mechanism is added. + +### 3.2.4 Data Exfiltration Controls + +Raw reads are bounded by message count and byte limits. Background watch buffers +are bounded. Export is allowed only to configured sinks and must be separately +authorized and audited. Arbitrary URL/file export is not part of the API. + +## 3.3 Other Design Considerations + +### 3.3.1 High Availability + +The deployment runs multiple replicas with pod anti-affinity. Bounded tools are +stateless per call and can run on any pod. Background watches are stateful per +MCP session and are owned by the session-pinned pod. + +Rolling upgrades should drain where possible: + +* Stop accepting new watch starts on terminating pods. +* Continue serving bounded reads during graceful shutdown if time permits. +* Mark active watches as terminating and emit status notifications where + supported. +* Document that v1 clients must resubscribe after pod/session loss. + +Durable cross-pod recovery is future work. +That future work is required for strict compliance with the inherited +stateless-pod requirement for long-running watches. Until then, the SDD treats +background watches as bounded, ephemeral, session-scoped state. + +### 3.3.2 Scalability + +Scalability controls include: + +* Per-pod maximum active MQTT connections. +* Per-auth-context maximum active MQTT connections. +* Per-pod maximum active logical subscriptions. +* Per-caller/session subscription limits. +* Per-MQTT-client maximum broker subscriptions. +* Per-MQTT-client maximum temporary bounded-call subscriptions. +* Dedicated-client thresholds for high-volume or broad-wildcard watches. +* Per-pod total buffer bytes. +* Probe count and timeout limits. +* Message, duration, and byte caps for bounded tools. +* Ring-buffer message and byte caps for background watches. +* Notification rate limits per session. +* Export byte and duration limits. +* Explicit overflow policies: drop-oldest, drop-newest, aggregate-only, or fail. +* Per-tenant rate limiting at the gateway. + +Connection pooling is allowed only within one pod, one MCP session, and one +effective auth context by default. It must not cross tenants, personas, +subjects, scopes, token expiry buckets, or policy versions. Cross-session +pooling for identical service-account contexts is a future optimization and +requires an explicit policy-version or permissions-hash signal. + +The v1 pod-local approach is scalable for bounded numbers of watches because new +MCP sessions are distributed across pods and each pod owns only the watches +pinned to it. It is not sufficient for thousands of high-volume, hours-long +watches that must survive pod restarts. That scale requires additional +infrastructure: + +| Need | Additional Infrastructure | +| :--- | :--- | +| Cross-pod watch recovery | Valkey / Redis or another KV store for watch metadata, owner lease, status, and cursor state. | +| Shared bounded buffers | Valkey streams/lists, Redis streams, or another capped buffer store with TTL and memory quotas. | +| Durable replay | NATS JetStream durable consumers or Flight Recorder-backed replay instead of pod memory. | +| Cross-pod ownership transfer | Lease/heartbeat protocol in the external store plus idempotent MQTT resubscribe. | +| Large export retention | Flight Recorder or approved observability/log storage, not arbitrary MCP file/URL export. | + +The recommended first implementation remains pod-local because it avoids adding +a new state backend before the watch UX, auth rules, and buffer semantics are +validated. + +### 3.3.3 Logging, Metrics, And Debugging + +Structured audit logs include: + +* MCP session ID. +* Caller tenant, issuer, subject, and SPIFFE ID when available. +* Tool name. +* Schema domain/channel or topic filter. +* Subscription ID where applicable. +* Safe argument summary. +* Decision and error code. +* Message count, byte count, duration, cursor, and stop reason. + +Metrics include: + +* Tool calls by tool, result, and error code. +* Active MCP sessions. +* Active background watches. +* Active MQTT connections. +* Broker subscriptions. +* MQTT connect and subscribe latency. +* ACL probe latency and cache hit rate. +* Messages and bytes received. +* Buffered messages and bytes. +* Dropped messages and overflow count. +* Reconnect count and reconnect exhaustion. +* Export attempts and export denials. + +Debugging starts with the MCP session ID and caller identity, then correlates +gateway access logs, upstream audit logs, broker ACL denials, and auth-callout +logs. + +### 3.3.4 Mock / Stub Mode + +Mock mode is required for partner and agent development. It should provide: + +* Static AsyncAPI resources. +* Synthetic BMS metadata and value streams. +* Synthetic power-management CloudEvents. +* Synthetic NICO state transitions. +* Synthetic SPIFFE keyset messages. +* Configurable ACL profiles for allowed and denied domains. +* Background watch behavior including notifications, cursor reads, summaries, + overflow, expiry, and broker-denial simulation. + +Mock mode must not require live Exchange, Nautobot, BMS, LaunchLayer, or broker +dependencies. + +### 3.3.5 Future Work + +Future work includes: + +* Entitlement/effective-permissions API backed by auth-callout. +* Durable cross-pod watch recovery and replay. +* Rich BMS graph model validated against production metadata. +* Correlated anomaly tools across BMS, NICO, power, logs, and metrics. +* Action-taking CLI/TUI MCP relationship to read-only Gateway MCP. +* Partner reference agent implementation. +* Full protected-resource/token-exchange design for gateway-to-MQTT reuse. +* Certification tooling for AsyncAPI compatibility and MCP tool behavior. + +# 4 Alternatives Considered + +## 4.1 Infinite Tool Call Stream + +An infinite `tools/call` response could subscribe to MQTT and stream all +messages over one SSE response. This is rejected as the primary contract because +it ties up one request forever, makes backpressure client-specific, complicates +reconnect and audit behavior, and fails for clients that do not expose +notifications or streaming well. + +## 4.2 Bounded Reads Only + +Bounded reads are simple and safe, but they do not satisfy the UX requirement +to watch factory signals in the background while the user asks follow-up +questions. Bounded reads remain supported but are not sufficient. + +## 4.3 Background Watch With Cursor Reads + +This is the selected v1 design. The server owns the MQTT subscription in the +background, returns a `subscription_id`, stores bounded buffered data, and lets +clients read, summarize, aggregate, export, and stop the watch. Optional SSE +notifications improve latency without becoming the only data path. + +## 4.4 Shared Durable State In V1 + +Shared durable state would allow cross-pod recovery and replay, but it adds a +state store, ownership protocol, cursor persistence, and data retention policy +before the core UX is validated. It is future work unless production +requirements promote durable replay to v1. + +## 4.5 Broker Probes vs Entitlement API + +Broker probes are accurate for exact filters because they use the final +enforcement point, but they are expensive and weak for partial schema +visibility. A read-only entitlement API backed by auth-callout is preferred for +production schema filtering, while broker SUBACK remains final for data access. + +# 5 Operations + +## 5.1 Deployment + +`dsx-exchange-mcp` is deployed as a Kubernetes Deployment and Service behind +the Latinum MCP Gateway. Production deployments use: + +* Runtime isolation compatible with DSX security posture. +* Non-root user, read-only root filesystem, seccomp runtime default, and dropped + Linux capabilities. +* Liveness and readiness probes. +* Prometheus metrics endpoint. +* PodDisruptionBudget and pod anti-affinity. +* NetworkPolicy allowing only required gateway, broker, entitlement, and + observability egress paths. + +## 5.2 Runtime Configuration + +Runtime configuration includes: + +* MCP listen address. +* Broker URL. +* MQTT OAuth username. +* Broker TLS trust configuration. +* Tool message, duration, and byte caps. +* Background watch TTL, idle timeout, buffer caps, and overflow policy. +* MQTT client idle TTL, max lifetime, reconnect budget, and token-expiry safety + margin. +* MQTT pooling mode: short-lived bounded-call clients, session/auth-context + pooled clients, or dedicated clients for noisy watches. +* Per-session, per-auth-context, per-MQTT-client, and per-pod limits. +* Probe timeouts, probe cache TTL, and max probes. +* Optional entitlement API endpoint. +* Optional external watch state backend for durable or cross-pod operation. +* Optional approved export sinks. +* Metrics and log configuration. + +Caller credentials are not configuration. + +## 5.3 Failure Modes + +| Failure | Behavior | +| :--- | :--- | +| Missing bearer | Reject tool call with `missing_bearer`. | +| Invalid gateway token | Gateway rejects before upstream. | +| MQTT auth failure | Return `mqtt_auth_failed`; do not retry indefinitely. | +| Topic ACL denial | Return `topic_acl_denied`; do not treat as empty data. | +| Broker unavailable | Return `bus_unavailable` for bounded calls; background watches enter reconnecting then failed if attempts exhaust. | +| Entitlement unavailable | Fail closed for schema visibility or return degraded discovery without exposing unauthorized schema. | +| Buffer overflow | Apply configured overflow policy and expose status/metrics/audit. | +| Pod restart | v1 active watches are lost; clients resubscribe. | +| MQTT client idle | Close after refcount reaches zero and idle TTL expires. | +| Token expiry | End affected MQTT connections and watches unless supported refresh exists. | +| Policy version change or revocation | Close affected MQTT clients and expire dependent watches. | + +## 5.4 Acceptance Criteria + +The SDD design is complete when it can be reviewed against these outcomes: + +* A caller can discover only authorized Exchange resources/tools. +* A caller can read BMS metadata and subscribe to live values through bounded + tools using the original bearer as the MQTT password. +* Unauthorized topics produce broker-backed structured ACL errors. +* AsyncAPI specs drive topic discovery for BMS, power-management, NICO, and + SPIFFE Exchange. +* A caller can start a background watch, receive `subscription_id`, read by + cursor, ask for status, summarize/aggregate, optionally receive notifications, + export to an approved sink, and stop the watch. +* Background watches are pinned to an upstream pod by MCP session routing. +* MQTT clients are not shared across authorization boundaries. +* Audit logs and metrics cover bounded tools, schema visibility, background + watches, errors, and exports. diff --git a/mcp/dsx-exchange-mcp/docs/local-llm-mcp-eval.md b/mcp/dsx-exchange-mcp/docs/local-llm-mcp-eval.md new file mode 100644 index 0000000..25ccc46 --- /dev/null +++ b/mcp/dsx-exchange-mcp/docs/local-llm-mcp-eval.md @@ -0,0 +1,109 @@ +# Local LLM MCP Prompt Eval + +This eval checks whether a local LLM can read an operator prompt, use the +`dsx-exchange-mcp` MCP tools, and produce the expected tool plan from +`internal/server/testdata/tool_call_expectations.json`. + +It is intentionally opt-in because it depends on a local model runtime and can +be nondeterministic. + +## What It Proves + +- The MCP endpoint is reachable and completes the Streamable HTTP initialize + flow. +- `tools/list` exposes the DSX Exchange tools, either directly or with the + Latinum MCP Gateway prefix. +- The LLM actually emits MCP tool calls. +- The harness executes those tool calls and logs the tool trace. +- The LLM's final `planned_tool_calls` JSON contains the expected fixture calls. + +By default, only `dsx_exchange_describe_topic` is exposed to the LLM. The model +must still include the planned `dsx_exchange_read_retained` and +`dsx_exchange_subscribe` calls in its final JSON, but the harness does not hit +the live broker unless explicitly enabled. + +## LLM Endpoint + +The harness expects an OpenAI-compatible local chat completions endpoint: + +```sh +export DSX_EXCHANGE_LLM_BASE_URL=http://127.0.0.1:11434/v1 +export DSX_EXCHANGE_LLM_MODEL='' +``` + +Set `DSX_EXCHANGE_LLM_API_KEY` only if your local endpoint requires one. + +## Direct In-Process MCP Server + +If `DSX_EXCHANGE_MCP_URL` is not set, the test starts an in-process +`dsx-exchange-mcp` server with a test bearer and calls it directly. + +```sh +RUN_EXCHANGE_LLM_MCP_EVAL=1 \ +DSX_EXCHANGE_LLM_BASE_URL=http://127.0.0.1:11434/v1 \ +DSX_EXCHANGE_LLM_MODEL='' \ +go test ./internal/server -run TestLocalLLMMCPPromptEval -count=1 -v +``` + +This path is best for fast local iteration on schema discovery behavior. + +## Through Latinum MCP Gateway + +To evaluate the production-style path, run or port-forward the gateway from the +`dsx-mcp` repo, then point this harness at the gateway `/mcp` endpoint: + +```sh +export DSX_EXCHANGE_MCP_URL=http://localhost:18180/mcp +export DSX_EXCHANGE_E2E_BEARER="$TOKEN" + +RUN_EXCHANGE_LLM_MCP_EVAL=1 \ +DSX_EXCHANGE_LLM_BASE_URL=http://127.0.0.1:11434/v1 \ +DSX_EXCHANGE_LLM_MODEL='' \ +go test ./internal/server -run TestLocalLLMMCPPromptEval -count=1 -v +``` + +The gateway may expose prefixed tools such as +`dsx-exchange-mcp-mcp_dsx_exchange_describe_topic`. The harness passes those +actual names to the LLM, executes them as returned, and normalizes them back to +canonical names when comparing with the fixture. + +## Selecting Cases + +By default the harness runs only the first fixture to keep local iteration fast. +Run one or more named cases with: + +```sh +DSX_EXCHANGE_LLM_EVAL_CASES=bms-rack-temperature-latest,nico-machine-state +``` + +Run all cases by listing all fixture IDs from +`internal/server/testdata/tool_call_expectations.json`. + +## Live Broker Tool Execution + +Leave this off for normal prompt-planning evals: + +```sh +export DSX_EXCHANGE_LLM_EXECUTE_LIVE_TOOLS=1 +``` + +When enabled, the LLM can execute `dsx_exchange_read_retained` and +`dsx_exchange_subscribe` too. Use it only when the MCP endpoint has a valid +broker configuration, bearer token, topic permissions, and bounded test topics. + +## Reading The Output + +Run with `-v`. Each fixture logs: + +- the natural-language question +- the MCP tool trace the LLM actually emitted +- the model's final user-facing answer +- the final planned tool calls compared against the fixture + +Failures are useful evidence. They usually mean one of: + +- the model did not call tools +- the model chose overly broad or wrong topic filters +- the schema description did not provide enough signal +- the final JSON was not machine-parseable +- the gateway or local MCP endpoint was not reachable diff --git a/mcp/dsx-exchange-mcp/docs/long-running-subscriptions-ux.md b/mcp/dsx-exchange-mcp/docs/long-running-subscriptions-ux.md new file mode 100644 index 0000000..53b676a --- /dev/null +++ b/mcp/dsx-exchange-mcp/docs/long-running-subscriptions-ux.md @@ -0,0 +1,132 @@ +# Long-Running Subscription UX User Stories + +This note captures the end-user experience expected from long-running DSX +Exchange MQTT subscriptions exposed through MCP. It should be considered as an +input to the DSX Exchange MCP SDD. + +## UX Model + +End users should not interact with raw MQTT clients or unbounded protocol +streams directly. They should ask an agent to watch a factory signal, and the +agent should use MCP tools to create, inspect, summarize, export, and stop a +managed background subscription. + +The baseline flow is: + +```text +User request + -> agent starts a subscription through MCP + -> dsx-exchange-mcp keeps the MQTT subscription active in the background + -> server buffers matching messages under a subscription_id + -> user asks for status, summaries, aggregations, raw batches, or export + -> agent stops the subscription, or TTL/idle expiry cleans it up +``` + +Streamable HTTP and SSE notifications may improve the live experience, but they +should not be the only way to consume data. Notifications should be lightweight +signals that new data or a state change is available. The reliable contract is +cursor-based reads and server-side summaries over bounded buffers. + +## User Stories + +| ID | Priority | User | I want... | So that... | +| --- | --- | --- | --- | --- | +| LSUB-1 | P0 | Agent Developer | start a long-running Exchange subscription and receive a `subscription_id` immediately | my agent can monitor live factory signals without holding one tool call open forever | +| LSUB-2 | P0 | AI Factory Operator | ask an agent to watch a topic or domain in natural language | I do not need to know raw MQTT topic hierarchy or keep a terminal open | +| LSUB-3 | P0 | Agent Developer | read buffered messages by cursor with bounded `max_messages` and `max_bytes` limits | my agent can safely process live events without memory spikes or timeouts | +| LSUB-4 | P0 | AI Factory Operator | ask "what happened since I started watching?" | I can get a concise operational summary instead of a raw event dump | +| LSUB-5 | P0 | Site Reliability Engineer | query subscription status such as running, reconnecting, expired, denied, or buffer_overflow | I can understand whether silence means no events or a broken watch | +| LSUB-6 | P1 | Agent Developer | receive optional MCP/SSE notifications when new messages arrive or status changes | clients that support live updates can react quickly without polling aggressively | +| LSUB-7 | P1 | AI Factory Operator | ask for aggregations over the background stream, such as counts, latest values, min/max/avg, or grouping by topic/object type | I can reason about trends and thresholds without pulling every raw message into the model | +| LSUB-8 | P1 | Site Reliability Engineer | dump a bounded raw batch of subscription messages in JSON/JSONL | I can inspect exact event payloads during debugging | +| LSUB-9 | P1 | AI Factory Operator | export a subscription to an approved observability sink such as Flight Recorder or logs | incident evidence can be retained without exposing arbitrary exfiltration paths | +| LSUB-10 | P0 | Security Reviewer | have every subscription start, read, aggregation, export, and stop audited with caller identity and arguments | I can reconstruct agent behavior during incidents | +| LSUB-11 | P0 | AI Factory Operator | stop a subscription explicitly or let it expire by TTL/idle timeout | background watches do not run forever by accident | +| LSUB-12 | P1 | Agent Developer | receive structured errors for ACL denial, authentication failure, reconnect exhaustion, buffer overflow, and expired subscriptions | my agent can recover or explain the failure to the operator | + +## Expected User Interactions + +Examples of natural-language requests: + +```text +Watch BMS leak events for row 3 and tell me if anything changes. +Summarize rack power changes from the last 10 minutes. +Show the latest value per CDU from this watch. +Count NICO state transitions by state since I started watching. +Dump the raw events for the last 5 minutes. +Export this watch to Flight Recorder for one hour. +Stop watching rack leak events. +``` + +The agent translates these requests into MCP tool calls such as: + +```text +dsx_exchange_start_subscription(...) +dsx_exchange_read_subscription(...) +dsx_exchange_subscription_status(...) +dsx_exchange_summarize_subscription(...) +dsx_exchange_aggregate_subscription(...) +dsx_exchange_export_subscription(...) +dsx_exchange_stop_subscription(...) +``` + +## Notification Behavior + +When supported by the MCP client, `dsx-exchange-mcp` can emit lightweight +server-to-client notifications over Streamable HTTP/SSE. Notifications should +announce availability or state, not carry unbounded payloads. + +Example notification payload: + +```json +{ + "subscription_id": "sub_123", + "event": "messages_available", + "count": 17, + "severity": "warning", + "summary": "Rack leak event observed for rack R12" +} +``` + +Clients that do not expose notifications should still work by polling +`status_subscription` and `read_subscription`. + +## Data Access Modes + +Long-running subscription UX should support three data access modes: + +| Mode | Purpose | Guardrails | +| --- | --- | --- | +| Notifications | Tell the client that new data or a status change exists | lightweight only; no large payloads | +| Cursor reads | Retrieve bounded raw or normalized message batches | required cursor, message cap, byte cap | +| Summaries and aggregations | Answer operational questions without dumping every message to the model | bounded windows, explicit groupings, sample examples | + +Export is a fourth mode, but only to approved sinks. It should be separately +authorized and audited because unrestricted raw export can become a data +exfiltration path. + +## SDD Implications + +The SDD should describe long-running subscriptions as a managed lifecycle, not +as an infinite `tools/call` response. + +Key design implications: + +- `start_subscription` returns quickly with a `subscription_id`, status, cursor, + and TTL. +- Active MQTT subscriptions are owned by the session-pinned `dsx-exchange-mcp` + pod. +- `Mcp-Session-Id` and agentgateway stateful routing keep follow-up reads and + status calls on the owning pod. +- The server stores messages in bounded per-subscription buffers with explicit + overflow policy. +- Optional SSE/MCP notifications are an acceleration path; cursor reads are the + reliable baseline. +- Server-side aggregation and summarization tools should be first-class for + high-volume streams. +- Raw dumps must be bounded. Long-running export should target approved sinks + only. +- v1 can treat pod restart or session loss as subscription loss requiring + resubscription. Durable cross-pod replay is a future requirement unless + explicitly added to v1 scope. + diff --git a/mcp/dsx-exchange-mcp/docs/mcp-schema-prompt-eval-results.csv b/mcp/dsx-exchange-mcp/docs/mcp-schema-prompt-eval-results.csv new file mode 100644 index 0000000..e4da151 --- /dev/null +++ b/mcp/dsx-exchange-mcp/docs/mcp-schema-prompt-eval-results.csv @@ -0,0 +1,8 @@ +"eval_date","eval_scope","prompt_id","prompt","expected_describe_calls","expected_runtime_plan","expected_schema","actual_mcp_describe_trace","actual_schema_result","result","prompt_level_analysis","llm_prompt_eval_status" +"2026-06-01","gateway_mcp_schema_eval","bms-rack-temperature-latest","Grab me all of the most recent rack temperature data.","dsx_exchange_describe_topic(BMS/v1/PUB/Metadata/Rack/RackLiquidSupplyTemperature/#); dsx_exchange_describe_topic(BMS/v1/PUB/Metadata/Rack/RackLiquidReturnTemperature/#)","read_retained metadata for supply and return temperature; subscribe to BMS/v1/PUB/Value/Rack/RackLiquidSupplyTemperature/# and BMS/v1/PUB/Value/Rack/RackLiquidReturnTemperature/#","bms/rackMetadata; related value topics BMS/v1/PUB/Value/Rack/RackLiquidSupplyTemperature/# and BMS/v1/PUB/Value/Rack/RackLiquidReturnTemperature/#","describe supply metadata -> count=1 first=bms/rackMetadata; describe return metadata -> count=1 first=bms/rackMetadata","expected channels found through http://localhost:18180/mcp using dsx_exchange_describe_topic","PASS","Good schema signal for the prompt. The expected plan is metadata-first because the user asked for most recent rack temperature data, so retained metadata should be read before sampling live value topics. Current fixture only treats RackLiquidSupplyTemperature and RackLiquidReturnTemperature as rack temperature data; if air-side rack temperature points are later added, this prompt may need broader topic discovery.","NOT_RUN_NO_LOCAL_LLM_ENDPOINT" +"2026-06-01","gateway_mcp_schema_eval","bms-rack-liquid-isolation-status","Show me rack liquid isolation status updates.","dsx_exchange_describe_topic(BMS/v1/PUB/Value/Rack/RackLiquidIsolationStatus/#)","read_retained BMS/v1/PUB/Metadata/Rack/RackLiquidIsolationStatus/#; subscribe to BMS/v1/PUB/Value/Rack/RackLiquidIsolationStatus/#","bms/rackBmsValue; related metadata topic BMS/v1/PUB/Metadata/Rack/RackLiquidIsolationStatus/#","describe value topic -> count=2 first=bms/rackBmsValue","expected channel found; extra overlapping BMS integration-value channel is also matched","PASS_WITH_EXTRA_MATCH","The prompt maps to the correct live BMS value topic and related metadata topic. The extra match is caused by the generic integration channel BMS/v1/{integration}/Value/Rack/{pointType}/{tagPath} overlapping PUB; the schema matcher does not currently enforce parameter enum constraints, so an LLM may need ranking or filtering to prefer rackBmsValue for PUB-owned telemetry.","NOT_RUN_NO_LOCAL_LLM_ENDPOINT" +"2026-06-01","gateway_mcp_schema_eval","bms-rack-power","What topic should I use for rack power telemetry?","dsx_exchange_describe_topic(BMS/v1/PUB/Value/Rack/RackPower/#)","read_retained BMS/v1/PUB/Metadata/Rack/RackPower/#; subscribe to BMS/v1/PUB/Value/Rack/RackPower/#","bms/rackBmsValue; related metadata topic BMS/v1/PUB/Metadata/Rack/RackPower/#","describe value topic -> count=2 first=bms/rackBmsValue","expected channel found; extra overlapping BMS integration-value channel is also matched","PASS_WITH_EXTRA_MATCH","The schema tool gives enough signal to answer with the correct rack power value topic and metadata companion. Same caveat as liquid isolation status: the overlapping integration-value channel should be deprioritized or eliminated by enum-aware matching so the model does not over-explain irrelevant integration topics.","NOT_RUN_NO_LOCAL_LLM_ENDPOINT" +"2026-06-01","gateway_mcp_schema_eval","power-breach-alerts","Listen for power breach alerts from power agents.","dsx_exchange_describe_topic(grid/v1/poweragent/+/powerbreach)","subscribe to grid/v1/poweragent/+/powerbreach","power-management/powerBreachAlertChannel","describe topic -> count=1 first=power-management/powerBreachAlertChannel","expected channel found through http://localhost:18180/mcp using dsx_exchange_describe_topic","PASS","Clean mapping. The prompt is naturally a live subscription request, and there is no retained metadata companion in the current AsyncAPI schema. Expected behavior is describe first, then bounded subscribe.","NOT_RUN_NO_LOCAL_LLM_ENDPOINT" +"2026-06-01","gateway_mcp_schema_eval","power-state-status","Find current power state status events.","dsx_exchange_describe_topic(grid/v1/poweragent/+/powerstate/status)","subscribe to grid/v1/poweragent/+/powerstate/status","power-management/powerStateStatusChannel","describe topic -> count=1 first=power-management/powerStateStatusChannel","expected channel found through http://localhost:18180/mcp using dsx_exchange_describe_topic","PASS","Clean mapping for power-agent status events. The word current could tempt a retained read, but the current schema fixture expects this as an event subscription because no retained metadata/value split is modeled for power-management.","NOT_RUN_NO_LOCAL_LLM_ENDPOINT" +"2026-06-01","gateway_mcp_schema_eval","power-enforcement-outcomes","Which topic has infrastructure enforcement outcomes for power breaches?","dsx_exchange_describe_topic(grid/v1/infra/+/powerbreach/enforcement)","subscribe to grid/v1/infra/+/powerbreach/enforcement","power-management/powerBreachEnforcementChannel","describe topic -> count=1 first=power-management/powerBreachEnforcementChannel","expected channel found through http://localhost:18180/mcp using dsx_exchange_describe_topic","PASS","Clean mapping. This is more of a topic discovery question than a data retrieval request, but the expected plan still includes a bounded subscribe if the client wants live enforcement outcomes.","NOT_RUN_NO_LOCAL_LLM_ENDPOINT" +"2026-06-01","gateway_mcp_schema_eval","nico-machine-state","Subscribe to NICO machine state changes.","dsx_exchange_describe_topic(NICO/v1/machine/+/state)","subscribe to NICO/v1/machine/+/state","nico/managedHostState","describe topic -> count=1 first=nico/managedHostState","expected schema channel found; separate runtime subscribe should stop after broker ACL denial","PASS_RUNTIME_ACL_GATING_REQUIRED","Schema lookup quality is good and schema discovery does not need to be ACL-blocked. The required intelligence is runtime adaptive planning: if read_retained or subscribe returns an ACL/authorization denial for NICO, the MCP client or server should mark that namespace/domain as unavailable for this caller/session and avoid repeatedly pursuing NICO subscriptions unless the user explicitly asks to retry or changes credentials.","NOT_RUN_NO_LOCAL_LLM_ENDPOINT" diff --git a/mcp/dsx-exchange-mcp/docs/mcp-tasks-vs-explicit-async-tools.md b/mcp/dsx-exchange-mcp/docs/mcp-tasks-vs-explicit-async-tools.md new file mode 100644 index 0000000..3bade22 --- /dev/null +++ b/mcp/dsx-exchange-mcp/docs/mcp-tasks-vs-explicit-async-tools.md @@ -0,0 +1,147 @@ +# MCP Tasks vs Explicit Async Tools + +This note compares two ways for `dsx-exchange-mcp` to expose long-running MQTT +subscriptions through MCP. + +## Summary + +`dsx-exchange-mcp` can provide asynchronous subscription behavior even without +native MCP Tasks by exposing ordinary tools that create, inspect, fetch, and +cancel background work. Native MCP Tasks move that same lifecycle into the MCP +protocol so the client or host can understand and manage it generically. + +The backend distributed-systems requirements are mostly the same in both cases: +Valkey-backed task state, worker lease/heartbeat, bounded result buffers, +cancellation, expiry, no raw JWT persistence, and clear failover semantics. + +## Explicit Async Tools + +In this model, async behavior is represented as normal MCP tools. + +Example tool surface: + +| Tool | Purpose | +| --- | --- | +| `dsx_exchange_start_subscription` | Starts a background MQTT watch and returns immediately with a task/subscription ID. | +| `dsx_exchange_task_status` | Reads task state, heartbeat, counters, expiry, and error information. | +| `dsx_exchange_task_result` | Reads buffered or final subscription results. | +| `dsx_exchange_cancel_task` | Requests cooperative cancellation. | +| `dsx_exchange_list_tasks` | Lists recent tasks visible to the caller. | + +Typical flow: + +```text +MCP client + -> tools/call dsx_exchange_start_subscription(...) + <- { "task_id": "watch_123", "status": "working", "poll_interval_s": 5 } + +MCP client + -> tools/call dsx_exchange_task_status({ "task_id": "watch_123" }) + <- { "status": "working", "message_count": 42 } + +MCP client + -> tools/call dsx_exchange_task_result({ "task_id": "watch_123" }) + <- { "status": "completed", "messages": [...] } +``` + +This is still asynchronous because the initial tool call does not hold the HTTP +request open for the full MQTT subscription lifetime. It returns a handle, while +the server continues work in the background. + +The downside is that the async contract lives in tool descriptions and agent +behavior. The agent must remember the task ID, decide when to poll, choose when +to fetch results, and call the correct cancellation tool. + +## Native MCP Tasks + +Native MCP Tasks represent async work at the protocol level. + +Expected flow: + +```text +MCP client + -> tools/call dsx_exchange_watch(...) with task support + <- CreateTaskResult / task_id + +MCP host or client + -> tasks/get(task_id) + <- task status/progress + +MCP host or client + -> tasks/result(task_id) + <- final CallToolResult + +MCP host or client + -> tasks/cancel(task_id) + <- cancellation acknowledgement +``` + +Native Tasks let the server advertise task capability and allow the MCP host to +own the async loop instead of relying on the model to learn a custom tool +workflow. + +## Added Benefit of Native MCP Tasks + +| Benefit | Why it matters | +| --- | --- | +| Host-owned polling | The MCP client/host can poll `tasks/get` without relying on the LLM to remember to call a status tool. | +| Protocol-owned task ID | The task ID is part of MCP state, not just text in a tool result. | +| Standard status UX | Clients can show running, completed, failed, cancelled, progress, and result availability consistently. | +| Deferred result retrieval | `tasks/result` provides a standard path to fetch the final tool result later. | +| Capability negotiation | The server can advertise which tools support task mode. | +| Tool execution semantics | A tool can declare task behavior such as required, optional, or forbidden. | +| Standard cancellation | `tasks/cancel` avoids a custom cancel tool per server. | +| Better reconnect behavior | A client can reconnect and continue polling a known task ID using protocol semantics. | +| Less prompt engineering | Tool descriptions do not need to teach every client the start/status/result/cancel loop. | +| Future interoperability | Gateways, dashboards, traces, and agent runtimes can understand task lifecycle generically. | + +## What Native Tasks Do Not Solve + +Native MCP Tasks improve the protocol and client UX. They do not eliminate the +backend work needed for long-running DSX Exchange subscriptions. + +Both approaches still require: + +- Valkey or equivalent durable task metadata and bounded result storage. +- Worker lease and heartbeat records. +- MQTT client lifecycle management. +- Cancellation checks and broker disconnect. +- Task TTL and result retention. +- Caller access checks for task status and result reads. +- A policy for token expiry while a watch is running. +- No raw JWT persistence in task state. +- Explicit failover semantics; pod failure may still create a subscription gap. + +## SDK Implications + +As of this note, the Go MCP SDK does not expose native Tasks APIs. The Python SDK +has experimental Tasks support, but using it would not remove the state, +failover, and JWT lifecycle work above. + +For a conservative Go v1, explicit async tools are the lower-churn path. The +internal task model should still align with MCP Tasks terminology (`working`, +`completed`, `failed`, `cancelled`, expiry, result retrieval, cancellation) so +the server can migrate to native MCP Tasks when Go SDK support and client +support are ready. + +## Recommendation + +For v1: + +1. Keep bounded synchronous tools for simple reads and short live samples. +2. Add explicit async tools for long-running MQTT watches. +3. Store task state/results in Valkey for cross-pod visibility and recovery + metadata. +4. Do not store raw JWTs; use the current caller bearer only when starting or + resuming an MQTT client. +5. Document best-effort failover: a later authenticated request can resume a + watch, but events may be missed during pod outage. +6. Track native MCP Tasks as a future API-layer migration once the Go SDK and + target MCP clients support it. + +References: + +- MCP Tasks specification: https://modelcontextprotocol.io/specification/2025-11-25/basic/utilities/tasks +- MCP Tasks overview: https://modelcontextprotocol.io/extensions/tasks/overview +- Go SDK Tasks issue: https://github.com/modelcontextprotocol/go-sdk/issues/626 +- Python SDK Tasks issue: https://github.com/modelcontextprotocol/python-sdk/issues/1546 diff --git a/mcp/dsx-exchange-mcp/docs/schema-tool-question-bank.md b/mcp/dsx-exchange-mcp/docs/schema-tool-question-bank.md new file mode 100644 index 0000000..c00f98d --- /dev/null +++ b/mcp/dsx-exchange-mcp/docs/schema-tool-question-bank.md @@ -0,0 +1,74 @@ +# Schema Tool Question Bank + +This question bank documents expected tool-call plans for natural-language +requests that should be answered from the embedded AsyncAPI schema catalogue. +The executable copy lives in +`internal/server/testdata/tool_call_expectations.json`. + +These examples validate schema-driven planning only. Runtime tests execute +`dsx_exchange_describe_topic` and validate MQTT filter syntax, but they do not +connect to the live broker for `read_retained` or `subscribe`. + +## BMS + +### Grab me all of the most recent rack temperature data. + +Expected flow: + +1. `dsx_exchange_describe_topic({"topic_filter":"BMS/v1/PUB/Metadata/Rack/RackLiquidSupplyTemperature/#"})` +2. `dsx_exchange_describe_topic({"topic_filter":"BMS/v1/PUB/Metadata/Rack/RackLiquidReturnTemperature/#"})` +3. `dsx_exchange_read_retained({"topic_filter":"BMS/v1/PUB/Metadata/Rack/RackLiquidSupplyTemperature/#","max_messages":1000})` +4. `dsx_exchange_read_retained({"topic_filter":"BMS/v1/PUB/Metadata/Rack/RackLiquidReturnTemperature/#","max_messages":1000})` +5. `dsx_exchange_subscribe({"topic_filter":"BMS/v1/PUB/Value/Rack/RackLiquidSupplyTemperature/#","max_messages":100,"max_duration_s":30})` +6. `dsx_exchange_subscribe({"topic_filter":"BMS/v1/PUB/Value/Rack/RackLiquidReturnTemperature/#","max_messages":100,"max_duration_s":30})` + +Rationale: metadata is retained and gives point identity/units/relationships; +value topics are live and separate supply/return point types. + +### Show me rack liquid isolation status updates. + +Expected flow: + +1. `dsx_exchange_describe_topic({"topic_filter":"BMS/v1/PUB/Value/Rack/RackLiquidIsolationStatus/#"})` +2. `dsx_exchange_read_retained({"topic_filter":"BMS/v1/PUB/Metadata/Rack/RackLiquidIsolationStatus/#","max_messages":1000})` +3. `dsx_exchange_subscribe({"topic_filter":"BMS/v1/PUB/Value/Rack/RackLiquidIsolationStatus/#","max_messages":100,"max_duration_s":30})` + +### What topic should I use for rack power telemetry? + +Expected flow: + +1. `dsx_exchange_describe_topic({"topic_filter":"BMS/v1/PUB/Value/Rack/RackPower/#"})` +2. `dsx_exchange_read_retained({"topic_filter":"BMS/v1/PUB/Metadata/Rack/RackPower/#","max_messages":1000})` +3. `dsx_exchange_subscribe({"topic_filter":"BMS/v1/PUB/Value/Rack/RackPower/#","max_messages":100,"max_duration_s":30})` + +## Power Management + +### Listen for power breach alerts from power agents. + +Expected flow: + +1. `dsx_exchange_describe_topic({"topic_filter":"grid/v1/poweragent/+/powerbreach"})` +2. `dsx_exchange_subscribe({"topic_filter":"grid/v1/poweragent/+/powerbreach","max_messages":100,"max_duration_s":30})` + +### Find current power state status events. + +Expected flow: + +1. `dsx_exchange_describe_topic({"topic_filter":"grid/v1/poweragent/+/powerstate/status"})` +2. `dsx_exchange_subscribe({"topic_filter":"grid/v1/poweragent/+/powerstate/status","max_messages":100,"max_duration_s":30})` + +### Which topic has infrastructure enforcement outcomes for power breaches? + +Expected flow: + +1. `dsx_exchange_describe_topic({"topic_filter":"grid/v1/infra/+/powerbreach/enforcement"})` +2. `dsx_exchange_subscribe({"topic_filter":"grid/v1/infra/+/powerbreach/enforcement","max_messages":100,"max_duration_s":30})` + +## NICO + +### Subscribe to NICO machine state changes. + +Expected flow: + +1. `dsx_exchange_describe_topic({"topic_filter":"NICO/v1/machine/+/state"})` +2. `dsx_exchange_subscribe({"topic_filter":"NICO/v1/machine/+/state","max_messages":100,"max_duration_s":30})` diff --git a/mcp/dsx-exchange-mcp/docs/sdd-discussion-notes.md b/mcp/dsx-exchange-mcp/docs/sdd-discussion-notes.md new file mode 100644 index 0000000..151fcc3 --- /dev/null +++ b/mcp/dsx-exchange-mcp/docs/sdd-discussion-notes.md @@ -0,0 +1,549 @@ +# DSX Exchange MCP SDD Discussion Notes + +This note captures future SDD topics for `dsx-exchange-mcp`. The near-term +assumption is that the MCP server supplements the existing DSX Event Bus and +does not change NATS, MQTT, broker auth, or auth-callout behavior. + +## Current Constraint + +The current MCP server can only learn effective MQTT access by attempting MQTT +operations with the caller bearer. It does not have a supported API to ask +auth-callout for the caller's effective NATS permissions before connecting. + +Broker enforcement remains authoritative: + +1. The MCP gateway validates the caller and forwards the bearer. +2. `dsx-exchange-mcp` presents that bearer as the MQTT password. +3. NATS/auth-callout validates the bearer and returns NATS user permissions. +4. The broker enforces subscribe and publish ACLs on the MQTT connection. + +## What Can Be Done With Only The MCP Server + +### ACL Probe By MQTT Connect + +For discovery or preflight, the MCP server can open a short-lived MQTT +connection with the caller bearer and attempt a bounded subscribe against one +or more candidate filters. + +Example uses: + +- Check whether `BMS/v1/PUB/Metadata/#` is readable before showing the BMS + schema resource. +- Check whether `NICO/v1/#` is denied and avoid exposing NICO-specific tools or + resources to that caller. +- Validate a user-supplied `topic_filter` before spending a longer collection + window on it. + +This is accurate because the broker is the final enforcement point. It is also +expensive and should be bounded. + +Recommended limits: + +- short connect timeout +- short subscribe timeout +- max probe count per request +- no payload return for pure authorization probes +- cache result for a short TTL no longer than token expiry +- fail closed when probe auth or connectivity is ambiguous + +### Schema Filtering By Probe + +Until an entitlement API exists, schema/resource filtering can be approximated +by probing canonical topic filters for each schema domain. + +Example mapping: + +| Schema | Probe Filter | +| --- | --- | +| `bms` | `BMS/v1/PUB/Metadata/#` | +| `nico` | `NICO/v1/#` | +| `power-management` | approved power-management metadata/event prefix | + +If the probe succeeds, expose that schema resource. If it fails with ACL denial, +hide the schema. If it fails due to broker/network availability, fail closed or +return a degraded discovery error rather than exposing everything. + +This is a tactical POC approach, not the preferred production design. + +## MQTT Client Reuse + +MQTT connections are authenticated at connect time. A connection carries the +effective broker identity and ACLs produced by auth-callout for that caller. +Reusing a connection across callers can leak broader permissions. + +Safe rule: + +> Do not share MQTT clients across distinct effective caller identities or ACL +> sets. + +For the current MCP server, the safest model is one short-lived MQTT client per +tool call. That is simple, stateless, and avoids cross-session privilege bleed. + +If connection reuse becomes necessary for scale, pool only by a key that +captures the effective broker authorization context: + +- issuer +- subject +- authorized party / service id +- tenant or persona +- broker account +- token scopes +- token expiry +- policy version or permissions hash, if available + +Without a policy version or entitlement hash, pooling should be conservative: +per caller token or per exact identity with short TTL, never cross-tenant and +never cross-persona. + +## Scalability Considerations + +One MQTT connection per tool call is acceptable for a bounded POC, but it has +costs: + +- connection setup latency on every tool call +- TLS handshake cost +- broker auth-callout load for repeated connects +- broker connection churn +- higher tail latency when agents call multiple filters in sequence + +Mitigations that do not require event-bus changes: + +- cache ACL probe results for a short TTL +- cap concurrent MQTT connections per pod +- cap concurrent probes per caller/session +- prefer broad-but-approved discovery probes over many narrow probes +- keep subscribe/read tools bounded by messages, duration, and result bytes +- expose metrics for active MQTT connections, connect failures, subscribe ACL + failures, probe cache hits, and per-caller throttling + +If sustained long-running subscriptions are required, they should be designed +separately. The default MCP tool model should remain bounded request/response. + +## Future SDD Topic: Entitlement API + +A production design should discuss adding a read-only entitlement or +authorization-check API to auth-callout, or to a sibling policy service that +uses the same permission manager. + +Possible API shapes: + +- `POST /v1/authorize` for exact checks such as `mqtt.subscribe` on a topic + filter. +- `GET /v1/mcp-entitlements` for schema/tool discovery filtering. + +The API must not become a second source of truth. It should expose the same +effective decision that auth-callout would apply during MQTT connection setup, +while the broker remains the final enforcement point. + +Open approval questions for the SDD: + +- Is auth-callout allowed to expose effective permissions to MCP backends? +- Should tenant callers receive raw topic allowlists or only coarse schema + capabilities? +- What identity should the MCP server use when calling the entitlement API? +- What are the cache TTL and invalidation rules when permissions hot-reload? +- Should entitlement failures fail closed for all personas? +- Does connection pooling require an explicit policy version or ACL hash? + +## Future SDD Topic: Long-Lived MQTT Subscriptions + +The SDD should distinguish bounded MCP collection tools from long-lived MQTT +subscriptions. A normal `tools/call` should not be treated as an unbounded raw +MQTT stream. Streamable HTTP can carry SSE-framed responses and notifications, +but long-running firehose-style tool calls create poor UX and scaling pressure. + +Recommended v1 posture: + +- Keep existing read/subscribe tools bounded by message count, duration, and + result bytes. +- Add a managed subscription control plane only if sustained monitoring is + required. +- Treat live MQTT delivery as a background activity owned by the MCP session's + backend pod. +- Prefer bounded reads, cursors, and summaries as the model-facing data path. +- Use server-to-client notifications as an optional acceleration path, not the + only way for the client to observe updates. + +Possible managed-subscription tools: + +- `dsx_exchange_start_subscription(topic_filter, ttl_s, buffer_max_messages, + buffer_max_bytes, drop_policy)` +- `dsx_exchange_read_subscription(subscription_id, cursor, max_messages)` +- `dsx_exchange_subscription_status(subscription_id)` +- `dsx_exchange_list_subscriptions()` +- `dsx_exchange_stop_subscription(subscription_id)` + +The start call should return quickly with a subscription acknowledgement: + +```json +{ + "subscription_id": "sub_123", + "status": "running", + "next_cursor": "0", + "resource_uri": "dsx-exchange://subscriptions/sub_123" +} +``` + +The MCP client can then poll/read bounded batches or ask for summaries. Clients +that support MCP resource subscriptions or Streamable HTTP server-to-client +notifications can additionally receive update notifications and decide when to +read the buffered data. + +## Future SDD Topic: Stateful Sessions And Pod Ownership + +Long-lived subscription state should be tied to stateful MCP sessions. With +Agentgateway selector-based targets and stateful session routing, the gateway +can pin subsequent requests carrying the same `Mcp-Session-Id` to the same +resolved upstream pod. + +Recommended v1 posture: + +- Use Streamable HTTP for remote MCP transport. +- Require stateful MCP sessions for managed subscriptions. +- Use selector-based Agentgateway upstream targets so gateway-managed + session pinning can resolve and pin individual backend pods. +- Keep active subscription state in the owning `dsx-exchange-mcp` pod memory. +- Accept that pod restart, eviction, or session loss terminates in-memory + subscriptions and requires client resubscription. +- Do not require Redis, Valkey, or other shared state in v1 unless recovery, + cross-pod visibility, or durable replay becomes a requirement. + +State kept in the owning pod should include: + +- MCP session ID +- caller identity / auth context fingerprint +- subscription ID +- topic filter +- MQTT connection key +- subscription status +- buffer cursor and ring-buffer state +- last broker error or disconnect reason +- TTL and idle-expiry timestamps + +This model scales by adding pods. New sessions are distributed across pods by +the gateway's initial routing decision; existing sessions continue to the +pinned backend pod. + +## Future SDD Topic: MQTT Connection Strategy For Subscriptions + +The SDD should explicitly separate logical MCP subscriptions from MQTT +connections. One MQTT connection can carry many topic subscriptions, but it is +authenticated at MQTT CONNECT time and therefore carries the permissions of the +bearer used as its password. + +Unsafe rule: + +> Do not use one shared MQTT connection per pod for all callers. + +Safer rule: + +> Pool MQTT connections per pod by broker configuration and effective caller +> authorization context. + +A conservative pool key should include: + +- broker URL and TLS config +- MQTT username +- issuer +- subject or authorized party / service ID +- audience +- scopes +- tenant or persona +- token expiry bucket +- policy version or permissions hash, if available + +For demo traffic that uses one SSA service account, sharing a connection across +sessions from that same effective auth context may be acceptable. For production +traffic with distinct users, tenants, personas, or bearer scopes, connections +must not be shared across auth boundaries. + +The pod should support: + +- many MCP sessions +- multiple logical subscriptions per MCP session +- multiple MQTT connections keyed by auth context +- many topic filters per eligible MQTT connection +- internal fan-out from MQTT messages to per-subscription buffers + +If multiple active subscriptions share one MQTT client, the implementation needs +a demux layer: + +1. MQTT message arrives. +2. Match the topic to active filters owned by the same auth context. +3. Write the message or an aggregate into each matching subscription buffer. +4. Emit optional status/update notifications. + +The SDD should also define unsubscribe reference counting so one logical +subscription cannot remove a broker subscription still needed by another +logical subscription. + +## Future SDD Topic: Lifecycle, Failure, And Backpressure + +Managed subscriptions need explicit lifecycle and safety behavior: + +- `start_subscription` and `stop_subscription` should be idempotent where + possible. +- Each subscription must have TTL and idle timeout controls. +- Token expiry must close or recreate affected MQTT connections. +- If the server cannot refresh a caller bearer, subscriptions tied to that + bearer must end before or at token expiry. +- Broker disconnects and reconnect attempts should update subscription status. +- Topic ACL denial should surface as authorization failure, not as an empty + stream. +- MQTT connect auth failures should surface as authentication failure. +- Buffer overflow policy must be explicit: drop oldest, drop newest, aggregate, + or fail the subscription. +- Rolling deploys should either drain subscriptions gracefully or document that + clients must resubscribe after session loss. + +Suggested lifecycle/status notifications: + +- subscribed +- unsubscribed +- reconnecting +- disconnected +- authorization_denied +- authentication_failed +- buffer_overflow +- expired + +The SDD should define per-pod limits and metrics: + +- active MCP sessions +- active logical subscriptions +- active MQTT connections +- broker subscriptions +- MQTT messages/sec in +- MCP/SSE notifications/sec out +- buffered messages and bytes +- dropped messages +- authn/authz failures +- reconnect count +- event loop / handler latency + +## Recommended SDD Position For V1 + +The SDD should likely state: + +> The DSX Exchange MCP server uses Streamable HTTP with stateful MCP sessions. +> Agentgateway selector-based session routing pins a client session to one +> `dsx-exchange-mcp` pod. For v1, active MQTT subscription state is held in that +> owning pod's memory and is lost on pod restart. MQTT connections are pooled +> per pod by broker config and caller authorization context, not globally. A +> pooled MQTT connection may carry multiple topic subscriptions for the same +> auth context. The server fans incoming MQTT messages into bounded +> per-subscription buffers. MCP clients consume those buffers through explicit +> read/status calls, with optional server-to-client notifications for clients +> that support live updates. + +## Future SDD Topic: Schema Discovery Based On MQTT ACLs + +MCP schema/resource visibility should correspond to the caller's effective MQTT +subscribe authorization. A caller should not see DSX Exchange schema domains or +channels that they cannot subscribe to through the broker. The MCP server must +not trust client-provided claims alone for schema visibility. + +Broker enforcement remains the final source of truth: + +1. Gateway validates the caller and forwards the bearer. +2. `dsx-exchange-mcp` uses the bearer for MQTT when reading data. +3. NATS/auth-callout mints NATS permissions for that bearer. +4. The broker enforces topic subscribe ACLs. + +The open design problem is how `dsx-exchange-mcp` can filter `/schema` or +`dsx-exchange://specs/*` discovery without brute-force probing every possible +topic. + +### Tactical POC Approach: Canonical ACL Probes + +If the MCP server only has a caller bearer and no entitlement API, the only +fully accurate authorization check is to ask the broker: + +```text +MQTT CONNECT with caller bearer +MQTT SUBSCRIBE candidate filter +observe SUBACK success or denial +``` + +This should not enumerate concrete topics. At most, probe a small number of +canonical schema filters: + +| Schema / Domain | Candidate Probe | +| --- | --- | +| BMS metadata | `BMS/v1/PUB/Metadata/#` | +| BMS values | `BMS/v1/PUB/Value/#` | +| NICO | `NICO/v1/#` | +| Power management | canonical power-management prefix | + +Limitations: + +- A broad probe can produce false negatives for narrow ACLs. For example, a + caller allowed only `BMS/v1/PUB/Metadata/Rack/#` may be denied on + `BMS/v1/PUB/Metadata/#` even though part of the BMS schema is relevant. +- Broker or network errors are ambiguous and should fail closed or return a + degraded discovery result. +- Probe results should be cached only for a short TTL no longer than token + expiry. +- Probe count and timeout must be tightly bounded. + +This is acceptable for a POC or demo, but it is not the preferred production +design. + +### Preferred Approach: Entitlement Or ACL Introspection API + +The cleaner design is an internal read-only authorization API backed by the +same permission manager used by auth-callout. Today auth-callout resolves: + +```text +JWT claims: sub / azp / scope + -> UserProfile + -> jwt.Permissions + -> pub/sub allow/deny ACLs + -> signed NATS user JWT +``` + +The missing capability is a supported internal API that exposes the effective +decision or effective permissions for schema filtering. Possible shapes: + +```http +POST /v1/authorize +{ + "token": "", + "action": "mqtt.subscribe", + "topic_filter": "BMS/v1/PUB/Metadata/#" +} +``` + +or: + +```http +POST /v1/effective-permissions +{ + "token": "", + "protocol": "mqtt" +} +``` + +Example response: + +```json +{ + "subject": "...", + "azp": "...", + "account": "...", + "subscribe": { + "allow": ["BMS.v1.PUB.Metadata.>", "BMS.v1.PUB.Value.Rack.>"], + "deny": ["NICO.v1.>"] + }, + "policy_version": "..." +} +``` + +The API must not become a second source of truth. It should expose the same +effective permissions that auth-callout would apply while minting the NATS user +JWT. The broker still enforces the final data access decision. + +### Best Product Contract: Schema Entitlements + +Rather than exposing raw topic ACLs directly to MCP clients, the authorization +service or MCP server can map effective permissions into schema capabilities: + +```json +{ + "resources": ["dsx-exchange://specs/bms"], + "channels": ["bms.rackMetadata", "bms.rackValue"], + "recommended_filters": [ + "BMS/v1/PUB/Metadata/Rack/#", + "BMS/v1/PUB/Value/Rack/#" + ] +} +``` + +This gives agents a cleaner discovery surface while keeping the raw ACLs +internal. Broker SUBACK remains final enforcement for actual MQTT reads. + +### Pattern Intersection Instead Of Topic Enumeration + +The MCP server should maintain a compiled schema access index derived from +AsyncAPI channels: + +```text +Schema channel: bms.rackMetadata +MQTT filter: BMS/v1/PUB/Metadata/Rack/# +NATS filter: BMS.v1.PUB.Metadata.Rack.> + +Schema channel: bms.rackValue +MQTT filter: BMS/v1/PUB/Value/Rack/# +NATS filter: BMS.v1.PUB.Value.Rack.> +``` + +NATS MQTT topic/subject conversion: + +| MQTT | NATS | +| --- | --- | +| `/` | `.` | +| `+` | `*` | +| `#` | `>` | + +At discovery time: + +```text +1. Get caller effective subscribe ACLs. +2. For each schema channel in the access index: + if channel filter intersects acl.sub.allow + and is not fully excluded by acl.sub.deny: + expose channel/resource +3. Return filtered schema index/resources. +``` + +Examples: + +```text +ACL allow: BMS.v1.PUB.Metadata.> +Schema: BMS.v1.PUB.Metadata.Rack.> +Result: visible + +ACL allow: BMS.v1.PUB.Metadata.Rack.> +Schema: BMS.v1.PUB.Metadata.CDU.> +Result: hidden + +ACL allow: BMS.v1.PUB.> +ACL deny: BMS.v1.PUB.Metadata.Secret.> +Schema: BMS.v1.PUB.Metadata.> +Result: partially visible; remove or annotate denied channel subset +``` + +The SDD should define deny precedence and how to represent partially visible +schemas. Conservative behavior is to hide a channel when deny/allow +intersection cannot be represented safely. + +### Recommended SDD Position + +The SDD should likely state: + +> Schema/resource discovery is filtered from the caller's effective MQTT +> subscribe authorization. The MCP server must not infer schema access from +> untrusted client claims alone. Broker enforcement remains the source of truth +> for final message access, but discovery filtering should use an internal +> entitlement API backed by the same permission manager that auth-callout uses +> to mint NATS user JWTs. + +And: + +> The MCP server maintains a compiled schema access index that maps AsyncAPI +> resources and channels to canonical MQTT topic filters and equivalent NATS +> subject filters. Given effective subscribe allow/deny filters, the server +> computes visible schema resources by wildcard-pattern intersection. It does +> not enumerate concrete topic instances. + +Open approval questions: + +- Can auth-callout expose effective permissions or authorization decisions to + MCP backends? +- Should MCP clients see raw topic ACLs, schema capabilities, or only filtered + resources? +- What identity and credential does `dsx-exchange-mcp` use to call the + entitlement API? +- How are policy version, hot reload, and cache invalidation represented? +- How should partially visible schema domains be presented? +- Should ambiguous entitlement failures fail closed for all callers? diff --git a/mcp/dsx-exchange-mcp/docs/v1-background-watch-benchmark-plan.md b/mcp/dsx-exchange-mcp/docs/v1-background-watch-benchmark-plan.md new file mode 100644 index 0000000..0035ac9 --- /dev/null +++ b/mcp/dsx-exchange-mcp/docs/v1-background-watch-benchmark-plan.md @@ -0,0 +1,208 @@ +# V1 Background Watch Benchmark Plan + +This note defines the benchmark and failure-test plan for the v1 +`dsx-exchange-mcp` background watch design. + +## Objective + +Validate whether the v1 session-pinned, pod-local watch model is sufficient +before adding external watch state such as Valkey, Redis, JetStream consumers, +or separate MQTT worker pods. + +The v1 design intentionally treats background watches as ephemeral, +session-scoped state: + +- `start_subscription` returns a `subscription_id` quickly. +- Follow-up status/read/stop calls are routed to the same upstream pod by + `Mcp-Session-Id` and Agentgateway stateful routing. +- Active watch state, MQTT connections, cursors, and ring buffers are held in + the owning `dsx-exchange-mcp` pod. +- Pod restart, pod eviction, or MCP session loss terminates active watches and + requires client resubscription. + +The benchmark should determine whether this tradeoff is acceptable for v1 +usage, and where the breakpoints are. + +See `docs/watch-state-tradeoff-note.md` for the current tradeoff decision: +active MQTT watches stay pod-local, while any Valkey use should be limited to +best-effort status and aggregate snapshots rather than transparent MQTT +failover. + +## Non-Goals + +- Prove durable cross-pod recovery. +- Hide pod failure from clients. +- Turn Valkey or JetStream into an MCP-owned message database. +- Benchmark unbounded raw MQTT streaming through one infinite MCP tool call. + +## Benchmark Questions + +The benchmark should answer: + +| Question | Decision It Informs | +| --- | --- | +| How many concurrent MCP sessions can one deployment support? | Replica sizing and per-pod limits. | +| How many active watches can each pod hold safely? | Watch admission limits. | +| How many MQTT connections and broker subscriptions are created? | Connection pooling strategy. | +| How do narrow and broad topic filters affect CPU, memory, and drops? | Topic guardrails and dedicated-client thresholds. | +| What is the p95/p99 latency for status and cursor reads? | User-facing UX limits. | +| How quickly do buffers fill under hot topics? | Buffer caps and overflow policy. | +| How expensive are broker reconnects and auth-callout evaluations? | Reconnect and pooling policy. | +| What happens during pod loss, rollout, and broker disruption? | Whether v1 failure semantics are acceptable. | + +## Load Shapes + +Run each load shape at multiple replica counts, starting with two replicas to +match the deployment default. + +| Scenario | Example Levels | Purpose | +| --- | --- | --- | +| Concurrent MCP sessions | 100, 500, 1000 | Validate session pinning, memory, and gateway behavior. | +| Watches per session | 1, 5, 10 | Model normal and power-user agent workflows. | +| Narrow filters | Specific rack/object paths | Baseline operational usage. | +| Domain-wide filters | BMS or NICO domain-level watches | High-volume but plausible usage. | +| Broad wildcard filters | `#` or similar unsafe patterns, if allowed in test | Worst-case/bad-agent pressure. | +| Sparse topics | Low event rate | Idle connection overhead. | +| Hot topics | High event rate | Buffer pressure, drops, and summarization cost. | +| Control-plane churn | Repeated start/status/read/stop | Lifecycle overhead and cleanup leaks. | +| Denied topics | Broker ACL denial | Structured error and audit behavior. | +| Broker unavailable | Connect/subscribe failures | Backoff, error, and retry pressure. | + +## Metrics To Capture + +### MCP Server + +- Active MCP sessions. +- Active background watches. +- Active tool calls. +- Tool calls by tool, status, and error code. +- `start_subscription`, `read_subscription`, `subscription_status`, and + `stop_subscription` latency p50/p95/p99. +- Active MQTT connections. +- Broker subscriptions. +- Messages and bytes received. +- Buffered messages and bytes. +- Dropped messages and overflow count. +- Per-pod buffer memory. +- Goroutine count. +- Pod CPU and memory. +- Pod restarts. + +### MQTT / Broker Path + +- MQTT connect latency. +- MQTT subscribe latency. +- MQTT connection failures. +- Subscribe ACL denials. +- Broker reconnect count. +- Reconnect exhaustion count. +- Auth-callout request rate and latency, if available. +- Broker connection count by client identity or auth context, if available. + +### Gateway Path + +- Gateway request rate. +- Gateway 4xx/5xx by reason. +- Session routing failures. +- Requests missing or changing `Mcp-Session-Id`. +- Upstream dispatch latency. + +### User-Visible Results + +- Time from `start_subscription` to `running`. +- Time to first message. +- `read_subscription` p95/p99 latency. +- `subscription_status` p95/p99 latency. +- Message loss as represented by buffer overflow counters. +- Number of watches requiring client resubscription during failure tests. + +## Suggested Pass / Review Gates + +Set exact thresholds per environment before running the benchmark. Initial +review gates can use this shape: + +| Gate | Initial Target | +| --- | --- | +| 1000 sessions with 1 watch each | No unbounded memory or goroutine growth. | +| `read_subscription` latency | p95 below 500 ms under normal load. | +| `subscription_status` latency | p95 below 250 ms under normal load. | +| Pod memory | Stays below 70 percent of configured limit. | +| Buffer overflow | Only occurs under configured overflow scenarios. | +| Stop cleanup | MQTT subscriptions, buffers, and goroutines are released. | +| Pod kill behavior | Watch loss is visible and client can resubscribe. | +| Broker ACL denial | Returns structured `topic_acl_denied`, not empty data. | +| Broker unavailable | Returns/updates `bus_unavailable` or `reconnecting` without hot looping. | + +## Failure Scenarios + +The v1 design does not promise transparent recovery. The tests should verify +clear status, cleanup, and resubscription behavior. + +| Failure | Expected V1 Behavior | +| --- | --- | +| Owning MCP pod killed mid-watch | Active watches on that pod are lost; client must resubscribe. | +| Rolling deployment | Terminating pod stops accepting new watch starts; active watches are drained or reported lost where possible. | +| Gateway pod restart | Follow-up requests with the same `Mcp-Session-Id` should still route to the owning upstream pod. | +| MQTT broker disconnect | Watch enters reconnecting or failed status; no silent empty stream. | +| Auth token expiry | Watch ends at or before token expiry unless token refresh/token exchange is added. | +| ACL revoked during watch | Watch stops or fails on reconnect or next broker enforcement point. | +| Buffer overflow | Configured overflow policy applies and status/metrics expose the drop. | +| Client stops polling | Idle timeout eventually cleans up the watch. | +| Client disappears without stop | TTL or idle expiry releases MQTT subscriptions and buffers. | +| Node drain or eviction | Same as pod termination; client resubscribes. | + +## When To Add External Watch State + +Promote Valkey, Redis, JetStream consumers, or another external state backend +only if benchmark or product data shows one or more of these are required: + +- Watches must survive `dsx-exchange-mcp` pod restart or node drain. +- Operators need to inspect all active watches globally across pods. +- `read_subscription` or `subscription_status` cannot reliably stay pinned to + the owning pod. +- Watch lifetimes are long enough that rollout-driven interruption is common + and unacceptable. +- Client resubscription creates too much UX friction or misses important + incident evidence. +- Background watch count or message rate requires separate worker scaling. +- Support needs ownership leases, heartbeats, and cross-pod takeover. + +If the goal is only better post-interruption UX, prefer a narrower +status-snapshot store before adding a full distributed ownership model. That +snapshot store can hold TTL-bound heartbeat, last-message, counter, and +aggregate data while active MQTT clients and raw buffers remain pod-local. + +If external state is added, keep the responsibility split explicit: + +| Data | Preferred Owner | +| --- | --- | +| Durable event replay | NATS JetStream or Flight Recorder. | +| Best-effort watch metadata/status/snapshot watermarks | Valkey, Redis, or approved KV store. | +| Bounded recent MCP buffers | Pod memory for v1; external capped buffers only when needed. | +| Live read cursors and raw ring buffers | Pod memory for v1. | +| Long-term incident evidence | Approved observability sinks, not MCP buffers. | + +## Recommended Milestones + +1. Implement v1 pod-local background watches with strong limits and metrics. +2. Run the benchmark at 100, 500, and 1000 concurrent sessions. +3. Run failure tests for pod kill, rollout, broker disruption, token expiry, + ACL denial, buffer overflow, and idle cleanup. +4. Review observed interruption rate and operator/client impact. +5. Decide whether v2 needs external watch state, worker pods, broker-backed + replay, or only tuning of v1 limits. + +## Decision Record Template + +After each benchmark run, record: + +| Field | Notes | +| --- | --- | +| Date and environment | Cluster, broker, gateway, image versions. | +| Replica count | Gateway and `dsx-exchange-mcp`. | +| Load shape | Sessions, watches/session, topics, message rate. | +| Limits | Buffer caps, TTL, idle timeout, message caps. | +| Results | Latency, CPU, memory, drops, errors, restarts. | +| Failure behavior | What failed, what recovered, what required resubscription. | +| Decision | Keep v1, tune v1, or promote external watch state. | +| Follow-up | Required code, chart, observability, or product changes. | diff --git a/mcp/dsx-exchange-mcp/docs/watch-state-tradeoff-note.md b/mcp/dsx-exchange-mcp/docs/watch-state-tradeoff-note.md new file mode 100644 index 0000000..b8c0215 --- /dev/null +++ b/mcp/dsx-exchange-mcp/docs/watch-state-tradeoff-note.md @@ -0,0 +1,180 @@ +# Background Watch State Tradeoff Note + +This note captures the current design position for `dsx-exchange-mcp` +long-running MQTT watches after comparing pod-local state, Valkey-backed state, +and broker-backed recovery. + +## Decision + +Active background watches remain pod-local for the first implementation. The +MQTT client, subscription callbacks, raw ring buffer, and live cursor state are +owned by the `dsx-exchange-mcp` pod selected by Agentgateway stateful MCP +session routing. + +Valkey, if introduced, should be limited to best-effort watch status and +aggregate snapshots. It should not be used in v1 for transparent MQTT client +failover, lease-based ownership transfer, raw message replay, or token refresh. + +## Why Not Store The MQTT Client + +An MQTT client is a live TCP connection plus in-process callback state. It +cannot survive pod restart by being stored in a key/value database. When the +owning pod dies, the MQTT connection and in-memory callbacks are gone. + +The server can store only metadata about the watch: + +- `subscription_id` +- topic filter or schema-derived selector +- owner pod identity +- creation, expiry, and last heartbeat timestamps +- last message timestamp +- message counters and drop counters +- latest/oldest snapshot watermarks +- latest status and error code +- bounded aggregate snapshots + +This metadata can improve user-facing status after interruption, but it does +not recreate the MQTT subscription. + +## Credential Constraint + +The gateway validates each MCP tool call and forwards the caller bearer to the +upstream. `dsx-exchange-mcp` uses that bearer as the MQTT password when opening +the broker connection. Broker/auth-callout remains authoritative for topic ACLs. + +The MCP server should not persist raw JWTs or manage caller token refresh. This +means a replacement pod cannot autonomously reconnect a caller-scoped MQTT +subscription after owner pod failure unless a later authenticated tool call +provides a fresh bearer, or a separate approved token-exchange mechanism is +added. + +Because of that constraint, Valkey-backed lease takeover would create the +appearance of failover without solving the credential needed to resume the MQTT +stream. + +## Valkey Usage That Fits + +A narrow Valkey use can still be valuable if product UX needs post-interruption +status: + +- TTL-bound watch status records. +- Owner heartbeat and last-message timestamps. +- Message, byte, and drop counters. +- Last known status such as `running`, `expired`, `interrupted`, `failed`, or + `buffer_overflow`. +- Latest and oldest snapshot watermarks for explaining what the snapshot + covered. +- Periodic aggregate snapshots per topic, such as count, min, max, mean, last + value, and frequency. + +This state is best-effort. If Valkey is unavailable, the active pod-local watch +can continue. Snapshot writes may fail, and post-mortem status may be missing +if the pod later dies. + +## Valkey Usage To Avoid For V1 + +Avoid using Valkey for: + +- MQTT client persistence. +- Raw JWT persistence. +- Transparent MQTT reconnect after pod failure. +- Lease-based active owner promotion. +- Live read cursors or replay positions used to continue a pod-local stream + after pod failure. +- Raw ring buffers, exact message batches, or raw replay. +- A durable telemetry database. +- Replica reads for ownership, live state, or lease decisions. + +These uses require stronger consistency, failover, token lifecycle, and recovery +semantics than the current v1 goal needs. + +## Deployment Implication + +For best-effort watch status snapshots, Valkey can be treated like a transient +cache: + +- One writable primary endpoint is sufficient for v1. +- Replicas are optional and mainly useful for future HA promotion, not read + scaling. +- Reads and writes should go to the primary to avoid stale status and snapshot + confusion. +- Persistence is optional because records are TTL-bound and not source-of-truth + telemetry. +- If Valkey fails, the system should fail open to local-only watch behavior. + +This differs from gateway RLS Valkey. Gateway RLS stores short-lived shared rate +limit counters where loss only weakens throttling temporarily. Watch snapshots +are user-visible observability state, so they should be framed as best-effort +and not as a reliability boundary. + +## Valkey Snapshot TTL + +Snapshot records should expire soon after they stop helping the user understand +an interruption. The default TTL should be: + +```text +snapshot_ttl = min(watch_expires_at + 30 minutes, created_at + 2 hours) +``` + +With a 15-minute maximum watch TTL, this keeps most snapshot records for up to +45 minutes after creation. That gives the user or agent time to ask what +happened after an interruption without keeping stale monitoring state around as +if it were durable telemetry. + +Before watch expiry, the owning pod-local state is authoritative. After expiry +or interruption, a snapshot is useful only as last-known context. Longer-term +incident evidence belongs in audit logs, Flight Recorder, metrics, logs, or +another approved observability system rather than Valkey watch snapshots. + +## User-Facing Failure Semantics + +Pod-local state is authoritative. A Valkey snapshot can only explain the last +known state; it cannot prove that a watch is still active or resume raw reads. + +| Tool | Local State Exists | Local State Missing, No Snapshot | Local State Missing, Snapshot Exists | +| --- | --- | --- | --- | +| `dsx_exchange_start_subscription` | Create a new authenticated pod-local watch. | Create a new authenticated pod-local watch. | Create a new authenticated pod-local watch; old snapshot remains historical context until TTL. | +| `dsx_exchange_read_subscription` | Read the owning pod's local buffer and return bounded messages plus next cursor. | Return `subscription_not_found` or `session_lost`. | Return `interrupted` with snapshot metadata or aggregates and no raw messages. | +| `dsx_exchange_subscription_status` | Return live pod-local status. | Return `subscription_not_found` or `session_lost`. | Return `interrupted` with last heartbeat, last message time, counters, and aggregate snapshot. | +| `dsx_exchange_stop_subscription` | Stop the local MQTT subscription, release buffers, and return `stopped`. | Return `subscription_not_found`. | Mark the snapshot `stopped` or return idempotent `stopped` with `stopped_reason=session_lost`; no MQTT cleanup is possible. | + +If the owning pod dies and no Valkey snapshot is available, a later read/status +call should return `subscription_not_found` or `session_lost`. + +If a stale Valkey snapshot is available, a later read/status call may return: + +```json +{ + "subscription_id": "sub_123", + "status": "interrupted", + "interrupted_reason": "owner_heartbeat_stale", + "last_heartbeat_at": "2026-06-03T19:10:02Z", + "last_message_at": "2026-06-03T19:09:58Z", + "message_count": 1842, + "aggregates": [ + { + "topic": "BMS/v1/PUB/Value/Rack/Power/Rack02", + "count": 120, + "min": 28.4, + "max": 35.9, + "mean": 31.2, + "last_value": 32.1 + } + ] +} +``` + +The agent can then explain that the watch was interrupted and offer to restart +it through a fresh authenticated tool call. Messages after the interruption may +be missed. + +The `interrupted` status is informational only. It does not mean the watch is +still active, recoverable, or eligible for raw cursor reads. The recovery path is +a fresh authenticated `dsx_exchange_start_subscription` call. + +## Current Recommendation + +Implement pod-local watches first with short TTLs, clear interruption status, +and strong bounds. Add best-effort Valkey status and aggregate snapshots only if +the post-interruption UX needs more than `subscription_not_found` or +`session_lost`. diff --git a/mcp/dsx-exchange-mcp/go.mod b/mcp/dsx-exchange-mcp/go.mod new file mode 100644 index 0000000..93422f8 --- /dev/null +++ b/mcp/dsx-exchange-mcp/go.mod @@ -0,0 +1,21 @@ +module github.com/NVIDIA/dsx-exchange/mcp/dsx-exchange-mcp + +go 1.25 + +require ( + github.com/eclipse/paho.mqtt.golang v1.5.1 + github.com/modelcontextprotocol/go-sdk v1.4.0 + gopkg.in/yaml.v3 v3.0.1 +) + +require ( + github.com/google/jsonschema-go v0.4.2 // indirect + github.com/gorilla/websocket v1.5.3 // indirect + github.com/segmentio/asm v1.1.3 // indirect + github.com/segmentio/encoding v0.5.3 // indirect + github.com/yosida95/uritemplate/v3 v3.0.2 // indirect + golang.org/x/net v0.44.0 // indirect + golang.org/x/oauth2 v0.34.0 // indirect + golang.org/x/sync v0.17.0 // indirect + golang.org/x/sys v0.40.0 // indirect +) diff --git a/mcp/dsx-exchange-mcp/go.sum b/mcp/dsx-exchange-mcp/go.sum new file mode 100644 index 0000000..ab10cc7 --- /dev/null +++ b/mcp/dsx-exchange-mcp/go.sum @@ -0,0 +1,32 @@ +github.com/eclipse/paho.mqtt.golang v1.5.1 h1:/VSOv3oDLlpqR2Epjn1Q7b2bSTplJIeV2ISgCl2W7nE= +github.com/eclipse/paho.mqtt.golang v1.5.1/go.mod h1:1/yJCneuyOoCOzKSsOTUc0AJfpsItBGWvYpBLimhArU= +github.com/golang-jwt/jwt/v5 v5.3.0 h1:pv4AsKCKKZuqlgs5sUmn4x8UlGa0kEVt/puTpKx9vvo= +github.com/golang-jwt/jwt/v5 v5.3.0/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/google/jsonschema-go v0.4.2 h1:tmrUohrwoLZZS/P3x7ex0WAVknEkBZM46iALbcqoRA8= +github.com/google/jsonschema-go v0.4.2/go.mod h1:r5quNTdLOYEz95Ru18zA0ydNbBuYoo9tgaYcxEYhJVE= +github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg= +github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= +github.com/modelcontextprotocol/go-sdk v1.4.0 h1:u0kr8lbJc1oBcawK7Df+/ajNMpIDFE41OEPxdeTLOn8= +github.com/modelcontextprotocol/go-sdk v1.4.0/go.mod h1:Nxc2n+n/GdCebUaqCOhTetptS17SXXNu9IfNTaLDi1E= +github.com/segmentio/asm v1.1.3 h1:WM03sfUOENvvKexOLp+pCqgb/WDjsi7EK8gIsICtzhc= +github.com/segmentio/asm v1.1.3/go.mod h1:Ld3L4ZXGNcSLRg4JBsZ3//1+f/TjYl0Mzen/DQy1EJg= +github.com/segmentio/encoding v0.5.3 h1:OjMgICtcSFuNvQCdwqMCv9Tg7lEOXGwm1J5RPQccx6w= +github.com/segmentio/encoding v0.5.3/go.mod h1:HS1ZKa3kSN32ZHVZ7ZLPLXWvOVIiZtyJnO1gPH1sKt0= +github.com/yosida95/uritemplate/v3 v3.0.2 h1:Ed3Oyj9yrmi9087+NczuL5BwkIc4wvTb5zIM+UJPGz4= +github.com/yosida95/uritemplate/v3 v3.0.2/go.mod h1:ILOh0sOhIJR3+L/8afwt/kE++YT040gmv5BQTMR2HP4= +golang.org/x/net v0.44.0 h1:evd8IRDyfNBMBTTY5XRF1vaZlD+EmWx6x8PkhR04H/I= +golang.org/x/net v0.44.0/go.mod h1:ECOoLqd5U3Lhyeyo/QDCEVQ4sNgYsqvCZ722XogGieY= +golang.org/x/oauth2 v0.34.0 h1:hqK/t4AKgbqWkdkcAeI8XLmbK+4m4G5YeQRrmiotGlw= +golang.org/x/oauth2 v0.34.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA= +golang.org/x/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug= +golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= +golang.org/x/sys v0.40.0 h1:DBZZqJ2Rkml6QMQsZywtnjnnGvHza6BTfYFWY9kjEWQ= +golang.org/x/sys v0.40.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/tools v0.41.0 h1:a9b8iMweWG+S0OBnlU36rzLp20z1Rp10w+IY2czHTQc= +golang.org/x/tools v0.41.0/go.mod h1:XSY6eDqxVNiYgezAVqqCeihT4j1U2CCsqvH3WhQpnlg= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/mcp/dsx-exchange-mcp/internal/auth/context.go b/mcp/dsx-exchange-mcp/internal/auth/context.go new file mode 100644 index 0000000..dc5ff39 --- /dev/null +++ b/mcp/dsx-exchange-mcp/internal/auth/context.go @@ -0,0 +1,60 @@ +// Copyright 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +package auth + +import ( + "context" + "net/http" + "strings" +) + +type ctxKey struct{} + +// Caller is the request identity material the gateway passes through. The raw +// bearer is used only as the MQTT password; the x-mcp-* fields are audit labels +// emitted by the gateway's ext_authz path when present. +type Caller struct { + Bearer string + SessionID string + Tenant string + Issuer string + Subject string + SpiffeID string +} + +// Middleware extracts the caller bearer and gateway-projected identity headers +// from the HTTP request and stores them on the request context. +func Middleware(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + caller := Caller{ + Bearer: bearerFromHeader(r.Header.Get("Authorization")), + SessionID: r.Header.Get("Mcp-Session-Id"), + Tenant: r.Header.Get("x-mcp-tenant"), + Issuer: r.Header.Get("x-mcp-issuer"), + Subject: r.Header.Get("x-mcp-sub"), + SpiffeID: r.Header.Get("x-mcp-spiffe-id"), + } + r = r.WithContext(context.WithValue(r.Context(), ctxKey{}, caller)) + next.ServeHTTP(w, r) + }) +} + +func bearerFromHeader(h string) string { + const prefix = "Bearer " + if !strings.HasPrefix(strings.ToLower(h), strings.ToLower(prefix)) { + return "" + } + return strings.TrimSpace(h[len(prefix):]) +} + +// FromContext returns all caller identity material stored on ctx. +func FromContext(ctx context.Context) Caller { + v, _ := ctx.Value(ctxKey{}).(Caller) + return v +} + +// Bearer returns the caller's bearer token from ctx, or "" if absent. +func Bearer(ctx context.Context) string { + return FromContext(ctx).Bearer +} diff --git a/mcp/dsx-exchange-mcp/internal/auth/context_test.go b/mcp/dsx-exchange-mcp/internal/auth/context_test.go new file mode 100644 index 0000000..568f187 --- /dev/null +++ b/mcp/dsx-exchange-mcp/internal/auth/context_test.go @@ -0,0 +1,50 @@ +// Copyright 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +package auth + +import ( + "net/http" + "net/http/httptest" + "testing" +) + +func TestMiddlewareStoresCaller(t *testing.T) { + var got Caller + next := Middleware(http.HandlerFunc(func(_ http.ResponseWriter, r *http.Request) { + got = FromContext(r.Context()) + })) + + req := httptest.NewRequest(http.MethodPost, "/mcp", nil) + req.Header.Set("Authorization", "Bearer token-123") + req.Header.Set("x-mcp-tenant", "tenant-a") + req.Header.Set("x-mcp-issuer", "https://issuer") + req.Header.Set("x-mcp-sub", "tenant-a/agent") + req.Header.Set("x-mcp-spiffe-id", "spiffe://tenant-a/agent/tenant-a%2Fagent") + + next.ServeHTTP(httptest.NewRecorder(), req) + + if got.Bearer != "token-123" { + t.Fatalf("Bearer = %q, want token-123", got.Bearer) + } + if got.Tenant != "tenant-a" || got.Issuer != "https://issuer" || got.Subject != "tenant-a/agent" { + t.Fatalf("caller identity not propagated: %+v", got) + } + if got.SpiffeID == "" { + t.Fatalf("SpiffeID was not propagated") + } +} + +func TestBearerSchemeIsCaseInsensitive(t *testing.T) { + req := httptest.NewRequest(http.MethodPost, "/mcp", nil) + req.Header.Set("Authorization", "bearer token-123") + + var got string + Middleware(http.HandlerFunc(func(_ http.ResponseWriter, r *http.Request) { + got = Bearer(r.Context()) + })).ServeHTTP(httptest.NewRecorder(), req) + + if got != "token-123" { + t.Fatalf("Bearer = %q, want token-123", got) + } +} diff --git a/mcp/dsx-exchange-mcp/internal/metrics/metrics.go b/mcp/dsx-exchange-mcp/internal/metrics/metrics.go new file mode 100644 index 0000000..1eb30ba --- /dev/null +++ b/mcp/dsx-exchange-mcp/internal/metrics/metrics.go @@ -0,0 +1,230 @@ +// Copyright 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +package metrics + +import ( + "fmt" + "net/http" + "sort" + "strings" + "sync" + "sync/atomic" + "time" +) + +// Recorder keeps a small Prometheus-compatible metrics surface without adding +// a dependency on a metrics framework. The service can swap this for OTel or +// prometheus/client_golang later without changing tool code. +type Recorder struct { + activeCalls int64 + activeWatches int64 + watchMessages uint64 + watchDropped uint64 + + mu sync.Mutex + toolCalls map[string]uint64 + toolErrors map[labelKey]uint64 + toolDuration map[string]time.Duration + messageCounts map[string]uint64 + stoppedReasons map[labelKey]uint64 +} + +type labelKey struct { + Tool string + Value string +} + +func NewRecorder() *Recorder { + return &Recorder{ + toolCalls: map[string]uint64{}, + toolErrors: map[labelKey]uint64{}, + toolDuration: map[string]time.Duration{}, + messageCounts: map[string]uint64{}, + stoppedReasons: map[labelKey]uint64{}, + } +} + +func (r *Recorder) BeginToolCall() { + atomic.AddInt64(&r.activeCalls, 1) +} + +func (r *Recorder) EndToolCall() { + atomic.AddInt64(&r.activeCalls, -1) +} + +func (r *Recorder) BeginWatch() { + if r == nil { + return + } + atomic.AddInt64(&r.activeWatches, 1) +} + +func (r *Recorder) EndWatch() { + if r == nil { + return + } + atomic.AddInt64(&r.activeWatches, -1) +} + +func (r *Recorder) RecordWatchMessage() { + if r == nil { + return + } + atomic.AddUint64(&r.watchMessages, 1) +} + +func (r *Recorder) RecordWatchDrop(n int64) { + if r == nil || n <= 0 { + return + } + atomic.AddUint64(&r.watchDropped, uint64(n)) +} + +func (r *Recorder) RecordToolCall(tool, code, stoppedReason string, duration time.Duration, messages int) { + if r == nil { + return + } + r.mu.Lock() + defer r.mu.Unlock() + + r.toolCalls[tool]++ + r.toolDuration[tool] += duration + if messages > 0 { + r.messageCounts[tool] += uint64(messages) + } + if code != "" { + r.toolErrors[labelKey{Tool: tool, Value: code}]++ + } + if stoppedReason != "" { + r.stoppedReasons[labelKey{Tool: tool, Value: stoppedReason}]++ + } +} + +func (r *Recorder) Handler() http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "text/plain; version=0.0.4; charset=utf-8") + r.writePrometheus(w) + }) +} + +func (r *Recorder) writePrometheus(w http.ResponseWriter) { + if r == nil { + fmt.Fprintln(w, "# no metrics recorder configured") + return + } + + r.mu.Lock() + toolCalls := cloneStringMap(r.toolCalls) + toolDuration := cloneDurationMap(r.toolDuration) + messageCounts := cloneStringMap(r.messageCounts) + toolErrors := cloneLabelMap(r.toolErrors) + stoppedReasons := cloneLabelMap(r.stoppedReasons) + r.mu.Unlock() + + fmt.Fprintln(w, "# HELP dsx_exchange_mcp_active_tool_calls Tool calls currently in flight.") + fmt.Fprintln(w, "# TYPE dsx_exchange_mcp_active_tool_calls gauge") + fmt.Fprintf(w, "dsx_exchange_mcp_active_tool_calls %d\n", atomic.LoadInt64(&r.activeCalls)) + + fmt.Fprintln(w, "# HELP dsx_exchange_mcp_active_background_watches Background watches currently active in this pod.") + fmt.Fprintln(w, "# TYPE dsx_exchange_mcp_active_background_watches gauge") + fmt.Fprintf(w, "dsx_exchange_mcp_active_background_watches %d\n", atomic.LoadInt64(&r.activeWatches)) + + fmt.Fprintln(w, "# HELP dsx_exchange_mcp_tool_calls_total Total tool calls by tool.") + fmt.Fprintln(w, "# TYPE dsx_exchange_mcp_tool_calls_total counter") + for _, tool := range sortedKeys(toolCalls) { + fmt.Fprintf(w, "dsx_exchange_mcp_tool_calls_total{tool=\"%s\"} %d\n", promLabel(tool), toolCalls[tool]) + } + + fmt.Fprintln(w, "# HELP dsx_exchange_mcp_tool_errors_total Tool errors by tool and code.") + fmt.Fprintln(w, "# TYPE dsx_exchange_mcp_tool_errors_total counter") + for _, k := range sortedLabelKeys(toolErrors) { + fmt.Fprintf(w, "dsx_exchange_mcp_tool_errors_total{tool=\"%s\",code=\"%s\"} %d\n", promLabel(k.Tool), promLabel(k.Value), toolErrors[k]) + } + + fmt.Fprintln(w, "# HELP dsx_exchange_mcp_tool_duration_seconds_sum Total tool duration by tool.") + fmt.Fprintln(w, "# TYPE dsx_exchange_mcp_tool_duration_seconds_sum counter") + for _, tool := range sortedDurationKeys(toolDuration) { + fmt.Fprintf(w, "dsx_exchange_mcp_tool_duration_seconds_sum{tool=\"%s\"} %.6f\n", promLabel(tool), toolDuration[tool].Seconds()) + } + + fmt.Fprintln(w, "# HELP dsx_exchange_mcp_mqtt_messages_collected_total MQTT messages returned by tool.") + fmt.Fprintln(w, "# TYPE dsx_exchange_mcp_mqtt_messages_collected_total counter") + for _, tool := range sortedKeys(messageCounts) { + fmt.Fprintf(w, "dsx_exchange_mcp_mqtt_messages_collected_total{tool=\"%s\"} %d\n", promLabel(tool), messageCounts[tool]) + } + + fmt.Fprintln(w, "# HELP dsx_exchange_mcp_stopped_reasons_total Tool stop reasons by tool.") + fmt.Fprintln(w, "# TYPE dsx_exchange_mcp_stopped_reasons_total counter") + for _, k := range sortedLabelKeys(stoppedReasons) { + fmt.Fprintf(w, "dsx_exchange_mcp_stopped_reasons_total{tool=\"%s\",reason=\"%s\"} %d\n", promLabel(k.Tool), promLabel(k.Value), stoppedReasons[k]) + } + + fmt.Fprintln(w, "# HELP dsx_exchange_mcp_background_watch_messages_total MQTT messages received by background watches.") + fmt.Fprintln(w, "# TYPE dsx_exchange_mcp_background_watch_messages_total counter") + fmt.Fprintf(w, "dsx_exchange_mcp_background_watch_messages_total %d\n", atomic.LoadUint64(&r.watchMessages)) + + fmt.Fprintln(w, "# HELP dsx_exchange_mcp_background_watch_dropped_messages_total MQTT messages dropped from background watch buffers.") + fmt.Fprintln(w, "# TYPE dsx_exchange_mcp_background_watch_dropped_messages_total counter") + fmt.Fprintf(w, "dsx_exchange_mcp_background_watch_dropped_messages_total %d\n", atomic.LoadUint64(&r.watchDropped)) +} + +func cloneStringMap(in map[string]uint64) map[string]uint64 { + out := make(map[string]uint64, len(in)) + for k, v := range in { + out[k] = v + } + return out +} + +func cloneDurationMap(in map[string]time.Duration) map[string]time.Duration { + out := make(map[string]time.Duration, len(in)) + for k, v := range in { + out[k] = v + } + return out +} + +func cloneLabelMap(in map[labelKey]uint64) map[labelKey]uint64 { + out := make(map[labelKey]uint64, len(in)) + for k, v := range in { + out[k] = v + } + return out +} + +func sortedKeys(m map[string]uint64) []string { + out := make([]string, 0, len(m)) + for k := range m { + out = append(out, k) + } + sort.Strings(out) + return out +} + +func sortedDurationKeys(m map[string]time.Duration) []string { + out := make([]string, 0, len(m)) + for k := range m { + out = append(out, k) + } + sort.Strings(out) + return out +} + +func sortedLabelKeys(m map[labelKey]uint64) []labelKey { + out := make([]labelKey, 0, len(m)) + for k := range m { + out = append(out, k) + } + sort.Slice(out, func(i, j int) bool { + if out[i].Tool == out[j].Tool { + return out[i].Value < out[j].Value + } + return out[i].Tool < out[j].Tool + }) + return out +} + +func promLabel(s string) string { + return strings.NewReplacer("\\", "\\\\", "\n", "\\n", "\"", "\\\"").Replace(s) +} diff --git a/mcp/dsx-exchange-mcp/internal/mqttbus/client.go b/mcp/dsx-exchange-mcp/internal/mqttbus/client.go new file mode 100644 index 0000000..45a4ccc --- /dev/null +++ b/mcp/dsx-exchange-mcp/internal/mqttbus/client.go @@ -0,0 +1,617 @@ +// Copyright 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +package mqttbus + +import ( + "context" + "crypto/tls" + "crypto/x509" + "encoding/base64" + "errors" + "fmt" + "os" + "strings" + "sync" + "time" + "unicode/utf8" + + mqtt "github.com/eclipse/paho.mqtt.golang" +) + +const ( + DefaultUsername = "oauthtoken" + + CodeMissingBearer = "missing_bearer" + CodeInvalidTopicFilter = "invalid_topic_filter" + CodeInvalidArgument = "invalid_argument" + CodeTLSConfigError = "tls_config_error" + CodeTLSHandshakeFailed = "tls_handshake_failed" + CodeBusUnavailable = "bus_unavailable" + CodeMQTTAuthFailed = "mqtt_auth_failed" + CodeTopicACLDenied = "topic_acl_denied" + CodeMQTTAuthorizationFailed = "mqtt_authorization_failed" + CodeMQTTSubscribeFailed = "mqtt_subscribe_failed" + CodeInternalError = "internal_error" +) + +const ( + StoppedMaxMessages = "max_messages" + StoppedMaxDuration = "max_duration" + StoppedRetainedIdle = "retained_idle" + StoppedCallerCancel = "caller_cancelled" + StoppedBrokerError = "broker_error" + StoppedResultTooLarge = "result_too_large" +) + +type Config struct { + BrokerURL string + Username string + TLS TLSConfig + ConnectTimeout time.Duration + SubscribeTimeout time.Duration + MaxResultBytes int +} + +type TLSConfig struct { + CAFile string + ServerName string + InsecureSkipVerify bool +} + +// Message is a single MQTT message captured from the bus. +type Message struct { + Topic string `json:"topic"` + Payload string `json:"payload"` + PayloadEncoding string `json:"payload_encoding"` + Retained bool `json:"retained"` + QoS byte `json:"qos"` + ReceivedAt time.Time `json:"received_at"` +} + +type CollectResult struct { + Messages []Message `json:"messages"` + StoppedReason string `json:"stopped_reason"` + Truncated bool `json:"truncated"` + Duration time.Duration `json:"-"` +} + +type StreamOptions struct { + ClientID string + MaxMessages int + MaxDuration time.Duration + OnSubscribed func() +} + +type StreamResult struct { + Count int + StoppedReason string + Duration time.Duration +} + +type BusError struct { + Code string + Message string + Err error +} + +func (e *BusError) Error() string { + if e == nil { + return "" + } + if e.Err == nil { + return e.Message + } + return e.Message + ": " + e.Err.Error() +} + +func (e *BusError) Unwrap() error { + if e == nil { + return nil + } + return e.Err +} + +func ErrorCode(err error) string { + var busErr *BusError + if errors.As(err, &busErr) { + return busErr.Code + } + if err == nil { + return "" + } + return CodeInternalError +} + +// Collect opens a one-shot MQTT connection, subscribes to topicFilter, and +// returns up to maxMessages messages or until maxDuration elapses. The caller's +// bearer is passed as the MQTT password; DSX Exchange auth-callout owns token +// validation and topic ACL enforcement. +func Collect( + ctx context.Context, + cfg Config, + bearer, topicFilter string, + maxMessages int, + maxDuration time.Duration, + retainedOnly bool, +) (CollectResult, error) { + start := time.Now() + out := CollectResult{} + + if bearer == "" { + return out, &BusError{Code: CodeMissingBearer, Message: "missing caller bearer; gateway should pass Authorization through"} + } + if err := ValidateTopicFilter(topicFilter); err != nil { + return out, err + } + if maxMessages <= 0 { + return out, &BusError{Code: CodeInvalidArgument, Message: "max_messages must be greater than zero"} + } + if maxDuration <= 0 { + return out, &BusError{Code: CodeInvalidArgument, Message: "max_duration_s must be greater than zero"} + } + if cfg.BrokerURL == "" { + return out, &BusError{Code: CodeInvalidArgument, Message: "broker URL is required"} + } + + connectTimeout := cfg.ConnectTimeout + if connectTimeout <= 0 { + connectTimeout = 5 * time.Second + } + subscribeTimeout := cfg.SubscribeTimeout + if subscribeTimeout <= 0 { + subscribeTimeout = 5 * time.Second + } + username := cfg.Username + if username == "" { + username = DefaultUsername + } + + opts := mqtt.NewClientOptions(). + AddBroker(cfg.BrokerURL). + SetClientID(fmt.Sprintf("dsx-exchange-mcp-%d", time.Now().UnixNano())). + SetUsername(username). + SetPassword(bearer). + SetCleanSession(true). + SetAutoReconnect(false). + SetConnectTimeout(connectTimeout) + + if usesTLS(cfg.BrokerURL) || cfg.TLS.CAFile != "" || cfg.TLS.ServerName != "" || cfg.TLS.InsecureSkipVerify { + tlsCfg, err := buildTLSConfig(cfg.TLS) + if err != nil { + return out, err + } + opts.SetTLSConfig(tlsCfg) + } + + var ( + mu sync.Mutex + messages = make([]Message, 0, maxMessages) + resultBytes int + truncated bool + done = make(chan string, 1) + messageSeen = make(chan struct{}, 1) + closed bool + ) + finish := func(reason string) { + if !closed { + closed = true + done <- reason + } + } + + opts.SetDefaultPublishHandler(func(_ mqtt.Client, m mqtt.Message) { + mu.Lock() + defer mu.Unlock() + if closed { + return + } + msg := convertMessage(m) + nextBytes := resultBytes + len(msg.Topic) + len(msg.Payload) + if cfg.MaxResultBytes > 0 && nextBytes > cfg.MaxResultBytes { + truncated = true + finish(StoppedResultTooLarge) + return + } + resultBytes = nextBytes + messages = append(messages, msg) + select { + case messageSeen <- struct{}{}: + default: + } + if len(messages) >= maxMessages { + finish(StoppedMaxMessages) + } + }) + + c := mqtt.NewClient(opts) + if tok := c.Connect(); !tok.WaitTimeout(connectTimeout) { + return out, &BusError{Code: CodeBusUnavailable, Message: "mqtt connect timeout"} + } else if tok.Error() != nil { + return out, classifyConnectError(tok.Error()) + } + defer c.Disconnect(250) + + if tok := c.Subscribe(topicFilter, 0, nil); !tok.WaitTimeout(subscribeTimeout) { + return out, &BusError{Code: CodeBusUnavailable, Message: fmt.Sprintf("mqtt subscribe %q timeout", topicFilter)} + } else if tok.Error() != nil { + return out, classifySubscribeError(topicFilter, tok.Error()) + } else if err := classifySubscribeResult(topicFilter, tok); err != nil { + return out, err + } + + deadline := time.NewTimer(maxDuration) + defer deadline.Stop() + + var idle *time.Timer + if retainedOnly { + idle = time.NewTimer(750 * time.Millisecond) + defer idle.Stop() + } + + for { + var idleC <-chan time.Time + if idle != nil { + idleC = idle.C + } + select { + case <-ctx.Done(): + mu.Lock() + finish(StoppedCallerCancel) + out.Messages = append([]Message(nil), messages...) + out.StoppedReason = StoppedCallerCancel + out.Truncated = truncated + out.Duration = time.Since(start) + mu.Unlock() + return out, ctx.Err() + case <-deadline.C: + mu.Lock() + finish(StoppedMaxDuration) + out.Messages = append([]Message(nil), messages...) + out.StoppedReason = StoppedMaxDuration + out.Truncated = truncated + out.Duration = time.Since(start) + mu.Unlock() + return out, nil + case <-messageSeen: + if idle != nil { + if !idle.Stop() { + select { + case <-idle.C: + default: + } + } + idle.Reset(750 * time.Millisecond) + } + case <-idleC: + mu.Lock() + finish(StoppedRetainedIdle) + out.Messages = append([]Message(nil), messages...) + out.StoppedReason = StoppedRetainedIdle + out.Truncated = truncated + out.Duration = time.Since(start) + mu.Unlock() + return out, nil + case reason := <-done: + mu.Lock() + out.Messages = append([]Message(nil), messages...) + out.StoppedReason = reason + out.Truncated = truncated + out.Duration = time.Since(start) + mu.Unlock() + return out, nil + } + } +} + +// Stream opens an MQTT connection, subscribes to topicFilter, and calls +// onMessage for each message until a bound is reached or the context is +// cancelled. It is intended for async task workers that persist messages +// outside this package. +func Stream( + ctx context.Context, + cfg Config, + bearer, topicFilter string, + opts StreamOptions, + onMessage func(Message) error, +) (StreamResult, error) { + start := time.Now() + out := StreamResult{} + + if bearer == "" { + return out, &BusError{Code: CodeMissingBearer, Message: "missing caller bearer; gateway should pass Authorization through"} + } + if err := ValidateTopicFilter(topicFilter); err != nil { + return out, err + } + if opts.MaxMessages <= 0 { + return out, &BusError{Code: CodeInvalidArgument, Message: "max_messages must be greater than zero"} + } + if opts.MaxDuration <= 0 { + return out, &BusError{Code: CodeInvalidArgument, Message: "max_duration_s must be greater than zero"} + } + if cfg.BrokerURL == "" { + return out, &BusError{Code: CodeInvalidArgument, Message: "broker URL is required"} + } + if onMessage == nil { + return out, &BusError{Code: CodeInvalidArgument, Message: "onMessage callback is required"} + } + + connectTimeout := cfg.ConnectTimeout + if connectTimeout <= 0 { + connectTimeout = 5 * time.Second + } + subscribeTimeout := cfg.SubscribeTimeout + if subscribeTimeout <= 0 { + subscribeTimeout = 5 * time.Second + } + username := cfg.Username + if username == "" { + username = DefaultUsername + } + clientID := opts.ClientID + if clientID == "" { + clientID = fmt.Sprintf("dsx-exchange-mcp-task-%d", time.Now().UnixNano()) + } + + done := make(chan string, 1) + errs := make(chan error, 1) + finish := func(reason string) { + select { + case done <- reason: + default: + } + } + fail := func(err error) { + select { + case errs <- err: + default: + } + } + + optsMQTT := mqtt.NewClientOptions(). + AddBroker(cfg.BrokerURL). + SetClientID(clientID). + SetUsername(username). + SetPassword(bearer). + SetCleanSession(true). + SetAutoReconnect(false). + SetConnectTimeout(connectTimeout). + SetConnectionLostHandler(func(_ mqtt.Client, err error) { + if err != nil { + fail(&BusError{Code: CodeBusUnavailable, Message: "mqtt connection lost", Err: err}) + return + } + finish(StoppedBrokerError) + }) + + if usesTLS(cfg.BrokerURL) || cfg.TLS.CAFile != "" || cfg.TLS.ServerName != "" || cfg.TLS.InsecureSkipVerify { + tlsCfg, err := buildTLSConfig(cfg.TLS) + if err != nil { + return out, err + } + optsMQTT.SetTLSConfig(tlsCfg) + } + + var mu sync.Mutex + count := 0 + optsMQTT.SetDefaultPublishHandler(func(_ mqtt.Client, m mqtt.Message) { + msg := convertMessage(m) + if err := onMessage(msg); err != nil { + fail(&BusError{Code: CodeInternalError, Message: "persist MQTT stream message", Err: err}) + return + } + mu.Lock() + count++ + reached := count >= opts.MaxMessages + mu.Unlock() + if reached { + finish(StoppedMaxMessages) + } + }) + + c := mqtt.NewClient(optsMQTT) + if tok := c.Connect(); !tok.WaitTimeout(connectTimeout) { + return out, &BusError{Code: CodeBusUnavailable, Message: "mqtt connect timeout"} + } else if tok.Error() != nil { + return out, classifyConnectError(tok.Error()) + } + defer c.Disconnect(250) + + if tok := c.Subscribe(topicFilter, 0, nil); !tok.WaitTimeout(subscribeTimeout) { + return out, &BusError{Code: CodeBusUnavailable, Message: fmt.Sprintf("mqtt subscribe %q timeout", topicFilter)} + } else if tok.Error() != nil { + return out, classifySubscribeError(topicFilter, tok.Error()) + } else if err := classifySubscribeResult(topicFilter, tok); err != nil { + return out, err + } + if opts.OnSubscribed != nil { + opts.OnSubscribed() + } + + deadline := time.NewTimer(opts.MaxDuration) + defer deadline.Stop() + + for { + select { + case <-ctx.Done(): + mu.Lock() + out.Count = count + mu.Unlock() + out.StoppedReason = StoppedCallerCancel + out.Duration = time.Since(start) + return out, ctx.Err() + case <-deadline.C: + mu.Lock() + out.Count = count + mu.Unlock() + out.StoppedReason = StoppedMaxDuration + out.Duration = time.Since(start) + return out, nil + case err := <-errs: + mu.Lock() + out.Count = count + mu.Unlock() + out.StoppedReason = StoppedBrokerError + out.Duration = time.Since(start) + return out, err + case reason := <-done: + mu.Lock() + out.Count = count + mu.Unlock() + out.StoppedReason = reason + out.Duration = time.Since(start) + return out, nil + } + } +} + +func ValidateTopicFilter(filter string) error { + if filter == "" { + return &BusError{Code: CodeInvalidTopicFilter, Message: "topic_filter is required"} + } + levels := strings.Split(filter, "/") + for i, level := range levels { + if strings.Contains(level, "#") { + if level != "#" { + return &BusError{Code: CodeInvalidTopicFilter, Message: "# wildcard must occupy an entire topic level"} + } + if i != len(levels)-1 { + return &BusError{Code: CodeInvalidTopicFilter, Message: "# wildcard must be the final topic level"} + } + } + if strings.Contains(level, "+") && level != "+" { + return &BusError{Code: CodeInvalidTopicFilter, Message: "+ wildcard must occupy an entire topic level"} + } + } + return nil +} + +func buildTLSConfig(cfg TLSConfig) (*tls.Config, error) { + tlsCfg := &tls.Config{ + MinVersion: tls.VersionTLS12, + ServerName: cfg.ServerName, + InsecureSkipVerify: cfg.InsecureSkipVerify, + } + + if cfg.CAFile != "" { + pool, err := x509.SystemCertPool() + if err != nil || pool == nil { + pool = x509.NewCertPool() + } + body, err := os.ReadFile(cfg.CAFile) + if err != nil { + return nil, &BusError{Code: CodeTLSConfigError, Message: "read MQTT TLS CA file", Err: err} + } + if !pool.AppendCertsFromPEM(body) { + return nil, &BusError{Code: CodeTLSConfigError, Message: "MQTT TLS CA file contains no PEM certificates"} + } + tlsCfg.RootCAs = pool + } + + return tlsCfg, nil +} + +func usesTLS(url string) bool { + lower := strings.ToLower(url) + return strings.HasPrefix(lower, "tls://") || strings.HasPrefix(lower, "ssl://") || strings.HasPrefix(lower, "mqtts://") +} + +func convertMessage(m mqtt.Message) Message { + payload := m.Payload() + encoding := "utf8" + body := string(payload) + if !utf8.Valid(payload) { + encoding = "base64" + body = base64.StdEncoding.EncodeToString(payload) + } + return Message{ + Topic: m.Topic(), + Payload: body, + PayloadEncoding: encoding, + Retained: m.Retained(), + QoS: m.Qos(), + ReceivedAt: time.Now().UTC(), + } +} + +func classifyConnectError(err error) error { + msg := strings.ToLower(err.Error()) + switch { + case looksTLS(msg): + return &BusError{Code: CodeTLSHandshakeFailed, Message: "mqtt TLS handshake failed", Err: err} + case looksAuth(msg): + return &BusError{Code: CodeMQTTAuthFailed, Message: "mqtt broker rejected OAuth2 credentials", Err: err} + case looksUnavailable(msg): + return &BusError{Code: CodeBusUnavailable, Message: "mqtt broker unavailable", Err: err} + default: + return &BusError{Code: CodeBusUnavailable, Message: "mqtt connect failed", Err: err} + } +} + +func classifySubscribeError(topic string, err error) error { + msg := strings.ToLower(err.Error()) + switch { + case looksAuth(msg): + return &BusError{Code: CodeTopicACLDenied, Message: fmt.Sprintf("mqtt subscribe %q denied by broker ACL", topic), Err: err} + case looksUnavailable(msg): + return &BusError{Code: CodeBusUnavailable, Message: fmt.Sprintf("mqtt subscribe %q failed because broker is unavailable", topic), Err: err} + default: + return &BusError{Code: CodeMQTTSubscribeFailed, Message: fmt.Sprintf("mqtt subscribe %q failed", topic), Err: err} + } +} + +func classifySubscribeResult(topic string, tok mqtt.Token) error { + subTok, ok := tok.(*mqtt.SubscribeToken) + if !ok { + return nil + } + return classifySubscribeResultCode(topic, subTok.Result()) +} + +func classifySubscribeResultCode(topic string, result map[string]byte) error { + code, ok := result[topic] + if !ok { + return &BusError{Code: CodeMQTTSubscribeFailed, Message: fmt.Sprintf("mqtt subscribe %q returned no broker result", topic)} + } + switch code { + case 0, 1, 2: + return nil + case 0x80: + return &BusError{Code: CodeTopicACLDenied, Message: fmt.Sprintf("mqtt subscribe %q denied by broker ACL", topic)} + default: + return &BusError{Code: CodeMQTTSubscribeFailed, Message: fmt.Sprintf("mqtt subscribe %q returned invalid SUBACK code 0x%02x", topic, code)} + } +} + +func looksAuth(msg string) bool { + return strings.Contains(msg, "not authorized") || + strings.Contains(msg, "not authorised") || + strings.Contains(msg, "unauthorized") || + strings.Contains(msg, "unauthorised") || + strings.Contains(msg, "bad user") || + strings.Contains(msg, "username") || + strings.Contains(msg, "password") || + strings.Contains(msg, "authentication") || + strings.Contains(msg, "authorization") || + strings.Contains(msg, "permission") || + strings.Contains(msg, "acl") || + strings.Contains(msg, "forbidden") +} + +func looksTLS(msg string) bool { + return strings.Contains(msg, "tls") || + strings.Contains(msg, "x509") || + strings.Contains(msg, "certificate") || + strings.Contains(msg, "unknown authority") +} + +func looksUnavailable(msg string) bool { + return strings.Contains(msg, "connection refused") || + strings.Contains(msg, "no such host") || + strings.Contains(msg, "i/o timeout") || + strings.Contains(msg, "network") || + strings.Contains(msg, "timeout") || + strings.Contains(msg, "eof") || + strings.Contains(msg, "broken pipe") +} diff --git a/mcp/dsx-exchange-mcp/internal/mqttbus/client_test.go b/mcp/dsx-exchange-mcp/internal/mqttbus/client_test.go new file mode 100644 index 0000000..ceaa39d --- /dev/null +++ b/mcp/dsx-exchange-mcp/internal/mqttbus/client_test.go @@ -0,0 +1,107 @@ +// Copyright 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +package mqttbus + +import ( + "errors" + "strings" + "testing" +) + +func TestValidateTopicFilter(t *testing.T) { + tests := []struct { + name string + filter string + wantErr bool + }{ + {name: "plain", filter: "BMS/v1/PUB/Metadata/Rack"}, + {name: "single wildcard", filter: "BMS/+/PUB"}, + {name: "multi wildcard final", filter: "BMS/#"}, + {name: "empty", filter: "", wantErr: true}, + {name: "hash not final", filter: "BMS/#/Rack", wantErr: true}, + {name: "hash partial", filter: "BMS/foo#/Rack", wantErr: true}, + {name: "plus partial", filter: "BMS/foo+/Rack", wantErr: true}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := ValidateTopicFilter(tt.filter) + if tt.wantErr && err == nil { + t.Fatalf("ValidateTopicFilter(%q) succeeded, want error", tt.filter) + } + if !tt.wantErr && err != nil { + t.Fatalf("ValidateTopicFilter(%q) failed: %v", tt.filter, err) + } + }) + } +} + +func TestErrorCode(t *testing.T) { + err := &BusError{Code: CodeMQTTAuthFailed, Message: "denied"} + if got := ErrorCode(err); got != CodeMQTTAuthFailed { + t.Fatalf("ErrorCode = %q, want %q", got, CodeMQTTAuthFailed) + } + if got := ErrorCode(errors.New("plain")); got != CodeInternalError { + t.Fatalf("ErrorCode for plain error = %q, want %q", got, CodeInternalError) + } +} + +func TestClassifyConnectError(t *testing.T) { + tests := []struct { + name string + err error + code string + }{ + {name: "auth", err: errors.New("not authorized"), code: CodeMQTTAuthFailed}, + {name: "tls", err: errors.New("x509: certificate signed by unknown authority"), code: CodeTLSHandshakeFailed}, + {name: "unavailable", err: errors.New("connection refused"), code: CodeBusUnavailable}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := classifyConnectError(tt.err) + if got := ErrorCode(err); got != tt.code { + t.Fatalf("code = %q, want %q (err=%v)", got, tt.code, err) + } + }) + } +} + +func TestClassifySubscribeResult(t *testing.T) { + tests := []struct { + name string + results map[string]byte + want string + }{ + {name: "granted qos 0", results: map[string]byte{"BMS/#": 0}}, + {name: "denied", results: map[string]byte{"BMS/#": 0x80}, want: CodeTopicACLDenied}, + {name: "missing result", results: map[string]byte{}, want: CodeMQTTSubscribeFailed}, + {name: "invalid code", results: map[string]byte{"BMS/#": 0x81}, want: CodeMQTTSubscribeFailed}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := classifySubscribeResultCode("BMS/#", tt.results) + if tt.want == "" && err != nil { + t.Fatalf("classifySubscribeResultCode returned error: %v", err) + } + if tt.want != "" && ErrorCode(err) != tt.want { + t.Fatalf("code = %q, want %q (err=%v)", ErrorCode(err), tt.want, err) + } + }) + } +} + +func TestBuildTLSConfigMissingCA(t *testing.T) { + _, err := buildTLSConfig(TLSConfig{CAFile: "/no/such/ca.crt"}) + if err == nil { + t.Fatalf("buildTLSConfig succeeded with missing CA file") + } + if got := ErrorCode(err); got != CodeTLSConfigError { + t.Fatalf("code = %q, want %q", got, CodeTLSConfigError) + } + if !strings.Contains(err.Error(), "no/such/ca.crt") { + t.Fatalf("error did not include CA path context: %v", err) + } +} diff --git a/mcp/dsx-exchange-mcp/internal/mqttbus/e2e_test.go b/mcp/dsx-exchange-mcp/internal/mqttbus/e2e_test.go new file mode 100644 index 0000000..8e67e5c --- /dev/null +++ b/mcp/dsx-exchange-mcp/internal/mqttbus/e2e_test.go @@ -0,0 +1,85 @@ +// Copyright 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +package mqttbus + +import ( + "context" + "os" + "testing" + "time" +) + +func TestDeployedBusE2EAllowedTopic(t *testing.T) { + if os.Getenv("RUN_EXCHANGE_E2E_DEPLOYED_BUS") != "1" { + t.Skip("set RUN_EXCHANGE_E2E_DEPLOYED_BUS=1 to run deployed-bus e2e") + } + brokerURL := requiredEnv(t, "DSX_EXCHANGE_MQTT_URL") + bearer := requiredEnv(t, "DSX_EXCHANGE_E2E_BEARER") + topic := requiredEnv(t, "DSX_EXCHANGE_E2E_ALLOWED_TOPIC") + + ctx, cancel := context.WithTimeout(context.Background(), 45*time.Second) + defer cancel() + + res, err := Collect(ctx, Config{ + BrokerURL: brokerURL, + Username: envOrDefault("DSX_EXCHANGE_MQTT_USERNAME", DefaultUsername), + TLS: TLSConfig{ + CAFile: os.Getenv("DSX_EXCHANGE_MQTT_CA_FILE"), + ServerName: os.Getenv("DSX_EXCHANGE_MQTT_SERVER_NAME"), + }, + MaxResultBytes: 1048576, + }, bearer, topic, 1, 10*time.Second, false) + if err != nil { + t.Fatalf("deployed bus subscribe failed with code %q: %v", ErrorCode(err), err) + } + if res.StoppedReason == "" { + t.Fatalf("stopped reason was empty") + } +} + +func TestDeployedBusE2EDeniedTopic(t *testing.T) { + if os.Getenv("RUN_EXCHANGE_E2E_DEPLOYED_BUS") != "1" { + t.Skip("set RUN_EXCHANGE_E2E_DEPLOYED_BUS=1 to run deployed-bus e2e") + } + brokerURL := requiredEnv(t, "DSX_EXCHANGE_MQTT_URL") + bearer := requiredEnv(t, "DSX_EXCHANGE_E2E_BEARER") + topic := requiredEnv(t, "DSX_EXCHANGE_E2E_DENIED_TOPIC") + + ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second) + defer cancel() + + _, err := Collect(ctx, Config{ + BrokerURL: brokerURL, + Username: envOrDefault("DSX_EXCHANGE_MQTT_USERNAME", DefaultUsername), + TLS: TLSConfig{ + CAFile: os.Getenv("DSX_EXCHANGE_MQTT_CA_FILE"), + ServerName: os.Getenv("DSX_EXCHANGE_MQTT_SERVER_NAME"), + }, + MaxResultBytes: 1048576, + }, bearer, topic, 1, 5*time.Second, false) + if err == nil { + t.Fatalf("denied topic unexpectedly succeeded") + } + switch ErrorCode(err) { + case CodeTopicACLDenied, CodeMQTTAuthorizationFailed, CodeMQTTAuthFailed, CodeMQTTSubscribeFailed: + default: + t.Fatalf("denied topic returned code %q, want auth/subscription failure: %v", ErrorCode(err), err) + } +} + +func requiredEnv(t *testing.T, key string) string { + t.Helper() + v := os.Getenv(key) + if v == "" { + t.Fatalf("%s is required", key) + } + return v +} + +func envOrDefault(key, fallback string) string { + if v := os.Getenv(key); v != "" { + return v + } + return fallback +} diff --git a/mcp/dsx-exchange-mcp/internal/schemaindex/index.go b/mcp/dsx-exchange-mcp/internal/schemaindex/index.go new file mode 100644 index 0000000..7002107 --- /dev/null +++ b/mcp/dsx-exchange-mcp/internal/schemaindex/index.go @@ -0,0 +1,759 @@ +// Copyright 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +package schemaindex + +import ( + "errors" + "fmt" + "io/fs" + "path" + "sort" + "strings" + "sync" + + "gopkg.in/yaml.v3" + + "github.com/NVIDIA/dsx-exchange/mcp/dsx-exchange-mcp/schemas" +) + +type Index struct { + topics []Topic +} + +type SearchOptions struct { + Domain string + Query string + Role string + ObjectType string + PointType string + OperationAction string + Limit int +} + +type Topic struct { + Domain string `json:"domain"` + SpecTitle string `json:"spec_title,omitempty"` + SpecVersion string `json:"spec_version,omitempty"` + Channel string `json:"channel"` + Address string `json:"address"` + TopicFilter string `json:"topic_filter"` + Description string `json:"description,omitempty"` + RetainedLiveBehavior string `json:"retained_live_behavior,omitempty"` + MatchedParameters map[string]string `json:"matched_parameters,omitempty"` + Parameters []ParameterSummary `json:"parameters,omitempty"` + Messages []MessageSummary `json:"messages,omitempty"` + Operations []OperationSummary `json:"operations,omitempty"` + RelatedTopics []RelatedTopic `json:"related_topics,omitempty"` + Examples []string `json:"examples,omitempty"` +} + +type ParameterSummary struct { + Name string `json:"name"` + Description string `json:"description,omitempty"` + Enum []string `json:"enum,omitempty"` +} + +type MessageSummary struct { + Name string `json:"name"` + Ref string `json:"ref,omitempty"` + Title string `json:"title,omitempty"` + Summary string `json:"summary,omitempty"` + Description string `json:"description,omitempty"` + Payload PayloadShape `json:"payload,omitempty"` +} + +type PayloadShape struct { + Ref string `json:"ref,omitempty"` + Type string `json:"type,omitempty"` + Required []string `json:"required,omitempty"` + Properties []PropertySummary `json:"properties,omitempty"` + AllOf []string `json:"all_of,omitempty"` + OneOf []string `json:"one_of,omitempty"` +} + +type PropertySummary struct { + Name string `json:"name"` + Type string `json:"type,omitempty"` + Ref string `json:"ref,omitempty"` + Description string `json:"description,omitempty"` + Enum []string `json:"enum,omitempty"` +} + +type OperationSummary struct { + Name string `json:"name"` + Action string `json:"action,omitempty"` + Summary string `json:"summary,omitempty"` + Description string `json:"description,omitempty"` +} + +type RelatedTopic struct { + Role string `json:"role"` + TopicFilter string `json:"topic_filter"` +} + +type document struct { + AsyncAPI string `yaml:"asyncapi"` + Info info `yaml:"info"` + Channels map[string]channel `yaml:"channels"` + Operations map[string]operation `yaml:"operations"` + Components components `yaml:"components"` +} + +type info struct { + Title string `yaml:"title"` + Version string `yaml:"version"` + Description string `yaml:"description"` +} + +type channel struct { + Ref string `yaml:"$ref"` + Address string `yaml:"address"` + Description string `yaml:"description"` + Parameters map[string]parameter `yaml:"parameters"` + Messages map[string]messageRef `yaml:"messages"` +} + +type parameter struct { + Ref string `yaml:"$ref"` + Description string `yaml:"description"` + Enum []string `yaml:"enum"` +} + +type messageRef struct { + Ref string `yaml:"$ref"` + Name string `yaml:"name"` + Title string `yaml:"title"` + Summary string `yaml:"summary"` + Description string `yaml:"description"` + Payload map[string]any `yaml:"payload"` +} + +type message struct { + Name string `yaml:"name"` + Title string `yaml:"title"` + Summary string `yaml:"summary"` + Description string `yaml:"description"` + Payload map[string]any `yaml:"payload"` +} + +type operation struct { + Action string `yaml:"action"` + Summary string `yaml:"summary"` + Description string `yaml:"description"` + Channel reference `yaml:"channel"` + Messages []reference `yaml:"messages"` +} + +type reference struct { + Ref string `yaml:"$ref"` +} + +type components struct { + Messages map[string]message `yaml:"messages"` + Parameters map[string]parameter `yaml:"parameters"` + Schemas map[string]map[string]any `yaml:"schemas"` +} + +var errMissingAsyncAPI = errors.New("missing asyncapi version") + +var ( + defaultOnce sync.Once + defaultIdx *Index + defaultErr error +) + +func Default() (*Index, error) { + defaultOnce.Do(func() { + defaultIdx, defaultErr = Load() + }) + return defaultIdx, defaultErr +} + +func Load() (*Index, error) { + var topics []Topic + err := fs.WalkDir(schemas.FS, "asyncapi", func(p string, d fs.DirEntry, err error) error { + if err != nil || d.IsDir() { + return err + } + ext := strings.ToLower(path.Ext(p)) + if ext != ".yaml" && ext != ".yml" && ext != ".json" { + return nil + } + body, err := schemas.FS.ReadFile(p) + if err != nil { + return err + } + domain := path.Base(path.Dir(p)) + doc, err := parseDocument(p, body) + if err != nil { + if errors.Is(err, errMissingAsyncAPI) { + return nil + } + return err + } + topics = append(topics, docTopics(domain, doc)...) + return nil + }) + if err != nil { + return nil, err + } + sortTopics(topics) + return &Index{topics: topics}, nil +} + +func ParseDocumentForTest(domain string, body []byte) (*Index, error) { + doc, err := parseDocument(domain+".yaml", body) + if err != nil { + return nil, err + } + topics := docTopics(domain, doc) + sortTopics(topics) + return &Index{topics: topics}, nil +} + +func (idx *Index) Describe(topicFilter string) []Topic { + topicFilter = strings.TrimSpace(topicFilter) + if topicFilter == "" { + return nil + } + var out []Topic + for _, topic := range idx.topics { + if !matchesAddress(topic.Address, topicFilter) && !filtersOverlap(topic.TopicFilter, topicFilter) { + continue + } + topic.MatchedParameters = inferParameters(topic.Address, topicFilter) + topic.RelatedTopics = relatedTopics(topic.Address, topic.MatchedParameters) + topic.Examples = examples(topic) + out = append(out, topic) + } + sortTopics(out) + return out +} + +func (idx *Index) Search(opts SearchOptions) []Topic { + domain := strings.ToLower(strings.TrimSpace(opts.Domain)) + query := strings.ToLower(strings.TrimSpace(opts.Query)) + role := strings.ToLower(strings.TrimSpace(opts.Role)) + objectType := strings.TrimSpace(opts.ObjectType) + pointType := strings.TrimSpace(opts.PointType) + action := strings.ToLower(strings.TrimSpace(opts.OperationAction)) + limit := opts.Limit + + var out []Topic + for _, topic := range idx.topics { + if domain != "" && strings.ToLower(topic.Domain) != domain { + continue + } + if role != "" && !matchesRole(topic, role) { + continue + } + if objectType != "" && (!matchesAddressValue(topic.Address, "objectType", objectType) || !parameterAllows(topic.Parameters, "objectType", objectType)) { + continue + } + if pointType != "" && (!matchesAddressValue(topic.Address, "pointType", pointType) || !parameterAllows(topic.Parameters, "pointType", pointType)) { + continue + } + if action != "" && !matchesOperationAction(topic.Operations, action) { + continue + } + if query != "" && !topicContains(topic, query) { + continue + } + + values := map[string]string{} + if objectType != "" { + values["objectType"] = objectType + } + if pointType != "" { + values["pointType"] = pointType + } + topic.TopicFilter = addressToFilter(topic.Address, values) + topic.MatchedParameters = valuesOrNil(values) + topic.RelatedTopics = relatedTopics(topic.Address, topic.MatchedParameters) + topic.Examples = examples(topic) + out = append(out, topic) + if limit > 0 && len(out) >= limit { + break + } + } + sortTopics(out) + return out +} + +func parseDocument(name string, body []byte) (document, error) { + var doc document + if err := yaml.Unmarshal(body, &doc); err != nil { + return document{}, fmt.Errorf("parse %s: %w", name, err) + } + if strings.TrimSpace(doc.AsyncAPI) == "" { + return document{}, fmt.Errorf("parse %s: %w", name, errMissingAsyncAPI) + } + return doc, nil +} + +func docTopics(domain string, doc document) []Topic { + names := make([]string, 0, len(doc.Channels)) + for name := range doc.Channels { + names = append(names, name) + } + sort.Strings(names) + + topics := make([]Topic, 0, len(names)) + for _, name := range names { + ch := doc.Channels[name] + if ch.Address == "" { + continue + } + topics = append(topics, Topic{ + Domain: domain, + SpecTitle: doc.Info.Title, + SpecVersion: doc.Info.Version, + Channel: name, + Address: ch.Address, + TopicFilter: addressToFilter(ch.Address, nil), + Description: strings.TrimSpace(ch.Description), + RetainedLiveBehavior: retainedLiveBehavior(ch.Address), + Parameters: summarizeParameters(ch.Parameters, doc.Components.Parameters), + Messages: summarizeMessages(ch.Messages, doc.Components), + Operations: summarizeOperations(name, doc.Operations), + }) + } + return topics +} + +func summarizeParameters(params map[string]parameter, components map[string]parameter) []ParameterSummary { + names := make([]string, 0, len(params)) + for name := range params { + names = append(names, name) + } + sort.Strings(names) + + out := make([]ParameterSummary, 0, len(names)) + for _, name := range names { + p := params[name] + if p.Ref != "" { + if resolved, ok := components[refName(p.Ref)]; ok { + p = resolved + } + } + out = append(out, ParameterSummary{ + Name: name, + Description: strings.TrimSpace(p.Description), + Enum: append([]string{}, p.Enum...), + }) + } + return out +} + +func summarizeMessages(refs map[string]messageRef, components components) []MessageSummary { + names := make([]string, 0, len(refs)) + for name := range refs { + names = append(names, name) + } + sort.Strings(names) + + out := make([]MessageSummary, 0, len(names)) + for _, name := range names { + ref := refs[name] + msg := message{ + Name: ref.Name, + Title: ref.Title, + Summary: ref.Summary, + Description: ref.Description, + Payload: ref.Payload, + } + if ref.Ref != "" { + if resolved, ok := components.Messages[refName(ref.Ref)]; ok { + msg = resolved + } + } + out = append(out, MessageSummary{ + Name: firstNonEmpty(msg.Name, name), + Ref: ref.Ref, + Title: msg.Title, + Summary: msg.Summary, + Description: strings.TrimSpace(msg.Description), + Payload: summarizePayload(msg.Payload, components.Schemas), + }) + } + return out +} + +func summarizeOperations(channelName string, operations map[string]operation) []OperationSummary { + var out []OperationSummary + for name, op := range operations { + if refName(op.Channel.Ref) != channelName { + continue + } + out = append(out, OperationSummary{ + Name: name, + Action: op.Action, + Summary: op.Summary, + Description: strings.TrimSpace(op.Description), + }) + } + sort.Slice(out, func(i, j int) bool { + return out[i].Name < out[j].Name + }) + return out +} + +func summarizePayload(payload map[string]any, schemas map[string]map[string]any) PayloadShape { + if len(payload) == 0 { + return PayloadShape{} + } + if ref, _ := payload["$ref"].(string); ref != "" { + shape := summarizeSchema(schemas[refName(ref)]) + shape.Ref = ref + return shape + } + return summarizeSchema(payload) +} + +func summarizeSchema(schema map[string]any) PayloadShape { + if len(schema) == 0 { + return PayloadShape{} + } + shape := PayloadShape{ + Type: stringValue(schema["type"]), + Required: stringSlice(schema["required"]), + AllOf: refList(schema["allOf"]), + OneOf: refList(schema["oneOf"]), + } + props := mapValue(schema["properties"]) + names := make([]string, 0, len(props)) + for name := range props { + names = append(names, name) + } + sort.Strings(names) + for _, name := range names { + prop := mapValue(props[name]) + shape.Properties = append(shape.Properties, PropertySummary{ + Name: name, + Type: stringValue(prop["type"]), + Ref: stringValue(prop["$ref"]), + Description: strings.TrimSpace(stringValue(prop["description"])), + Enum: stringSlice(prop["enum"]), + }) + } + return shape +} + +func addressToFilter(address string, values map[string]string) string { + parts := strings.Split(address, "/") + for i, part := range parts { + name, ok := placeholderName(part) + if !ok { + continue + } + if v := strings.TrimSpace(values[name]); v != "" { + parts[i] = v + continue + } + if i == len(parts)-1 && strings.Contains(strings.ToLower(name), "path") { + parts[i] = "#" + } else { + parts[i] = "+" + } + } + return strings.Join(parts, "/") +} + +func matchesAddress(address, topic string) bool { + addressParts := strings.Split(strings.Trim(address, "/"), "/") + topicParts := strings.Split(strings.Trim(topic, "/"), "/") + for i, part := range addressParts { + if i >= len(topicParts) { + return false + } + if name, ok := placeholderName(part); ok { + if i == len(addressParts)-1 && strings.Contains(strings.ToLower(name), "path") { + return true + } + continue + } + if topicParts[i] == "+" || topicParts[i] == "#" { + continue + } + if part != topicParts[i] { + return false + } + } + return len(addressParts) == len(topicParts) || strings.HasSuffix(topic, "/#") +} + +func filtersOverlap(a, b string) bool { + ap := strings.Split(strings.Trim(a, "/"), "/") + bp := strings.Split(strings.Trim(b, "/"), "/") + for i := 0; i < len(ap) && i < len(bp); i++ { + if ap[i] == "#" || bp[i] == "#" { + return true + } + if ap[i] == "+" || bp[i] == "+" { + continue + } + if ap[i] != bp[i] { + return false + } + } + return len(ap) == len(bp) +} + +func matchesRole(topic Topic, role string) bool { + switch role { + case "metadata": + return strings.Contains(topic.Address, "/Metadata/") + case "value": + return strings.Contains(topic.Address, "/Value/") + case "event": + return !strings.Contains(topic.Address, "/Metadata/") && !strings.Contains(topic.Address, "/Value/") + default: + return strings.Contains(strings.ToLower(topic.RetainedLiveBehavior), role) || + strings.Contains(strings.ToLower(topic.Address), role) || + strings.Contains(strings.ToLower(topic.Channel), role) + } +} + +func matchesAddressValue(address, name, value string) bool { + value = strings.TrimSpace(value) + if value == "" { + return true + } + parts := strings.Split(strings.Trim(address, "/"), "/") + for i, part := range parts { + placeholder, ok := placeholderName(part) + if ok && placeholder == name { + return true + } + if !ok && strings.EqualFold(part, value) { + switch name { + case "objectType": + return i > 0 && (parts[i-1] == "Value" || parts[i-1] == "Metadata") + case "pointType": + return i > 1 && (parts[i-2] == "Value" || parts[i-2] == "Metadata") + default: + return true + } + } + } + return false +} + +func parameterAllows(params []ParameterSummary, name, value string) bool { + for _, param := range params { + if param.Name != name { + continue + } + if len(param.Enum) == 0 { + return true + } + for _, allowed := range param.Enum { + if strings.EqualFold(allowed, value) { + return true + } + } + return false + } + return true +} + +func matchesOperationAction(ops []OperationSummary, action string) bool { + for _, op := range ops { + if strings.EqualFold(op.Action, action) { + return true + } + } + return false +} + +func topicContains(topic Topic, query string) bool { + if strings.Contains(strings.ToLower(topic.Domain), query) || + strings.Contains(strings.ToLower(topic.SpecTitle), query) || + strings.Contains(strings.ToLower(topic.Channel), query) || + strings.Contains(strings.ToLower(topic.Address), query) || + strings.Contains(strings.ToLower(topic.Description), query) || + strings.Contains(strings.ToLower(topic.RetainedLiveBehavior), query) { + return true + } + for _, msg := range topic.Messages { + if strings.Contains(strings.ToLower(msg.Name), query) || + strings.Contains(strings.ToLower(msg.Title), query) || + strings.Contains(strings.ToLower(msg.Summary), query) || + strings.Contains(strings.ToLower(msg.Description), query) || + strings.Contains(strings.ToLower(msg.Payload.Ref), query) { + return true + } + } + for _, op := range topic.Operations { + if strings.Contains(strings.ToLower(op.Name), query) || + strings.Contains(strings.ToLower(op.Action), query) || + strings.Contains(strings.ToLower(op.Summary), query) || + strings.Contains(strings.ToLower(op.Description), query) { + return true + } + } + return false +} + +func valuesOrNil(values map[string]string) map[string]string { + clean := map[string]string{} + for k, v := range values { + if strings.TrimSpace(v) != "" { + clean[k] = v + } + } + if len(clean) == 0 { + return nil + } + return clean +} + +func inferParameters(address, topic string) map[string]string { + addressParts := strings.Split(strings.Trim(address, "/"), "/") + topicParts := strings.Split(strings.Trim(topic, "/"), "/") + out := map[string]string{} + for i, part := range addressParts { + name, ok := placeholderName(part) + if !ok || i >= len(topicParts) { + continue + } + if i == len(addressParts)-1 && strings.Contains(strings.ToLower(name), "path") { + out[name] = strings.Join(topicParts[i:], "/") + continue + } + out[name] = topicParts[i] + } + if len(out) == 0 { + return nil + } + return out +} + +func relatedTopics(address string, values map[string]string) []RelatedTopic { + switch { + case strings.Contains(address, "/Value/"): + return []RelatedTopic{{ + Role: "metadata", + TopicFilter: addressToFilter(strings.Replace(address, "/Value/", "/Metadata/", 1), values), + }} + case strings.Contains(address, "/Metadata/"): + return []RelatedTopic{{ + Role: "value", + TopicFilter: addressToFilter(strings.Replace(address, "/Metadata/", "/Value/", 1), values), + }} + default: + return nil + } +} + +func retainedLiveBehavior(address string) string { + switch { + case strings.Contains(address, "/Metadata/"): + return "metadata channel; expected to be useful with dsx_exchange_read_retained before sampling related live values" + case strings.Contains(address, "/Value/"): + return "live value channel; use dsx_exchange_subscribe and read related metadata first when available" + default: + return "schema-defined channel; use the channel description and broker ACLs to decide whether retained reads or live subscription are appropriate" + } +} + +func examples(topic Topic) []string { + out := []string{topic.TopicFilter} + if len(topic.MatchedParameters) > 0 { + filter := addressToFilter(topic.Address, topic.MatchedParameters) + if filter != topic.TopicFilter { + out = append(out, filter) + } + } + return out +} + +func placeholderName(part string) (string, bool) { + if strings.HasPrefix(part, "{") && strings.HasSuffix(part, "}") && len(part) > 2 { + return part[1 : len(part)-1], true + } + return "", false +} + +func refName(ref string) string { + idx := strings.LastIndex(ref, "/") + if idx < 0 || idx == len(ref)-1 { + return ref + } + return ref[idx+1:] +} + +func firstNonEmpty(values ...string) string { + for _, v := range values { + if strings.TrimSpace(v) != "" { + return v + } + } + return "" +} + +func sortTopics(topics []Topic) { + sort.Slice(topics, func(i, j int) bool { + if topics[i].Domain != topics[j].Domain { + return topics[i].Domain < topics[j].Domain + } + return topics[i].Channel < topics[j].Channel + }) +} + +func mapValue(v any) map[string]any { + switch typed := v.(type) { + case map[string]any: + return typed + case map[any]any: + out := map[string]any{} + for k, v := range typed { + if s, ok := k.(string); ok { + out[s] = v + } + } + return out + default: + return nil + } +} + +func stringValue(v any) string { + if s, ok := v.(string); ok { + return s + } + return "" +} + +func stringSlice(v any) []string { + switch typed := v.(type) { + case []string: + return append([]string{}, typed...) + case []any: + out := make([]string, 0, len(typed)) + for _, item := range typed { + if s, ok := item.(string); ok { + out = append(out, s) + } + } + return out + default: + return nil + } +} + +func refList(v any) []string { + items, ok := v.([]any) + if !ok { + return nil + } + out := make([]string, 0, len(items)) + for _, item := range items { + ref := stringValue(mapValue(item)["$ref"]) + if ref != "" { + out = append(out, ref) + } + } + return out +} diff --git a/mcp/dsx-exchange-mcp/internal/schemaindex/index_test.go b/mcp/dsx-exchange-mcp/internal/schemaindex/index_test.go new file mode 100644 index 0000000..0062ac6 --- /dev/null +++ b/mcp/dsx-exchange-mcp/internal/schemaindex/index_test.go @@ -0,0 +1,96 @@ +// Copyright 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +package schemaindex + +import "testing" + +func TestDefaultDescribeBMSValueTopic(t *testing.T) { + idx, err := Default() + if err != nil { + t.Fatalf("load default schema index: %v", err) + } + + matches := idx.Describe("BMS/v1/PUB/Value/Rack/RackLiquidIsolationStatus/#") + if len(matches) == 0 { + t.Fatal("Describe returned no matches") + } + + got := matches[0] + if got.Domain != "bms" { + t.Fatalf("domain = %q, want bms", got.Domain) + } + if got.Channel != "rackBmsValue" { + t.Fatalf("channel = %q, want rackBmsValue", got.Channel) + } + if got.MatchedParameters["pointType"] != "RackLiquidIsolationStatus" { + t.Fatalf("pointType = %q, want RackLiquidIsolationStatus", got.MatchedParameters["pointType"]) + } + if len(got.RelatedTopics) != 1 || got.RelatedTopics[0].TopicFilter != "BMS/v1/PUB/Metadata/Rack/RackLiquidIsolationStatus/#" { + t.Fatalf("related topics = %#v, want metadata counterpart", got.RelatedTopics) + } + if len(got.Messages) == 0 || got.Messages[0].Payload.Type != "object" { + t.Fatalf("message payload summary = %#v, want object payload", got.Messages) + } +} + +func TestDefaultDescribePowerManagementTopic(t *testing.T) { + idx, err := Default() + if err != nil { + t.Fatalf("load default schema index: %v", err) + } + + matches := idx.Describe("grid/v1/poweragent/+/powerbreach") + if len(matches) == 0 { + t.Fatal("Describe returned no matches") + } + if got := matches[0].Domain; got != "power-management" { + t.Fatalf("domain = %q, want power-management", got) + } + if got := matches[0].MatchedParameters["identifier"]; got != "+" { + t.Fatalf("identifier parameter = %q, want +", got) + } +} + +func TestDefaultSearchBMSSelectorBuildsTopicFilter(t *testing.T) { + idx, err := Default() + if err != nil { + t.Fatalf("load default schema index: %v", err) + } + + matches := idx.Search(SearchOptions{ + Domain: "bms", + Role: "value", + ObjectType: "Rack", + PointType: "RackLiquidIsolationStatus", + Limit: 10, + }) + if len(matches) != 1 { + t.Fatalf("Search returned %d matches, want 1: %#v", len(matches), matches) + } + if got := matches[0].TopicFilter; got != "BMS/v1/PUB/Value/Rack/RackLiquidIsolationStatus/#" { + t.Fatalf("topic filter = %q, want BMS rack value filter", got) + } + if len(matches[0].RelatedTopics) != 1 || matches[0].RelatedTopics[0].TopicFilter != "BMS/v1/PUB/Metadata/Rack/RackLiquidIsolationStatus/#" { + t.Fatalf("related topics = %#v, want metadata counterpart", matches[0].RelatedTopics) + } +} + +func TestDefaultSearchQueryFindsNICO(t *testing.T) { + idx, err := Default() + if err != nil { + t.Fatalf("load default schema index: %v", err) + } + + matches := idx.Search(SearchOptions{ + Domain: "nico", + Query: "state", + Limit: 5, + }) + if len(matches) == 0 { + t.Fatal("Search returned no NICO state matches") + } + if matches[0].Domain != "nico" { + t.Fatalf("domain = %q, want nico", matches[0].Domain) + } +} diff --git a/mcp/dsx-exchange-mcp/internal/server/e2e_test.go b/mcp/dsx-exchange-mcp/internal/server/e2e_test.go new file mode 100644 index 0000000..5376fa9 --- /dev/null +++ b/mcp/dsx-exchange-mcp/internal/server/e2e_test.go @@ -0,0 +1,361 @@ +// Copyright 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +package server + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "os" + "strings" + "testing" + "time" +) + +func TestStagedMCPE2EDeployedBus(t *testing.T) { + if os.Getenv("RUN_EXCHANGE_MCP_E2E") != "1" { + t.Skip("set RUN_EXCHANGE_MCP_E2E=1 to run staged MCP e2e") + } + + endpoint := requiredEnv(t, "DSX_EXCHANGE_MCP_URL") + bearer := requiredEnv(t, "DSX_EXCHANGE_E2E_BEARER") + allowedTopic := requiredEnv(t, "DSX_EXCHANGE_E2E_ALLOWED_TOPIC") + + ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second) + defer cancel() + + client := &mcpHTTPClient{ + endpoint: endpoint, + bearer: bearer, + httpc: &http.Client{Timeout: 30 * time.Second}, + } + + sessionID, err := client.initialize(ctx) + if err != nil { + t.Fatalf("initialize through MCP endpoint failed: %v", err) + } + if err := client.initialized(ctx, sessionID); err != nil { + t.Fatalf("notifications/initialized failed: %v", err) + } + + tools, err := client.listTools(ctx, sessionID) + if err != nil { + t.Fatalf("tools/list failed: %v", err) + } + toolName := chooseSubscribeToolName(tools, os.Getenv("DSX_EXCHANGE_E2E_TOOL_NAME")) + if toolName == "" { + t.Fatalf("tools/list did not expose dsx_exchange_subscribe (tools: %v)", tools) + } + + res, err := client.callTool(ctx, sessionID, toolName, map[string]any{ + "topic_filter": allowedTopic, + "max_messages": 1, + "max_duration_s": 5, + }) + if err != nil { + t.Fatalf("tools/call(%q allowed topic) failed: %v", toolName, err) + } + if res.IsError { + t.Fatalf("tools/call(%q allowed topic) returned MCP tool error: %s", toolName, res.textSummary()) + } + + if deniedTopic := os.Getenv("DSX_EXCHANGE_E2E_DENIED_TOPIC"); deniedTopic != "" { + denied, err := client.callTool(ctx, sessionID, toolName, map[string]any{ + "topic_filter": deniedTopic, + "max_messages": 1, + "max_duration_s": 5, + }) + if err == nil && !denied.IsError { + t.Fatalf("tools/call(%q denied topic) unexpectedly succeeded", toolName) + } + } +} + +func TestStagedMCPSchemaDescribeThroughEndpoint(t *testing.T) { + if os.Getenv("RUN_EXCHANGE_MCP_SCHEMA_E2E") != "1" { + t.Skip("set RUN_EXCHANGE_MCP_SCHEMA_E2E=1 to run staged MCP schema e2e") + } + + endpoint := requiredEnv(t, "DSX_EXCHANGE_MCP_URL") + bearer := requiredEnv(t, "DSX_EXCHANGE_E2E_BEARER") + + ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second) + defer cancel() + + client := &mcpHTTPClient{ + endpoint: endpoint, + bearer: bearer, + httpc: &http.Client{Timeout: 30 * time.Second}, + } + + sessionID, err := client.initialize(ctx) + if err != nil { + t.Fatalf("initialize through MCP endpoint failed: %v", err) + } + if err := client.initialized(ctx, sessionID); err != nil { + t.Fatalf("notifications/initialized failed: %v", err) + } + + tools, err := client.listTools(ctx, sessionID) + if err != nil { + t.Fatalf("tools/list failed: %v", err) + } + toolName := chooseDescribeTopicToolName(tools, os.Getenv("DSX_EXCHANGE_E2E_DESCRIBE_TOOL_NAME")) + if toolName == "" { + t.Fatalf("tools/list did not expose %s (tools: %v)", toolDescribeTopic, tools) + } + t.Logf("using schema tool %q from endpoint %s", toolName, endpoint) + + for _, fixture := range loadToolCallFixtures(t) { + t.Run(fixture.ID, func(t *testing.T) { + for i, call := range fixture.ExpectedToolCalls { + if call.Tool != toolDescribeTopic { + continue + } + topicFilter := stringArg(t, fixture.ID, i, call.Arguments, "topic_filter") + res, err := client.callTool(ctx, sessionID, toolName, call.Arguments) + if err != nil { + t.Fatalf("tools/call(%q, %q) failed: %v", toolName, topicFilter, err) + } + if res.IsError { + t.Fatalf("tools/call(%q, %q) returned MCP tool error: %s", toolName, topicFilter, res.textSummary()) + } + + var out describeTopicOutput + if err := json.Unmarshal([]byte(res.lastText()), &out); err != nil { + t.Fatalf("decode schema response for %q: %v; content=%s", topicFilter, err, res.textSummary()) + } + if out.TopicFilter != topicFilter { + t.Fatalf("schema response topic_filter = %q, want %q", out.TopicFilter, topicFilter) + } + if out.Count == 0 { + t.Fatalf("schema response for %q returned no matches", topicFilter) + } + if !hasDomainChannel(out, fixture.ExpectedSchema.Domain, fixture.ExpectedSchema.Channels) { + t.Fatalf("schema response for %q missing expected domain/channel; result=%#v", topicFilter, out.Matches) + } + t.Logf("%s -> %d match(es); first=%s/%s", topicFilter, out.Count, out.Matches[0].Domain, out.Matches[0].Channel) + } + }) + } +} + +type mcpHTTPClient struct { + endpoint string + bearer string + httpc *http.Client + nextID int +} + +type rpcResponse struct { + Result json.RawMessage `json:"result"` + Error *rpcError `json:"error"` +} + +type rpcError struct { + Code int `json:"code"` + Message string `json:"message"` +} + +type toolCallResult struct { + IsError bool `json:"isError"` + Content []struct { + Type string `json:"type"` + Text string `json:"text"` + } `json:"content"` +} + +func (r toolCallResult) textSummary() string { + var texts []string + for _, item := range r.Content { + if item.Text != "" { + texts = append(texts, item.Text) + } + } + return strings.Join(texts, "\n") +} + +func (r toolCallResult) lastText() string { + for i := len(r.Content) - 1; i >= 0; i-- { + if r.Content[i].Text != "" { + return r.Content[i].Text + } + } + return "" +} + +func (c *mcpHTTPClient) initialize(ctx context.Context) (string, error) { + _, sessionID, err := c.request(ctx, "", "initialize", map[string]any{ + "protocolVersion": "2025-06-18", + "capabilities": map[string]any{}, + "clientInfo": map[string]any{ + "name": "dsx-exchange-mcp-e2e", + "version": "0.1.0", + }, + }) + return sessionID, err +} + +func (c *mcpHTTPClient) initialized(ctx context.Context, sessionID string) error { + _, _, err := c.post(ctx, sessionID, map[string]any{ + "jsonrpc": "2.0", + "method": "notifications/initialized", + }) + return err +} + +func (c *mcpHTTPClient) listTools(ctx context.Context, sessionID string) ([]string, error) { + raw, _, err := c.request(ctx, sessionID, "tools/list", map[string]any{}) + if err != nil { + return nil, err + } + var result struct { + Tools []struct { + Name string `json:"name"` + } `json:"tools"` + } + if err := json.Unmarshal(raw, &result); err != nil { + return nil, fmt.Errorf("decode tools/list result: %w", err) + } + names := make([]string, 0, len(result.Tools)) + for _, tool := range result.Tools { + names = append(names, tool.Name) + } + return names, nil +} + +func (c *mcpHTTPClient) callTool(ctx context.Context, sessionID, name string, args map[string]any) (toolCallResult, error) { + raw, _, err := c.request(ctx, sessionID, "tools/call", map[string]any{ + "name": name, + "arguments": args, + }) + if err != nil { + return toolCallResult{}, err + } + var result toolCallResult + if err := json.Unmarshal(raw, &result); err != nil { + return toolCallResult{}, fmt.Errorf("decode tools/call result: %w", err) + } + return result, nil +} + +func (c *mcpHTTPClient) request(ctx context.Context, sessionID, method string, params map[string]any) (json.RawMessage, string, error) { + c.nextID++ + resp, newSessionID, err := c.post(ctx, sessionID, map[string]any{ + "jsonrpc": "2.0", + "id": c.nextID, + "method": method, + "params": params, + }) + if err != nil { + return nil, newSessionID, err + } + if resp.Error != nil { + return nil, newSessionID, fmt.Errorf("json-rpc error %d: %s", resp.Error.Code, resp.Error.Message) + } + return resp.Result, newSessionID, nil +} + +func (c *mcpHTTPClient) post(ctx context.Context, sessionID string, payload map[string]any) (rpcResponse, string, error) { + body, err := json.Marshal(payload) + if err != nil { + return rpcResponse{}, "", err + } + req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.endpoint, bytes.NewReader(body)) + if err != nil { + return rpcResponse{}, "", err + } + req.Header.Set("Authorization", "Bearer "+c.bearer) + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Accept", "application/json, text/event-stream") + if sessionID != "" { + req.Header.Set("Mcp-Session-Id", sessionID) + } + + res, err := c.httpc.Do(req) + if err != nil { + return rpcResponse{}, "", err + } + defer res.Body.Close() + + raw, err := io.ReadAll(res.Body) + if err != nil { + return rpcResponse{}, res.Header.Get("Mcp-Session-Id"), err + } + if res.StatusCode >= http.StatusBadRequest { + return rpcResponse{}, res.Header.Get("Mcp-Session-Id"), fmt.Errorf("http %d: %s", res.StatusCode, strings.TrimSpace(string(raw))) + } + + data := lastMCPResponseData(raw) + if len(data) == 0 { + return rpcResponse{}, res.Header.Get("Mcp-Session-Id"), nil + } + var decoded rpcResponse + if err := json.Unmarshal(data, &decoded); err != nil { + return rpcResponse{}, res.Header.Get("Mcp-Session-Id"), fmt.Errorf("decode MCP response: %w (body: %s)", err, string(data)) + } + return decoded, res.Header.Get("Mcp-Session-Id"), nil +} + +func lastMCPResponseData(body []byte) []byte { + body = bytes.TrimSpace(body) + if len(body) == 0 || bytes.HasPrefix(body, []byte("{")) { + return body + } + var last []byte + for _, line := range bytes.Split(body, []byte("\n")) { + line = bytes.TrimSpace(line) + if !bytes.HasPrefix(line, []byte("data:")) { + continue + } + data := bytes.TrimSpace(bytes.TrimPrefix(line, []byte("data:"))) + if len(data) == 0 || bytes.Equal(data, []byte("[DONE]")) { + continue + } + last = append(last[:0], data...) + } + return last +} + +func chooseSubscribeToolName(names []string, explicit string) string { + return chooseToolName(names, toolSubscribe, explicit) +} + +func chooseDescribeTopicToolName(names []string, explicit string) string { + return chooseToolName(names, toolDescribeTopic, explicit) +} + +func chooseToolName(names []string, baseName string, explicit string) string { + if explicit != "" { + for _, name := range names { + if name == explicit { + return name + } + } + return "" + } + for _, name := range names { + if name == baseName { + return name + } + } + for _, name := range names { + if strings.HasSuffix(name, "_"+baseName) { + return name + } + } + return "" +} + +func requiredEnv(t *testing.T, key string) string { + t.Helper() + v := os.Getenv(key) + if v == "" { + t.Fatalf("%s is required", key) + } + return v +} diff --git a/mcp/dsx-exchange-mcp/internal/server/llm_eval_test.go b/mcp/dsx-exchange-mcp/internal/server/llm_eval_test.go new file mode 100644 index 0000000..c44f6ec --- /dev/null +++ b/mcp/dsx-exchange-mcp/internal/server/llm_eval_test.go @@ -0,0 +1,542 @@ +// Copyright 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +package server + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "net/http/httptest" + "os" + "strings" + "testing" + "time" + + "github.com/modelcontextprotocol/go-sdk/mcp" + + "github.com/NVIDIA/dsx-exchange/mcp/dsx-exchange-mcp/internal/auth" +) + +func TestLocalLLMMCPPromptEval(t *testing.T) { + if os.Getenv("RUN_EXCHANGE_LLM_MCP_EVAL") != "1" { + t.Skip("set RUN_EXCHANGE_LLM_MCP_EVAL=1 to run local LLM MCP prompt eval") + } + + model := requiredEnv(t, "DSX_EXCHANGE_LLM_MODEL") + llm := localLLMClient{ + baseURL: strings.TrimRight(envDefault("DSX_EXCHANGE_LLM_BASE_URL", "http://127.0.0.1:11434/v1"), "/"), + apiKey: os.Getenv("DSX_EXCHANGE_LLM_API_KEY"), + model: model, + httpc: &http.Client{Timeout: 2 * time.Minute}, + } + allowLiveTools := os.Getenv("DSX_EXCHANGE_LLM_EXECUTE_LIVE_TOOLS") == "1" + + mcpClient, cleanup, endpointLabel := newLLMEvalMCPClient(t) + defer cleanup() + + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute) + defer cancel() + + sessionID, err := mcpClient.initialize(ctx) + if err != nil { + t.Fatalf("initialize MCP endpoint %s failed: %v", endpointLabel, err) + } + if err := mcpClient.initialized(ctx, sessionID); err != nil { + t.Fatalf("notifications/initialized failed for MCP endpoint %s: %v", endpointLabel, err) + } + + mcpTools, err := mcpClient.listToolDefinitions(ctx, sessionID) + if err != nil { + t.Fatalf("tools/list failed for MCP endpoint %s: %v", endpointLabel, err) + } + llmTools := llmToolDefinitions(mcpTools, allowLiveTools) + if len(llmTools) == 0 { + t.Fatalf("MCP endpoint %s did not expose LLM-safe tools; saw %v", endpointLabel, toolDefinitionNames(mcpTools)) + } + + fixtures := selectLLMEvalFixtures(t, loadToolCallFixtures(t)) + t.Logf("running %d prompt eval fixture(s) through %s using local LLM model %q", len(fixtures), endpointLabel, model) + t.Logf("LLM-visible tools: %v", llmToolNames(llmTools)) + + for _, fixture := range fixtures { + t.Run(fixture.ID, func(t *testing.T) { + result, err := runLLMFixtureEval(ctx, llm, mcpClient, sessionID, llmTools, fixture, allowLiveTools) + if err != nil { + t.Fatalf("LLM eval failed: %v", err) + } + + t.Logf("question: %s", fixture.Question) + t.Logf("tool trace: %s", mustMarshalJSON(t, result.Trace)) + t.Logf("final answer: %s", result.Final.Answer) + t.Logf("planned calls: %s", mustMarshalJSON(t, result.Final.PlannedToolCalls)) + + if len(result.Trace) == 0 { + t.Fatalf("LLM did not call any MCP tools; final content: %q", result.RawFinalContent) + } + if !allowLiveTools && !traceIncludesExpectedDescribe(result.Trace, fixture.ExpectedToolCalls) { + t.Fatalf("LLM did not execute an expected %s call; trace=%s", toolDescribeTopic, mustMarshalJSON(t, result.Trace)) + } + if missing := missingExpectedToolCalls(fixture.ExpectedToolCalls, result.Final.PlannedToolCalls); len(missing) > 0 { + t.Fatalf("LLM final plan missing expected call(s): %s", strings.Join(missing, "; ")) + } + }) + } +} + +type llmEvalResult struct { + Trace []llmToolTrace + Final llmFinalPlan + RawFinalContent string +} + +type llmToolTrace struct { + Step int `json:"step"` + Tool string `json:"tool"` + Arguments map[string]any `json:"arguments"` + IsError bool `json:"is_error"` + Summary string `json:"summary"` +} + +type llmFinalPlan struct { + Answer string `json:"answer"` + PlannedToolCalls []fixtureToolCall `json:"planned_tool_calls"` + Notes []string `json:"notes,omitempty"` +} + +func newLLMEvalMCPClient(t *testing.T) (*mcpHTTPClient, func(), string) { + t.Helper() + if endpoint := os.Getenv("DSX_EXCHANGE_MCP_URL"); endpoint != "" { + return &mcpHTTPClient{ + endpoint: endpoint, + bearer: envDefault("DSX_EXCHANGE_E2E_BEARER", "test-token"), + httpc: &http.Client{Timeout: 30 * time.Second}, + }, func() {}, "configured endpoint " + endpoint + } + + srv := Build(Config{ + DefaultMaxMessages: 100, + MaxMessages: 1000, + DefaultDurationS: 30, + MaxDurationS: 30, + }) + handler := mcp.NewStreamableHTTPHandler(func(*http.Request) *mcp.Server { + return srv + }, nil) + + mux := http.NewServeMux() + mux.Handle("/mcp", auth.Middleware(handler)) + httpServer := httptest.NewServer(mux) + + return &mcpHTTPClient{ + endpoint: httpServer.URL + "/mcp", + bearer: "test-token", + httpc: &http.Client{Timeout: 30 * time.Second}, + }, httpServer.Close, "in-process MCP server " + httpServer.URL + "/mcp" +} + +type mcpToolDefinition struct { + Name string `json:"name"` + Description string `json:"description"` + InputSchema map[string]any `json:"inputSchema"` +} + +func (c *mcpHTTPClient) listToolDefinitions(ctx context.Context, sessionID string) ([]mcpToolDefinition, error) { + raw, _, err := c.request(ctx, sessionID, "tools/list", map[string]any{}) + if err != nil { + return nil, err + } + var result struct { + Tools []mcpToolDefinition `json:"tools"` + } + if err := json.Unmarshal(raw, &result); err != nil { + return nil, fmt.Errorf("decode tools/list result: %w", err) + } + return result.Tools, nil +} + +func runLLMFixtureEval(ctx context.Context, llm localLLMClient, mcpClient *mcpHTTPClient, sessionID string, tools []chatTool, fixture toolCallFixture, allowLiveTools bool) (llmEvalResult, error) { + messages := []chatMessage{ + {Role: "system", Content: llmEvalSystemPrompt(allowLiveTools)}, + {Role: "user", Content: fmt.Sprintf("Question: %s\n\nUse the MCP tools to inspect schemas before producing the final plan.", fixture.Question)}, + } + + var trace []llmToolTrace + maxSteps := envIntDefault("DSX_EXCHANGE_LLM_MAX_STEPS", 8) + for step := 1; step <= maxSteps; step++ { + response, err := llm.complete(ctx, chatCompletionRequest{ + Model: llm.model, + Messages: messages, + Tools: tools, + Temperature: 0, + }) + if err != nil { + return llmEvalResult{}, err + } + if len(response.Choices) == 0 { + return llmEvalResult{}, fmt.Errorf("LLM returned no choices") + } + + assistant := response.Choices[0].Message + if len(assistant.ToolCalls) == 0 { + final, err := parseLLMFinalPlan(assistant.Content) + if err != nil { + return llmEvalResult{Trace: trace, RawFinalContent: assistant.Content}, err + } + return llmEvalResult{ + Trace: trace, + Final: final, + RawFinalContent: assistant.Content, + }, nil + } + + messages = append(messages, assistant) + for _, toolCall := range assistant.ToolCalls { + args, err := decodeToolArguments(toolCall.Function.Arguments) + if err != nil { + return llmEvalResult{Trace: trace}, fmt.Errorf("decode LLM tool arguments for %s: %w", toolCall.Function.Name, err) + } + + toolResult, err := mcpClient.callTool(ctx, sessionID, toolCall.Function.Name, args) + if err != nil { + return llmEvalResult{Trace: trace}, fmt.Errorf("MCP tools/call(%s) failed: %w", toolCall.Function.Name, err) + } + trace = append(trace, llmToolTrace{ + Step: step, + Tool: normalizeToolName(toolCall.Function.Name), + Arguments: args, + IsError: toolResult.IsError, + Summary: truncateForTrace(toolResult.textSummary(), 1200), + }) + messages = append(messages, chatMessage{ + Role: "tool", + ToolCallID: toolCall.ID, + Content: truncateForTrace(toolResult.textSummary(), 5000), + }) + } + } + return llmEvalResult{Trace: trace}, fmt.Errorf("LLM did not produce a final plan after %d tool-use step(s)", maxSteps) +} + +func llmEvalSystemPrompt(allowLiveTools bool) string { + liveToolInstruction := "Only execute schema-description tools. For read_retained and subscribe, include the planned calls in final JSON but do not execute them." + if allowLiveTools { + liveToolInstruction = "You may execute all listed MCP tools, including read_retained and subscribe, using bounded arguments." + } + return `You are a DSX Exchange MCP client evaluator. +Use the available MCP tools before answering. Prefer dsx_exchange_describe_topic, or the gateway-prefixed equivalent, to discover matching AsyncAPI schema channels and related metadata/value topics. +For "most recent" or snapshot-style requests, plan a retained metadata read before sampling live values when the schema exposes related metadata and value topics. +For live stream requests, plan dsx_exchange_subscribe with bounded max_messages and max_duration_s. +` + liveToolInstruction + ` + +Final response requirements: +- Return one strict JSON object and no markdown. +- JSON shape: {"answer":"brief user-facing summary","planned_tool_calls":[{"tool":"dsx_exchange_describe_topic","arguments":{"topic_filter":"..."}},{"tool":"dsx_exchange_read_retained","arguments":{"topic_filter":"...","max_messages":1000}},{"tool":"dsx_exchange_subscribe","arguments":{"topic_filter":"...","max_messages":100,"max_duration_s":30}}],"notes":["optional caveat"]} +- Use unprefixed canonical tool names in planned_tool_calls even if the MCP endpoint exposes gateway-prefixed names. +- Do not invent raw data values. This eval is about choosing the right tools and topic filters.` +} + +type localLLMClient struct { + baseURL string + apiKey string + model string + httpc *http.Client +} + +type chatCompletionRequest struct { + Model string `json:"model"` + Messages []chatMessage `json:"messages"` + Tools []chatTool `json:"tools,omitempty"` + Temperature float64 `json:"temperature"` +} + +type chatCompletionResponse struct { + Choices []struct { + Message chatMessage `json:"message"` + } `json:"choices"` + Error *struct { + Message string `json:"message"` + Type string `json:"type"` + } `json:"error,omitempty"` +} + +type chatMessage struct { + Role string `json:"role"` + Content string `json:"content,omitempty"` + ToolCalls []llmToolCall `json:"tool_calls,omitempty"` + ToolCallID string `json:"tool_call_id,omitempty"` +} + +type llmToolCall struct { + ID string `json:"id"` + Type string `json:"type"` + Function struct { + Name string `json:"name"` + Arguments string `json:"arguments"` + } `json:"function"` +} + +type chatTool struct { + Type string `json:"type"` + Function chatFunction `json:"function"` +} + +type chatFunction struct { + Name string `json:"name"` + Description string `json:"description,omitempty"` + Parameters map[string]any `json:"parameters"` +} + +func (c localLLMClient) complete(ctx context.Context, req chatCompletionRequest) (chatCompletionResponse, error) { + body, err := json.Marshal(req) + if err != nil { + return chatCompletionResponse{}, err + } + httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, c.baseURL+"/chat/completions", bytes.NewReader(body)) + if err != nil { + return chatCompletionResponse{}, err + } + httpReq.Header.Set("Content-Type", "application/json") + if c.apiKey != "" { + httpReq.Header.Set("Authorization", "Bearer "+c.apiKey) + } + + resp, err := c.httpc.Do(httpReq) + if err != nil { + return chatCompletionResponse{}, fmt.Errorf("call local LLM API at %s: %w", c.baseURL, err) + } + defer resp.Body.Close() + + raw, err := io.ReadAll(resp.Body) + if err != nil { + return chatCompletionResponse{}, err + } + var decoded chatCompletionResponse + if err := json.Unmarshal(raw, &decoded); err != nil { + return chatCompletionResponse{}, fmt.Errorf("decode local LLM response: %w (body: %s)", err, string(raw)) + } + if resp.StatusCode >= http.StatusBadRequest { + if decoded.Error != nil { + return chatCompletionResponse{}, fmt.Errorf("local LLM http %d: %s", resp.StatusCode, decoded.Error.Message) + } + return chatCompletionResponse{}, fmt.Errorf("local LLM http %d: %s", resp.StatusCode, strings.TrimSpace(string(raw))) + } + return decoded, nil +} + +func llmToolDefinitions(tools []mcpToolDefinition, allowLiveTools bool) []chatTool { + out := make([]chatTool, 0, len(tools)) + for _, tool := range tools { + normalized := normalizeToolName(tool.Name) + if normalized != toolDescribeTopic && !(allowLiveTools && (normalized == toolReadRetained || normalized == toolSubscribe)) { + continue + } + parameters := tool.InputSchema + if parameters == nil { + parameters = map[string]any{"type": "object"} + } + out = append(out, chatTool{ + Type: "function", + Function: chatFunction{ + Name: tool.Name, + Description: tool.Description, + Parameters: parameters, + }, + }) + } + return out +} + +func parseLLMFinalPlan(content string) (llmFinalPlan, error) { + raw, err := extractJSONObject(content) + if err != nil { + return llmFinalPlan{}, err + } + var plan llmFinalPlan + if err := json.Unmarshal(raw, &plan); err != nil { + return llmFinalPlan{}, fmt.Errorf("decode final JSON plan: %w (content: %s)", err, content) + } + for i := range plan.PlannedToolCalls { + plan.PlannedToolCalls[i].Tool = normalizeToolName(plan.PlannedToolCalls[i].Tool) + } + if len(plan.PlannedToolCalls) == 0 { + return llmFinalPlan{}, fmt.Errorf("final JSON contained no planned_tool_calls: %s", content) + } + return plan, nil +} + +func extractJSONObject(content string) ([]byte, error) { + start := strings.Index(content, "{") + end := strings.LastIndex(content, "}") + if start < 0 || end < start { + return nil, fmt.Errorf("final response did not contain a JSON object: %q", content) + } + return []byte(content[start : end+1]), nil +} + +func decodeToolArguments(raw string) (map[string]any, error) { + if strings.TrimSpace(raw) == "" { + return map[string]any{}, nil + } + var args map[string]any + if err := json.Unmarshal([]byte(raw), &args); err != nil { + return nil, err + } + return args, nil +} + +func missingExpectedToolCalls(expected, actual []fixtureToolCall) []string { + var missing []string + for _, want := range expected { + found := false + for _, got := range actual { + if toolCallsEquivalent(want, got) { + found = true + break + } + } + if !found { + missing = append(missing, fmt.Sprintf("%s %s", want.Tool, mustMarshalComparable(want.Arguments))) + } + } + return missing +} + +func toolCallsEquivalent(want, got fixtureToolCall) bool { + if normalizeToolName(want.Tool) != normalizeToolName(got.Tool) { + return false + } + return canonicalArgs(want.Arguments) == canonicalArgs(got.Arguments) +} + +func canonicalArgs(args map[string]any) string { + normalized := make(map[string]any, len(args)) + for key, value := range args { + switch number := value.(type) { + case float64: + if number == float64(int(number)) { + normalized[key] = int(number) + } else { + normalized[key] = number + } + default: + normalized[key] = value + } + } + raw, _ := json.Marshal(normalized) + return string(raw) +} + +func traceIncludesExpectedDescribe(trace []llmToolTrace, expected []fixtureToolCall) bool { + for _, want := range expected { + if normalizeToolName(want.Tool) != toolDescribeTopic { + continue + } + wantFilter, _ := want.Arguments["topic_filter"].(string) + for _, got := range trace { + gotFilter, _ := got.Arguments["topic_filter"].(string) + if got.Tool == toolDescribeTopic && gotFilter == wantFilter && !got.IsError { + return true + } + } + } + return false +} + +func normalizeToolName(name string) string { + for _, canonical := range []string{toolDescribeTopic, toolReadRetained, toolSubscribe} { + if name == canonical || strings.HasSuffix(name, "_"+canonical) { + return canonical + } + } + return name +} + +func selectLLMEvalFixtures(t *testing.T, fixtures []toolCallFixture) []toolCallFixture { + t.Helper() + requested := os.Getenv("DSX_EXCHANGE_LLM_EVAL_CASES") + if requested == "" { + if len(fixtures) > 1 { + return fixtures[:1] + } + return fixtures + } + wanted := map[string]bool{} + for _, id := range strings.Split(requested, ",") { + id = strings.TrimSpace(id) + if id != "" { + wanted[id] = true + } + } + var selected []toolCallFixture + for _, fixture := range fixtures { + if wanted[fixture.ID] { + selected = append(selected, fixture) + delete(wanted, fixture.ID) + } + } + if len(wanted) > 0 { + t.Fatalf("unknown DSX_EXCHANGE_LLM_EVAL_CASES fixture id(s): %v", wanted) + } + return selected +} + +func toolDefinitionNames(tools []mcpToolDefinition) []string { + names := make([]string, 0, len(tools)) + for _, tool := range tools { + names = append(names, tool.Name) + } + return names +} + +func llmToolNames(tools []chatTool) []string { + names := make([]string, 0, len(tools)) + for _, tool := range tools { + names = append(names, tool.Function.Name) + } + return names +} + +func truncateForTrace(s string, max int) string { + s = strings.TrimSpace(s) + if len(s) <= max { + return s + } + return s[:max] + "..." +} + +func envDefault(key, fallback string) string { + if value := os.Getenv(key); value != "" { + return value + } + return fallback +} + +func envIntDefault(key string, fallback int) int { + value := strings.TrimSpace(os.Getenv(key)) + if value == "" { + return fallback + } + var parsed int + if _, err := fmt.Sscanf(value, "%d", &parsed); err != nil || parsed <= 0 { + return fallback + } + return parsed +} + +func mustMarshalJSON(t *testing.T, value any) string { + t.Helper() + raw, err := json.Marshal(value) + if err != nil { + t.Fatalf("marshal JSON: %v", err) + } + return string(raw) +} + +func mustMarshalComparable(value any) string { + raw, _ := json.Marshal(value) + return string(raw) +} diff --git a/mcp/dsx-exchange-mcp/internal/server/resources.go b/mcp/dsx-exchange-mcp/internal/server/resources.go new file mode 100644 index 0000000..06079f3 --- /dev/null +++ b/mcp/dsx-exchange-mcp/internal/server/resources.go @@ -0,0 +1,71 @@ +// Copyright 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +package server + +import ( + "context" + "encoding/json" + "fmt" + "strings" + + "github.com/modelcontextprotocol/go-sdk/mcp" + + "github.com/NVIDIA/dsx-exchange/mcp/dsx-exchange-mcp/internal/specs" +) + +func registerResources(s *mcp.Server) { + available := specs.List() + + s.AddResource( + &mcp.Resource{ + URI: "dsx-exchange://specs/", + Name: "DSX Exchange spec index", + Description: "Index of available AsyncAPI specs covering MQTT topics on the DSX Exchange event bus. Read individual specs at dsx-exchange://specs/.", + MIMEType: "application/json", + }, + func(_ context.Context, req *mcp.ReadResourceRequest) (*mcp.ReadResourceResult, error) { + body, _ := json.Marshal(map[string]any{"domains": available}) + return &mcp.ReadResourceResult{ + Contents: []*mcp.ResourceContents{{ + URI: req.Params.URI, + MIMEType: "application/json", + Text: string(body), + }}, + }, nil + }, + ) + + for _, d := range available { + domain := d + uri := "dsx-exchange://specs/" + domain + s.AddResource( + &mcp.Resource{ + URI: uri, + Name: domain + " AsyncAPI spec", + Description: fmt.Sprintf("AsyncAPI 3.x definition for the %s domain on DSX Exchange (MQTT topics, payloads, message metadata).", domain), + MIMEType: "application/yaml", + }, + func(_ context.Context, req *mcp.ReadResourceRequest) (*mcp.ReadResourceResult, error) { + body, err := specs.Read(domain) + if err != nil { + return nil, err + } + return &mcp.ReadResourceResult{ + Contents: []*mcp.ResourceContents{{ + URI: req.Params.URI, + MIMEType: mimeFor(req.Params.URI), + Text: string(body), + }}, + }, nil + }, + ) + } +} + +func mimeFor(uri string) string { + if strings.HasSuffix(uri, ".json") { + return "application/json" + } + return "application/yaml" +} diff --git a/mcp/dsx-exchange-mcp/internal/server/server.go b/mcp/dsx-exchange-mcp/internal/server/server.go new file mode 100644 index 0000000..6c2ca89 --- /dev/null +++ b/mcp/dsx-exchange-mcp/internal/server/server.go @@ -0,0 +1,49 @@ +// Copyright 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +package server + +import ( + "github.com/modelcontextprotocol/go-sdk/mcp" + + "github.com/NVIDIA/dsx-exchange/mcp/dsx-exchange-mcp/internal/metrics" + "github.com/NVIDIA/dsx-exchange/mcp/dsx-exchange-mcp/internal/mqttbus" +) + +type Config struct { + MQTT mqttbus.Config + Metrics *metrics.Recorder + DefaultMaxMessages int + MaxMessages int + DefaultDurationS int + MaxDurationS int + WatchDefaultTTLS int + WatchMaxTTLS int + WatchDefaultBufferMessages int + WatchMaxBufferMessages int + WatchDefaultBufferBytes int + WatchMaxBufferBytes int + WatchMaxPerSession int + WatchMaxPerPod int + FindTopicsDefaultLimit int + FindTopicsMaxLimit int +} + +// Build constructs the singleton MCP server. The same *mcp.Server is returned +// from the StreamableHTTPHandler factory for every request; per-request state +// (caller bearer) flows through ctx via the auth middleware. +func Build(cfg Config) *mcp.Server { + srv := mcp.NewServer( + &mcp.Implementation{ + Name: "dsx-exchange-mcp", + Version: "0.1.0", + }, + nil, + ) + + normalizeConfig(&cfg) + watches := newWatchManager(cfg) + registerTools(srv, cfg, watches) + registerResources(srv) + return srv +} diff --git a/mcp/dsx-exchange-mcp/internal/server/testdata/tool_call_expectations.json b/mcp/dsx-exchange-mcp/internal/server/testdata/tool_call_expectations.json new file mode 100644 index 0000000..615a375 --- /dev/null +++ b/mcp/dsx-exchange-mcp/internal/server/testdata/tool_call_expectations.json @@ -0,0 +1,229 @@ +[ + { + "id": "bms-rack-temperature-latest", + "domain": "bms", + "question": "Grab me all of the most recent rack temperature data.", + "expected_tool_calls": [ + { + "tool": "dsx_exchange_describe_topic", + "arguments": { + "topic_filter": "BMS/v1/PUB/Metadata/Rack/RackLiquidSupplyTemperature/#" + } + }, + { + "tool": "dsx_exchange_describe_topic", + "arguments": { + "topic_filter": "BMS/v1/PUB/Metadata/Rack/RackLiquidReturnTemperature/#" + } + }, + { + "tool": "dsx_exchange_read_retained", + "arguments": { + "topic_filter": "BMS/v1/PUB/Metadata/Rack/RackLiquidSupplyTemperature/#", + "max_messages": 1000 + } + }, + { + "tool": "dsx_exchange_read_retained", + "arguments": { + "topic_filter": "BMS/v1/PUB/Metadata/Rack/RackLiquidReturnTemperature/#", + "max_messages": 1000 + } + }, + { + "tool": "dsx_exchange_subscribe", + "arguments": { + "topic_filter": "BMS/v1/PUB/Value/Rack/RackLiquidSupplyTemperature/#", + "max_messages": 100, + "max_duration_s": 30 + } + }, + { + "tool": "dsx_exchange_subscribe", + "arguments": { + "topic_filter": "BMS/v1/PUB/Value/Rack/RackLiquidReturnTemperature/#", + "max_messages": 100, + "max_duration_s": 30 + } + } + ], + "expected_schema": { + "domain": "bms", + "channels": ["rackMetadata"], + "related_topics": [ + "BMS/v1/PUB/Value/Rack/RackLiquidSupplyTemperature/#", + "BMS/v1/PUB/Value/Rack/RackLiquidReturnTemperature/#" + ] + } + }, + { + "id": "bms-rack-liquid-isolation-status", + "domain": "bms", + "question": "Show me rack liquid isolation status updates.", + "expected_tool_calls": [ + { + "tool": "dsx_exchange_describe_topic", + "arguments": { + "topic_filter": "BMS/v1/PUB/Value/Rack/RackLiquidIsolationStatus/#" + } + }, + { + "tool": "dsx_exchange_read_retained", + "arguments": { + "topic_filter": "BMS/v1/PUB/Metadata/Rack/RackLiquidIsolationStatus/#", + "max_messages": 1000 + } + }, + { + "tool": "dsx_exchange_subscribe", + "arguments": { + "topic_filter": "BMS/v1/PUB/Value/Rack/RackLiquidIsolationStatus/#", + "max_messages": 100, + "max_duration_s": 30 + } + } + ], + "expected_schema": { + "domain": "bms", + "channels": ["rackBmsValue"], + "related_topics": ["BMS/v1/PUB/Metadata/Rack/RackLiquidIsolationStatus/#"] + } + }, + { + "id": "bms-rack-power", + "domain": "bms", + "question": "What topic should I use for rack power telemetry?", + "expected_tool_calls": [ + { + "tool": "dsx_exchange_describe_topic", + "arguments": { + "topic_filter": "BMS/v1/PUB/Value/Rack/RackPower/#" + } + }, + { + "tool": "dsx_exchange_read_retained", + "arguments": { + "topic_filter": "BMS/v1/PUB/Metadata/Rack/RackPower/#", + "max_messages": 1000 + } + }, + { + "tool": "dsx_exchange_subscribe", + "arguments": { + "topic_filter": "BMS/v1/PUB/Value/Rack/RackPower/#", + "max_messages": 100, + "max_duration_s": 30 + } + } + ], + "expected_schema": { + "domain": "bms", + "channels": ["rackBmsValue"], + "related_topics": ["BMS/v1/PUB/Metadata/Rack/RackPower/#"] + } + }, + { + "id": "power-breach-alerts", + "domain": "power-management", + "question": "Listen for power breach alerts from power agents.", + "expected_tool_calls": [ + { + "tool": "dsx_exchange_describe_topic", + "arguments": { + "topic_filter": "grid/v1/poweragent/+/powerbreach" + } + }, + { + "tool": "dsx_exchange_subscribe", + "arguments": { + "topic_filter": "grid/v1/poweragent/+/powerbreach", + "max_messages": 100, + "max_duration_s": 30 + } + } + ], + "expected_schema": { + "domain": "power-management", + "channels": ["powerBreachAlertChannel"], + "related_topics": [] + } + }, + { + "id": "power-state-status", + "domain": "power-management", + "question": "Find current power state status events.", + "expected_tool_calls": [ + { + "tool": "dsx_exchange_describe_topic", + "arguments": { + "topic_filter": "grid/v1/poweragent/+/powerstate/status" + } + }, + { + "tool": "dsx_exchange_subscribe", + "arguments": { + "topic_filter": "grid/v1/poweragent/+/powerstate/status", + "max_messages": 100, + "max_duration_s": 30 + } + } + ], + "expected_schema": { + "domain": "power-management", + "channels": ["powerStateStatusChannel"], + "related_topics": [] + } + }, + { + "id": "power-enforcement-outcomes", + "domain": "power-management", + "question": "Which topic has infrastructure enforcement outcomes for power breaches?", + "expected_tool_calls": [ + { + "tool": "dsx_exchange_describe_topic", + "arguments": { + "topic_filter": "grid/v1/infra/+/powerbreach/enforcement" + } + }, + { + "tool": "dsx_exchange_subscribe", + "arguments": { + "topic_filter": "grid/v1/infra/+/powerbreach/enforcement", + "max_messages": 100, + "max_duration_s": 30 + } + } + ], + "expected_schema": { + "domain": "power-management", + "channels": ["powerBreachEnforcementChannel"], + "related_topics": [] + } + }, + { + "id": "nico-machine-state", + "domain": "nico", + "question": "Subscribe to NICO machine state changes.", + "expected_tool_calls": [ + { + "tool": "dsx_exchange_describe_topic", + "arguments": { + "topic_filter": "NICO/v1/machine/+/state" + } + }, + { + "tool": "dsx_exchange_subscribe", + "arguments": { + "topic_filter": "NICO/v1/machine/+/state", + "max_messages": 100, + "max_duration_s": 30 + } + } + ], + "expected_schema": { + "domain": "nico", + "channels": ["managedHostState"], + "related_topics": [] + } + } +] diff --git a/mcp/dsx-exchange-mcp/internal/server/tools.go b/mcp/dsx-exchange-mcp/internal/server/tools.go new file mode 100644 index 0000000..e0a2dca --- /dev/null +++ b/mcp/dsx-exchange-mcp/internal/server/tools.go @@ -0,0 +1,457 @@ +// Copyright 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +package server + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "log/slog" + "strings" + "time" + + "github.com/modelcontextprotocol/go-sdk/mcp" + + "github.com/NVIDIA/dsx-exchange/mcp/dsx-exchange-mcp/internal/auth" + "github.com/NVIDIA/dsx-exchange/mcp/dsx-exchange-mcp/internal/mqttbus" + "github.com/NVIDIA/dsx-exchange/mcp/dsx-exchange-mcp/internal/schemaindex" +) + +const ( + toolSubscribe = "dsx_exchange_subscribe" + toolReadRetained = "dsx_exchange_read_retained" + toolDescribeTopic = "dsx_exchange_describe_topic" + toolFindTopics = "dsx_exchange_find_topics" +) + +type subscribeInput struct { + TopicFilter string `json:"topic_filter" jsonschema:"MQTT topic filter for live messages; supports + and # wildcards. For BMS values use BMS/v1/PUB/Value/# or a specific AsyncAPI value path such as BMS/v1/PUB/Value/Rack/RackLiquidIsolationStatus/#."` + MaxMessages int `json:"max_messages,omitempty" jsonschema:"stop after this many messages (default 100)"` + MaxDurationS int `json:"max_duration_s,omitempty" jsonschema:"stop after this many seconds (default 30; use the max when waiting for sparse live values)"` +} + +type readRetainedInput struct { + TopicFilter string `json:"topic_filter" jsonschema:"MQTT topic filter to read retained messages from. For BMS, use Metadata paths such as BMS/v1/PUB/Metadata/#. Do not use this for live Value paths; use dsx_exchange_subscribe instead."` + MaxMessages int `json:"max_messages,omitempty" jsonschema:"safety cap on returned messages (default 1000)"` +} + +type describeTopicInput struct { + TopicFilter string `json:"topic_filter" jsonschema:"MQTT topic filter or concrete topic to explain using embedded AsyncAPI schemas. Example: BMS/v1/PUB/Value/Rack/RackLiquidIsolationStatus/#."` +} + +type findTopicsInput struct { + Domain string `json:"domain,omitempty" jsonschema:"Optional AsyncAPI domain, for example bms, power-management, nico, or spiffe-exchange."` + Query string `json:"query,omitempty" jsonschema:"Optional free-text search over AsyncAPI domain, channel, address, description, operations, and message summaries."` + Role string `json:"role,omitempty" jsonschema:"Optional topic role hint: metadata, value, or event."` + ObjectType string `json:"object_type,omitempty" jsonschema:"Optional BMS object type such as Rack, CDU, System, AHU, or Chiller."` + PointType string `json:"point_type,omitempty" jsonschema:"Optional BMS point type such as RackLiquidIsolationStatus, RackPower, or RackLeakDetectTray."` + OperationAction string `json:"operation_action,omitempty" jsonschema:"Optional AsyncAPI operation action filter such as send or receive."` + Limit int `json:"limit,omitempty" jsonschema:"Maximum schema topics to return."` +} + +type collectOutput struct { + Messages []mqttbus.Message `json:"messages"` + Count int `json:"count"` + DurationMS int64 `json:"duration_ms"` + StoppedReason string `json:"stopped_reason"` + Truncated bool `json:"truncated"` +} + +type describeTopicOutput struct { + TopicFilter string `json:"topic_filter"` + Count int `json:"count"` + Matches []schemaindex.Topic `json:"matches"` +} + +type findTopicsOutput struct { + Count int `json:"count"` + Matches []schemaindex.Topic `json:"matches"` +} + +type structuredError struct { + Error errorBody `json:"error"` +} + +type errorBody struct { + Code string `json:"code"` + Message string `json:"message"` +} + +func registerTools(s *mcp.Server, cfg Config, watches *watchManager) { + mcp.AddTool(s, &mcp.Tool{ + Name: toolSubscribe, + Description: "Subscribe to a DSX Exchange MQTT topic filter and return live messages " + + "received within bounded limits. Use this for BMS Value channels; BMS live value paths " + + "are under BMS/v1/PUB/Value/{objectType}/{pointType}/{tagPath}. Good discovery filters " + + "are BMS/v1/PUB/Value/# and BMS/v1/PUB/Value/Rack/RackLiquidIsolationStatus/#. " + + "Consult dsx-exchange://specs/* before inventing topic segments such as Data or Telemetry. " + + "The caller bearer is passed to MQTT as the configured OAuth username/password=; " + + "DSX Exchange auth-callout enforces OAuth2 validity and topic ACLs.", + }, func(ctx context.Context, _ *mcp.CallToolRequest, in subscribeInput) (*mcp.CallToolResult, collectOutput, error) { + maxMessages := in.MaxMessages + durationS := in.MaxDurationS + return collectTool(ctx, cfg, toolSubscribe, in.TopicFilter, maxMessages, durationS, false) + }) + + mcp.AddTool(s, &mcp.Tool{ + Name: toolReadRetained, + Description: "Read currently-retained messages on a DSX Exchange MQTT topic filter. " + + "Use this for retained BMS Metadata, for example BMS/v1/PUB/Metadata/#, before " + + "deriving specific value topics. Do not use this tool for BMS live Value channels; " + + "a zero-count retained_idle result means no retained messages matched that filter. " + + "The caller bearer is passed to MQTT as the configured OAuth username/password=.", + }, func(ctx context.Context, _ *mcp.CallToolRequest, in readRetainedInput) (*mcp.CallToolResult, collectOutput, error) { + return collectTool(ctx, cfg, toolReadRetained, in.TopicFilter, in.MaxMessages, cfg.DefaultDurationS, true) + }) + + mcp.AddTool(s, &mcp.Tool{ + Name: toolDescribeTopic, + Description: "Schema Exploration: describe the AsyncAPI channel matching a DSX Exchange topic filter. " + + "Returns the schema channel, payload shape, retained/live behavior, examples, and related metadata/value topics. " + + "Use this before subscribing when the caller knows roughly which MQTT path they want but needs schema context.", + }, func(ctx context.Context, _ *mcp.CallToolRequest, in describeTopicInput) (*mcp.CallToolResult, describeTopicOutput, error) { + return describeTopicTool(ctx, in) + }) + + mcp.AddTool(s, &mcp.Tool{ + Name: toolFindTopics, + Description: "Schema Exploration: find AsyncAPI-derived DSX Exchange MQTT topic filters by domain, text query, role, object type, point type, or operation action. " + + "Use this before starting a long-running subscription when the caller describes a domain or signal but does not know the raw MQTT topic path. " + + "Returned topic filters still require broker ACL approval when used by MQTT tools.", + }, func(ctx context.Context, _ *mcp.CallToolRequest, in findTopicsInput) (*mcp.CallToolResult, findTopicsOutput, error) { + return findTopicsTool(ctx, cfg, in) + }) + + registerWatchTools(s, cfg, watches) +} + +func describeTopicTool(ctx context.Context, in describeTopicInput) (*mcp.CallToolResult, describeTopicOutput, error) { + topicFilter := strings.TrimSpace(in.TopicFilter) + if topicFilter == "" { + return describeTopicError(mqttbus.CodeInvalidTopicFilter, "topic_filter is required") + } + if err := mqttbus.ValidateTopicFilter(topicFilter); err != nil { + return describeTopicError(mqttbus.ErrorCode(err), publicMessage(err)) + } + + idx, err := schemaindex.Default() + if err != nil { + return describeTopicError(mqttbus.CodeInternalError, err.Error()) + } + + matches := idx.Describe(topicFilter) + out := describeTopicOutput{ + TopicFilter: topicFilter, + Count: len(matches), + Matches: matches, + } + raw, _ := json.Marshal(out) + slog.Info("schema topic description", + "audit", true, + "tool", toolDescribeTopic, + "caller_tenant", auth.FromContext(ctx).Tenant, + "caller_subject", auth.FromContext(ctx).Subject, + "topic_filter", topicFilter, + "match_count", out.Count, + ) + return &mcp.CallToolResult{ + Content: []mcp.Content{ + &mcp.TextContent{Text: fmt.Sprintf("matched %d schema channels", out.Count)}, + &mcp.TextContent{Text: string(raw)}, + }, + }, out, nil +} + +func describeTopicError(code, message string) (*mcp.CallToolResult, describeTopicOutput, error) { + if code == "" { + code = mqttbus.CodeInternalError + } + body := structuredError{Error: errorBody{Code: code, Message: message}} + raw, _ := json.Marshal(body) + return &mcp.CallToolResult{ + IsError: true, + Content: []mcp.Content{&mcp.TextContent{Text: string(raw)}}, + }, describeTopicOutput{}, nil +} + +func findTopicsTool(ctx context.Context, cfg Config, in findTopicsInput) (*mcp.CallToolResult, findTopicsOutput, error) { + limit, err := applyFindTopicsLimit(cfg, in.Limit) + if err != nil { + return findTopicsError(mqttbus.ErrorCode(err), publicMessage(err)) + } + idx, err := schemaindex.Default() + if err != nil { + return findTopicsError(mqttbus.CodeInternalError, err.Error()) + } + matches := idx.Search(schemaindex.SearchOptions{ + Domain: in.Domain, + Query: in.Query, + Role: in.Role, + ObjectType: in.ObjectType, + PointType: in.PointType, + OperationAction: in.OperationAction, + Limit: limit, + }) + out := findTopicsOutput{ + Count: len(matches), + Matches: matches, + } + raw, _ := json.Marshal(out) + slog.Info("schema topic search", + "audit", true, + "tool", toolFindTopics, + "caller_tenant", auth.FromContext(ctx).Tenant, + "caller_subject", auth.FromContext(ctx).Subject, + "domain", strings.TrimSpace(in.Domain), + "query", strings.TrimSpace(in.Query), + "role", strings.TrimSpace(in.Role), + "object_type", strings.TrimSpace(in.ObjectType), + "point_type", strings.TrimSpace(in.PointType), + "match_count", out.Count, + ) + return &mcp.CallToolResult{ + Content: []mcp.Content{ + &mcp.TextContent{Text: fmt.Sprintf("matched %d schema topics", out.Count)}, + &mcp.TextContent{Text: string(raw)}, + }, + }, out, nil +} + +func findTopicsError(code, message string) (*mcp.CallToolResult, findTopicsOutput, error) { + if code == "" { + code = mqttbus.CodeInternalError + } + body := structuredError{Error: errorBody{Code: code, Message: message}} + raw, _ := json.Marshal(body) + return &mcp.CallToolResult{ + IsError: true, + Content: []mcp.Content{&mcp.TextContent{Text: string(raw)}}, + }, findTopicsOutput{}, nil +} + +func collectTool( + ctx context.Context, + cfg Config, + tool, topicFilter string, + maxMessages int, + durationS int, + retainedOnly bool, +) (*mcp.CallToolResult, collectOutput, error) { + start := time.Now() + caller := auth.FromContext(ctx) + if cfg.Metrics != nil { + cfg.Metrics.BeginToolCall() + defer cfg.Metrics.EndToolCall() + } + + maxMessages, durationS, err := applyLimits(cfg, maxMessages, durationS) + if err != nil { + return finishTool(tool, caller, topicFilter, maxMessages, durationS, start, collectOutput{}, err, cfg) + } + + result, err := mqttbus.Collect(ctx, cfg.MQTT, caller.Bearer, topicFilter, maxMessages, time.Duration(durationS)*time.Second, retainedOnly) + out := collectOutput{ + Messages: append([]mqttbus.Message{}, result.Messages...), + Count: len(result.Messages), + DurationMS: result.Duration.Milliseconds(), + StoppedReason: result.StoppedReason, + Truncated: result.Truncated, + } + return finishTool(tool, caller, topicFilter, maxMessages, durationS, start, out, err, cfg) +} + +func finishTool( + tool string, + caller auth.Caller, + topicFilter string, + maxMessages int, + durationS int, + start time.Time, + out collectOutput, + err error, + cfg Config, +) (*mcp.CallToolResult, collectOutput, error) { + duration := time.Since(start) + if out.DurationMS == 0 { + out.DurationMS = duration.Milliseconds() + } + + code := errorCode(err) + if cfg.Metrics != nil { + cfg.Metrics.RecordToolCall(tool, code, out.StoppedReason, duration, out.Count) + } + auditToolCall(tool, caller, topicFilter, maxMessages, durationS, out, duration, code) + + if err != nil { + body := structuredError{Error: errorBody{Code: code, Message: publicMessage(err)}} + raw, _ := json.Marshal(body) + return &mcp.CallToolResult{ + IsError: true, + Content: []mcp.Content{&mcp.TextContent{Text: string(raw)}}, + }, collectOutput{}, nil + } + + raw, _ := json.Marshal(out) + return &mcp.CallToolResult{ + Content: []mcp.Content{ + &mcp.TextContent{Text: fmt.Sprintf("collected %d messages", out.Count)}, + &mcp.TextContent{Text: string(raw)}, + }, + }, out, nil +} + +func normalizeConfig(cfg *Config) { + if cfg.DefaultMaxMessages <= 0 { + cfg.DefaultMaxMessages = 100 + } + if cfg.MaxMessages <= 0 { + cfg.MaxMessages = 1000 + } + if cfg.DefaultDurationS <= 0 { + cfg.DefaultDurationS = 30 + } + if cfg.MaxDurationS <= 0 { + cfg.MaxDurationS = 30 + } + if cfg.WatchDefaultTTLS <= 0 { + cfg.WatchDefaultTTLS = 300 + } + if cfg.WatchMaxTTLS <= 0 { + cfg.WatchMaxTTLS = 900 + } + if cfg.WatchDefaultTTLS > cfg.WatchMaxTTLS { + cfg.WatchDefaultTTLS = cfg.WatchMaxTTLS + } + if cfg.WatchDefaultBufferMessages <= 0 { + cfg.WatchDefaultBufferMessages = 100 + } + if cfg.WatchMaxBufferMessages <= 0 { + cfg.WatchMaxBufferMessages = 1000 + } + if cfg.WatchDefaultBufferMessages > cfg.WatchMaxBufferMessages { + cfg.WatchDefaultBufferMessages = cfg.WatchMaxBufferMessages + } + if cfg.WatchDefaultBufferBytes <= 0 { + cfg.WatchDefaultBufferBytes = 262144 + } + if cfg.WatchMaxBufferBytes <= 0 { + cfg.WatchMaxBufferBytes = 1048576 + } + if cfg.WatchDefaultBufferBytes > cfg.WatchMaxBufferBytes { + cfg.WatchDefaultBufferBytes = cfg.WatchMaxBufferBytes + } + if cfg.WatchMaxPerSession <= 0 { + cfg.WatchMaxPerSession = 10 + } + if cfg.WatchMaxPerPod <= 0 { + cfg.WatchMaxPerPod = 1000 + } + if cfg.FindTopicsDefaultLimit <= 0 { + cfg.FindTopicsDefaultLimit = 20 + } + if cfg.FindTopicsMaxLimit <= 0 { + cfg.FindTopicsMaxLimit = 100 + } + if cfg.FindTopicsDefaultLimit > cfg.FindTopicsMaxLimit { + cfg.FindTopicsDefaultLimit = cfg.FindTopicsMaxLimit + } +} + +func applyLimits(cfg Config, maxMessages, durationS int) (int, int, error) { + if maxMessages == 0 { + maxMessages = cfg.DefaultMaxMessages + } + if durationS == 0 { + durationS = cfg.DefaultDurationS + } + if maxMessages <= 0 { + return maxMessages, durationS, &mqttbus.BusError{Code: mqttbus.CodeInvalidArgument, Message: "max_messages must be greater than zero"} + } + if durationS <= 0 { + return maxMessages, durationS, &mqttbus.BusError{Code: mqttbus.CodeInvalidArgument, Message: "max_duration_s must be greater than zero"} + } + if maxMessages > cfg.MaxMessages { + return maxMessages, durationS, &mqttbus.BusError{Code: mqttbus.CodeInvalidArgument, Message: fmt.Sprintf("max_messages exceeds cap %d", cfg.MaxMessages)} + } + if durationS > cfg.MaxDurationS { + return maxMessages, durationS, &mqttbus.BusError{Code: mqttbus.CodeInvalidArgument, Message: fmt.Sprintf("max_duration_s exceeds cap %d", cfg.MaxDurationS)} + } + return maxMessages, durationS, nil +} + +func applyFindTopicsLimit(cfg Config, limit int) (int, error) { + if limit == 0 { + limit = cfg.FindTopicsDefaultLimit + } + if limit <= 0 { + return limit, &mqttbus.BusError{Code: mqttbus.CodeInvalidArgument, Message: "limit must be greater than zero"} + } + if limit > cfg.FindTopicsMaxLimit { + return limit, &mqttbus.BusError{Code: mqttbus.CodeInvalidArgument, Message: fmt.Sprintf("limit exceeds cap %d", cfg.FindTopicsMaxLimit)} + } + return limit, nil +} + +func errorCode(err error) string { + if err == nil { + return "" + } + if errors.Is(err, context.Canceled) { + return "caller_cancelled" + } + if errors.Is(err, context.DeadlineExceeded) { + return "deadline_exceeded" + } + return mqttbus.ErrorCode(err) +} + +func publicMessage(err error) string { + var busErr *mqttbus.BusError + if errors.As(err, &busErr) { + return busErr.Message + } + if errors.Is(err, context.Canceled) { + return "caller cancelled the request" + } + if errors.Is(err, context.DeadlineExceeded) { + return "request deadline exceeded" + } + return "tool call failed" +} + +func auditToolCall( + tool string, + caller auth.Caller, + topicFilter string, + maxMessages int, + durationS int, + out collectOutput, + duration time.Duration, + code string, +) { + decision := "allowed" + if code != "" { + decision = "error" + } + slog.Info("tool invocation", + "audit", true, + "tool", tool, + "caller_tenant", caller.Tenant, + "caller_issuer", caller.Issuer, + "caller_subject", caller.Subject, + "caller_spiffe_id", caller.SpiffeID, + "mcp_session_id", caller.SessionID, + "bearer_present", caller.Bearer != "", + "topic_filter", topicFilter, + "max_messages", maxMessages, + "max_duration_s", durationS, + "decision", decision, + "message_count", out.Count, + "stopped_reason", out.StoppedReason, + "truncated", out.Truncated, + "duration_ms", duration.Milliseconds(), + "error_code", code, + ) +} diff --git a/mcp/dsx-exchange-mcp/internal/server/tools_test.go b/mcp/dsx-exchange-mcp/internal/server/tools_test.go new file mode 100644 index 0000000..2ff448d --- /dev/null +++ b/mcp/dsx-exchange-mcp/internal/server/tools_test.go @@ -0,0 +1,441 @@ +// Copyright 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +package server + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "os" + "strings" + "testing" + + "github.com/modelcontextprotocol/go-sdk/mcp" + + "github.com/NVIDIA/dsx-exchange/mcp/dsx-exchange-mcp/internal/auth" + "github.com/NVIDIA/dsx-exchange/mcp/dsx-exchange-mcp/internal/mqttbus" +) + +func TestApplyLimitsDefaultsAndCaps(t *testing.T) { + cfg := Config{ + DefaultMaxMessages: 10, + MaxMessages: 20, + DefaultDurationS: 5, + MaxDurationS: 30, + } + + msgs, dur, err := applyLimits(cfg, 0, 0) + if err != nil { + t.Fatalf("applyLimits defaults failed: %v", err) + } + if msgs != 10 || dur != 5 { + t.Fatalf("defaults = (%d,%d), want (10,5)", msgs, dur) + } + + _, _, err = applyLimits(cfg, 21, 5) + if got := mqttbus.ErrorCode(err); got != mqttbus.CodeInvalidArgument { + t.Fatalf("max message cap code = %q, want %q", got, mqttbus.CodeInvalidArgument) + } + + _, _, err = applyLimits(cfg, 10, 31) + if got := mqttbus.ErrorCode(err); got != mqttbus.CodeInvalidArgument { + t.Fatalf("duration cap code = %q, want %q", got, mqttbus.CodeInvalidArgument) + } +} + +func TestDescribeTopicToolMatchesSchema(t *testing.T) { + _, out, err := describeTopicTool(context.Background(), describeTopicInput{ + TopicFilter: "BMS/v1/PUB/Value/Rack/RackLiquidIsolationStatus/#", + }) + if err != nil { + t.Fatalf("describeTopicTool returned transport error: %v", err) + } + if out.Count == 0 { + t.Fatal("describeTopicTool returned no matches") + } + if got := out.Matches[0].RelatedTopics[0].TopicFilter; got != "BMS/v1/PUB/Metadata/Rack/RackLiquidIsolationStatus/#" { + t.Fatalf("related metadata topic = %q", got) + } +} + +func TestDescribeTopicToolRequiresFilter(t *testing.T) { + result, _, err := describeTopicTool(context.Background(), describeTopicInput{}) + if err != nil { + t.Fatalf("describeTopicTool returned transport error: %v", err) + } + if result == nil || !result.IsError { + t.Fatalf("describeTopicTool empty filter IsError = %v, want true", result != nil && result.IsError) + } +} + +func TestFindTopicsToolMatchesSelector(t *testing.T) { + cfg := Config{} + normalizeConfig(&cfg) + _, out, err := findTopicsTool(context.Background(), cfg, findTopicsInput{ + Domain: "bms", + Role: "value", + ObjectType: "Rack", + PointType: "RackLiquidIsolationStatus", + }) + if err != nil { + t.Fatalf("findTopicsTool returned transport error: %v", err) + } + if out.Count != 1 { + t.Fatalf("findTopicsTool count = %d, want 1: %#v", out.Count, out.Matches) + } + if got := out.Matches[0].TopicFilter; got != "BMS/v1/PUB/Value/Rack/RackLiquidIsolationStatus/#" { + t.Fatalf("topic filter = %q, want RackLiquidIsolationStatus value filter", got) + } +} + +func TestResolveSubscriptionTopicFromSelector(t *testing.T) { + cfg := Config{} + normalizeConfig(&cfg) + topicFilter, err := resolveSubscriptionTopic(cfg, startSubscriptionInput{ + Selector: findTopicsInput{ + Domain: "bms", + Role: "value", + ObjectType: "Rack", + PointType: "RackLiquidIsolationStatus", + }, + }) + if err != nil { + t.Fatalf("resolveSubscriptionTopic returned error: %v", err) + } + if topicFilter != "BMS/v1/PUB/Value/Rack/RackLiquidIsolationStatus/#" { + t.Fatalf("topic filter = %q, want RackLiquidIsolationStatus value filter", topicFilter) + } +} + +type toolCallFixture struct { + ID string `json:"id"` + Domain string `json:"domain"` + Question string `json:"question"` + ExpectedToolCalls []fixtureToolCall `json:"expected_tool_calls"` + ExpectedSchema fixtureSchemaCheck `json:"expected_schema"` +} + +type fixtureToolCall struct { + Tool string `json:"tool"` + Arguments map[string]any `json:"arguments"` +} + +type fixtureSchemaCheck struct { + Domain string `json:"domain"` + Channels []string `json:"channels"` + RelatedTopics []string `json:"related_topics"` +} + +func TestToolCallExpectationFixtures(t *testing.T) { + raw, err := os.ReadFile("testdata/tool_call_expectations.json") + if err != nil { + t.Fatalf("read tool-call fixture: %v", err) + } + var fixtures []toolCallFixture + if err := json.Unmarshal(raw, &fixtures); err != nil { + t.Fatalf("unmarshal tool-call fixture: %v", err) + } + if len(fixtures) == 0 { + t.Fatal("tool-call fixture is empty") + } + + cfg := Config{ + DefaultMaxMessages: 100, + MaxMessages: 1000, + DefaultDurationS: 30, + MaxDurationS: 30, + } + + for _, fixture := range fixtures { + t.Run(fixture.ID, func(t *testing.T) { + if fixture.Question == "" { + t.Fatal("fixture question is required") + } + if fixture.Domain == "" { + t.Fatal("fixture domain is required") + } + if len(fixture.ExpectedToolCalls) == 0 { + t.Fatal("expected_tool_calls is required") + } + + seenChannels := map[string]bool{} + seenRelatedTopics := map[string]bool{} + described := false + + for i, call := range fixture.ExpectedToolCalls { + topicFilter := stringArg(t, fixture.ID, i, call.Arguments, "topic_filter") + if err := mqttbus.ValidateTopicFilter(topicFilter); err != nil { + t.Fatalf("call %d %s topic_filter %q is invalid: %v", i, call.Tool, topicFilter, err) + } + + switch call.Tool { + case toolDescribeTopic: + described = true + result, out, err := describeTopicTool(context.Background(), describeTopicInput{TopicFilter: topicFilter}) + if err != nil { + t.Fatalf("describe topic transport error for %q: %v", topicFilter, err) + } + if result == nil || result.IsError { + t.Fatalf("describe topic result for %q is error: %#v", topicFilter, result) + } + if out.Count == 0 { + t.Fatalf("describe topic returned no schema matches for %q", topicFilter) + } + for _, match := range out.Matches { + if match.Domain == fixture.ExpectedSchema.Domain { + seenChannels[match.Channel] = true + } + for _, related := range match.RelatedTopics { + seenRelatedTopics[related.TopicFilter] = true + } + } + case toolReadRetained: + maxMessages := intArg(t, fixture.ID, i, call.Arguments, "max_messages") + if _, _, err := applyLimits(cfg, maxMessages, cfg.DefaultDurationS); err != nil { + t.Fatalf("read_retained limits invalid for %q: %v", topicFilter, err) + } + case toolSubscribe: + maxMessages := intArg(t, fixture.ID, i, call.Arguments, "max_messages") + maxDurationS := intArg(t, fixture.ID, i, call.Arguments, "max_duration_s") + if _, _, err := applyLimits(cfg, maxMessages, maxDurationS); err != nil { + t.Fatalf("subscribe limits invalid for %q: %v", topicFilter, err) + } + default: + t.Fatalf("call %d has unknown tool %q", i, call.Tool) + } + } + + if !described { + t.Fatal("fixture must include at least one dsx_exchange_describe_topic call") + } + for _, channel := range fixture.ExpectedSchema.Channels { + if !seenChannels[channel] { + t.Fatalf("expected schema channel %q was not observed; saw %#v", channel, seenChannels) + } + } + for _, topic := range fixture.ExpectedSchema.RelatedTopics { + if !seenRelatedTopics[topic] { + t.Fatalf("expected related topic %q was not observed; saw %#v", topic, seenRelatedTopics) + } + } + }) + } +} + +func TestMCPClientListsAndCallsDescribeTopic(t *testing.T) { + fixtures := loadToolCallFixtures(t) + session, cleanup := newTestMCPClient(t) + defer cleanup() + + tools, err := session.ListTools(context.Background(), nil) + if err != nil { + t.Fatalf("ListTools failed: %v", err) + } + toolNames := map[string]bool{} + for _, tool := range tools.Tools { + toolNames[tool.Name] = true + } + for _, name := range []string{toolDescribeTopic, toolFindTopics, toolReadRetained, toolSubscribe, toolStartSubscription, toolReadSubscription, toolStatusSubscription, toolStopSubscription} { + if !toolNames[name] { + t.Fatalf("ListTools did not expose %q; saw %#v", name, toolNames) + } + } + + for _, fixture := range fixtures { + t.Run(fixture.ID, func(t *testing.T) { + for i, call := range fixture.ExpectedToolCalls { + if call.Tool != toolDescribeTopic { + continue + } + topicFilter := stringArg(t, fixture.ID, i, call.Arguments, "topic_filter") + result, err := session.CallTool(context.Background(), &mcp.CallToolParams{ + Name: toolDescribeTopic, + Arguments: call.Arguments, + }) + if err != nil { + t.Fatalf("CallTool(%s, %q) returned client error: %v", toolDescribeTopic, topicFilter, err) + } + if result.IsError { + t.Fatalf("CallTool(%s, %q) returned tool error: %s", toolDescribeTopic, topicFilter, textContentSummary(result)) + } + + var out describeTopicOutput + if err := json.Unmarshal([]byte(lastTextContent(t, result)), &out); err != nil { + t.Fatalf("decode CallTool(%s, %q) JSON content: %v", toolDescribeTopic, topicFilter, err) + } + if out.TopicFilter != topicFilter { + t.Fatalf("MCP result topic_filter = %q, want %q", out.TopicFilter, topicFilter) + } + if out.Count == 0 { + t.Fatalf("MCP result for %q has no schema matches", topicFilter) + } + if !hasDomainChannel(out, fixture.ExpectedSchema.Domain, fixture.ExpectedSchema.Channels) { + t.Fatalf("MCP result for %q missing expected domain/channel; result=%#v", topicFilter, out.Matches) + } + } + }) + } +} + +func TestMCPClientDescribeTopicInvalidFilterReturnsToolError(t *testing.T) { + session, cleanup := newTestMCPClient(t) + defer cleanup() + + result, err := session.CallTool(context.Background(), &mcp.CallToolParams{ + Name: toolDescribeTopic, + Arguments: map[string]any{ + "topic_filter": "BMS/#/bad", + }, + }) + if err != nil { + t.Fatalf("CallTool invalid filter returned client/protocol error: %v", err) + } + if !result.IsError { + t.Fatalf("CallTool invalid filter IsError=false; content=%s", textContentSummary(result)) + } + if got := textContentSummary(result); !strings.Contains(got, mqttbus.CodeInvalidTopicFilter) { + t.Fatalf("invalid filter error content = %q, want code %q", got, mqttbus.CodeInvalidTopicFilter) + } +} + +func stringArg(t *testing.T, fixtureID string, callIndex int, args map[string]any, key string) string { + t.Helper() + value, ok := args[key] + if !ok { + t.Fatalf("%s call %d missing %q", fixtureID, callIndex, key) + } + str, ok := value.(string) + if !ok || str == "" { + t.Fatalf("%s call %d %q = %#v, want non-empty string", fixtureID, callIndex, key, value) + } + return str +} + +func intArg(t *testing.T, fixtureID string, callIndex int, args map[string]any, key string) int { + t.Helper() + value, ok := args[key] + if !ok { + t.Fatalf("%s call %d missing %q", fixtureID, callIndex, key) + } + number, ok := value.(float64) + if !ok || number != float64(int(number)) { + t.Fatalf("%s call %d %q = %#v, want integer", fixtureID, callIndex, key, value) + } + return int(number) +} + +func loadToolCallFixtures(t *testing.T) []toolCallFixture { + t.Helper() + raw, err := os.ReadFile("testdata/tool_call_expectations.json") + if err != nil { + t.Fatalf("read tool-call fixture: %v", err) + } + var fixtures []toolCallFixture + if err := json.Unmarshal(raw, &fixtures); err != nil { + t.Fatalf("unmarshal tool-call fixture: %v", err) + } + if len(fixtures) == 0 { + t.Fatal("tool-call fixture is empty") + } + return fixtures +} + +func newTestMCPClient(t *testing.T) (*mcp.ClientSession, func()) { + t.Helper() + srv := Build(Config{ + DefaultMaxMessages: 100, + MaxMessages: 1000, + DefaultDurationS: 30, + MaxDurationS: 30, + }) + handler := mcp.NewStreamableHTTPHandler(func(*http.Request) *mcp.Server { + return srv + }, nil) + + mux := http.NewServeMux() + mux.Handle("/mcp", auth.Middleware(handler)) + httpServer := httptest.NewServer(mux) + + client := mcp.NewClient(&mcp.Implementation{ + Name: "dsx-exchange-mcp-test-client", + Version: "0.1.0", + }, nil) + transport := &mcp.StreamableClientTransport{ + Endpoint: httpServer.URL + "/mcp", + HTTPClient: &http.Client{Transport: authHeaderTransport{base: http.DefaultTransport}}, + DisableStandaloneSSE: true, + } + session, err := client.Connect(context.Background(), transport, nil) + if err != nil { + httpServer.Close() + t.Fatalf("connect MCP test client: %v", err) + } + cleanup := func() { + _ = session.Close() + httpServer.Close() + } + return session, cleanup +} + +type authHeaderTransport struct { + base http.RoundTripper +} + +func (t authHeaderTransport) RoundTrip(req *http.Request) (*http.Response, error) { + base := t.base + if base == nil { + base = http.DefaultTransport + } + req.Header.Set("Authorization", "Bearer test-token") + req.Header.Set("x-mcp-tenant", "test-tenant") + req.Header.Set("x-mcp-sub", "test-subject") + return base.RoundTrip(req) +} + +func lastTextContent(t *testing.T, result *mcp.CallToolResult) string { + t.Helper() + for i := len(result.Content) - 1; i >= 0; i-- { + if text, ok := result.Content[i].(*mcp.TextContent); ok { + return text.Text + } + } + t.Fatalf("tool result contains no text content: %#v", result.Content) + return "" +} + +func textContentSummary(result *mcp.CallToolResult) string { + var parts []string + for _, content := range result.Content { + if text, ok := content.(*mcp.TextContent); ok { + parts = append(parts, text.Text) + } + } + return strings.Join(parts, "\n") +} + +func hasDomainChannel(out describeTopicOutput, domain string, channels []string) bool { + wanted := map[string]bool{} + for _, channel := range channels { + wanted[channel] = true + } + for _, match := range out.Matches { + if match.Domain == domain && wanted[match.Channel] { + return true + } + } + return false +} + +func TestCollectOutputZeroMessagesJSON(t *testing.T) { + raw, err := json.Marshal(collectOutput{ + Messages: []mqttbus.Message{}, + }) + if err != nil { + t.Fatalf("marshal collectOutput: %v", err) + } + if got, want := string(raw), `{"messages":[],"count":0,"duration_ms":0,"stopped_reason":"","truncated":false}`; got != want { + t.Fatalf("collectOutput JSON = %s, want %s", got, want) + } +} diff --git a/mcp/dsx-exchange-mcp/internal/server/watch.go b/mcp/dsx-exchange-mcp/internal/server/watch.go new file mode 100644 index 0000000..b7271c7 --- /dev/null +++ b/mcp/dsx-exchange-mcp/internal/server/watch.go @@ -0,0 +1,678 @@ +// Copyright 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +package server + +import ( + "context" + "crypto/rand" + "encoding/hex" + "fmt" + "math" + "strconv" + "strings" + "sync" + "time" + + "github.com/NVIDIA/dsx-exchange/mcp/dsx-exchange-mcp/internal/auth" + "github.com/NVIDIA/dsx-exchange/mcp/dsx-exchange-mcp/internal/mqttbus" +) + +const ( + watchStatusStarting = "starting" + watchStatusRunning = "running" + watchStatusExpired = "expired" + watchStatusFailed = "failed" + watchStatusStopped = "stopped" + + codeStatefulSessionRequired = "stateful_session_required" + codeSubscriptionNotFound = "subscription_not_found" + codeSubscriptionNotOwner = "subscription_not_owner" + codeSchemaTopicNotFound = "schema_topic_not_found" + codeSchemaTopicAmbiguous = "schema_topic_ambiguous" + codeBufferOverflow = "buffer_overflow" +) + +const finishedWatchRetention = 5 * time.Minute + +type streamRunner func(context.Context, mqttbus.Config, string, string, mqttbus.StreamOptions, func(mqttbus.Message) error) (mqttbus.StreamResult, error) + +type watchManager struct { + cfg Config + runner streamRunner + + mu sync.Mutex + watches map[string]map[string]*watch + total int + activeTotal int + now func() time.Time + newID func() string + retention time.Duration +} + +type watch struct { + id string + sessionID string + authKey string + topicFilter string + status string + createdAt time.Time + expiresAt time.Time + finishedAt time.Time + lastMessage time.Time + lastError *errorBody + + cursor int64 + droppedCount int64 + messageCount int64 + bufferBytes int + buffer []bufferedWatchMessage + + maxMessages int + maxBytes int + cancel context.CancelFunc + stopped bool + active bool +} + +type bufferedWatchMessage struct { + cursor string + size int + msg mqttbus.Message +} + +type watchStartRequest struct { + Caller auth.Caller + TopicFilter string + TTLS int + BufferMaxMessages int + BufferMaxBytes int +} + +type watchReadRequest struct { + Caller auth.Caller + SubscriptionID string + Cursor string + MaxMessages int + MaxBytes int +} + +type watchStatusRequest struct { + Caller auth.Caller + SubscriptionID string +} + +type watchStopRequest struct { + Caller auth.Caller + SubscriptionID string +} + +type watchLimitsOutput struct { + TTLSeconds int `json:"ttl_seconds"` + BufferMaxMessages int `json:"buffer_max_messages"` + BufferMaxBytes int `json:"buffer_max_bytes"` + OverflowPolicy string `json:"overflow_policy"` +} + +type watchWatermark struct { + OldestCursor string `json:"oldest_cursor"` + NewestCursor string `json:"newest_cursor"` +} + +type watchMessageOutput struct { + Cursor string `json:"cursor"` + Topic string `json:"topic"` + Payload string `json:"payload"` + PayloadEncoding string `json:"payload_encoding"` + Retained bool `json:"retained"` + QoS byte `json:"qos"` + ReceivedAt time.Time `json:"received_at"` +} + +type watchStartOutput struct { + SubscriptionID string `json:"subscription_id"` + Status string `json:"status"` + TopicFilter string `json:"topic_filter"` + Cursor string `json:"cursor"` + ExpiresAt time.Time `json:"expires_at"` + RecommendedReadAfterSeconds int `json:"recommended_read_after_seconds"` + Limits watchLimitsOutput `json:"limits"` +} + +type watchReadOutput struct { + SubscriptionID string `json:"subscription_id"` + Status string `json:"status"` + Messages []watchMessageOutput `json:"messages"` + Count int `json:"count"` + NextCursor string `json:"next_cursor"` + DroppedCount int64 `json:"dropped_count"` + BufferWatermark watchWatermark `json:"buffer_watermark"` + ExpiresAt time.Time `json:"expires_at"` + LastError *errorBody `json:"last_error,omitempty"` +} + +type watchStatusOutput struct { + SubscriptionID string `json:"subscription_id"` + Status string `json:"status"` + TopicFilter string `json:"topic_filter"` + MessageCount int64 `json:"message_count"` + DroppedCount int64 `json:"dropped_count"` + OldestCursor string `json:"oldest_cursor"` + NewestCursor string `json:"newest_cursor"` + ExpiresAt time.Time `json:"expires_at"` + LastMessageAt *time.Time `json:"last_message_at,omitempty"` + LastError *errorBody `json:"last_error,omitempty"` + BufferWatermark watchWatermark `json:"buffer_watermark"` +} + +type watchStopOutput struct { + SubscriptionID string `json:"subscription_id"` + Status string `json:"status"` + StoppedReason string `json:"stopped_reason"` + MessageCount int64 `json:"message_count"` + DroppedCount int64 `json:"dropped_count"` +} + +type streamFinished struct { + result mqttbus.StreamResult + err error +} + +func newWatchManager(cfg Config) *watchManager { + return &watchManager{ + cfg: cfg, + runner: mqttbus.Stream, + watches: map[string]map[string]*watch{}, + now: time.Now, + newID: randomSubscriptionID, + retention: finishedWatchRetention, + } +} + +func (m *watchManager) start(req watchStartRequest) (watchStartOutput, error) { + if err := validateWatchCaller(req.Caller); err != nil { + return watchStartOutput{}, err + } + if err := mqttbus.ValidateTopicFilter(req.TopicFilter); err != nil { + return watchStartOutput{}, err + } + ttlS, bufferMessages, bufferBytes, err := m.applyStartLimits(req) + if err != nil { + return watchStartOutput{}, err + } + + started := m.now() + ctx, cancel := context.WithCancel(context.Background()) + w := &watch{ + id: m.newID(), + sessionID: req.Caller.SessionID, + authKey: callerAuthKey(req.Caller), + topicFilter: strings.TrimSpace(req.TopicFilter), + status: watchStatusStarting, + createdAt: started, + expiresAt: started.Add(time.Duration(ttlS) * time.Second), + maxMessages: bufferMessages, + maxBytes: bufferBytes, + cancel: cancel, + active: true, + } + + ready := make(chan struct{}, 1) + finished := make(chan streamFinished, 1) + + m.mu.Lock() + if m.activeTotal >= m.cfg.WatchMaxPerPod { + m.mu.Unlock() + cancel() + return watchStartOutput{}, &mqttbus.BusError{Code: mqttbus.CodeInvalidArgument, Message: fmt.Sprintf("active watch count exceeds per-pod cap %d", m.cfg.WatchMaxPerPod)} + } + sessionWatches := m.watches[w.sessionID] + if activeSessionCount(sessionWatches) >= m.cfg.WatchMaxPerSession { + m.mu.Unlock() + cancel() + return watchStartOutput{}, &mqttbus.BusError{Code: mqttbus.CodeInvalidArgument, Message: fmt.Sprintf("active watch count exceeds per-session cap %d", m.cfg.WatchMaxPerSession)} + } + if sessionWatches == nil { + sessionWatches = map[string]*watch{} + m.watches[w.sessionID] = sessionWatches + } + sessionWatches[w.id] = w + m.total++ + m.activeTotal++ + m.mu.Unlock() + if m.cfg.Metrics != nil { + m.cfg.Metrics.BeginWatch() + } + + go func() { + result, err := m.runner(ctx, m.cfg.MQTT, req.Caller.Bearer, w.topicFilter, mqttbus.StreamOptions{ + ClientID: "dsx-exchange-mcp-watch-" + w.id, + MaxMessages: math.MaxInt32, + MaxDuration: time.Duration(ttlS) * time.Second, + OnSubscribed: func() { + m.markRunning(w.sessionID, w.id) + select { + case ready <- struct{}{}: + default: + } + }, + }, func(msg mqttbus.Message) error { + m.recordMessage(w.sessionID, w.id, msg) + return nil + }) + m.finish(w.sessionID, w.id, result, err) + select { + case finished <- streamFinished{result: result, err: err}: + default: + } + }() + + select { + case <-ready: + return m.startOutput(w.sessionID, w.id), nil + case done := <-finished: + if done.err != nil { + m.remove(w.sessionID, w.id) + return watchStartOutput{}, done.err + } + return m.startOutput(w.sessionID, w.id), nil + case <-time.After(m.startWait()): + return m.startOutput(w.sessionID, w.id), nil + } +} + +func (m *watchManager) read(req watchReadRequest) (watchReadOutput, error) { + if err := validateWatchCaller(req.Caller); err != nil { + return watchReadOutput{}, err + } + cursor, err := parseCursor(req.Cursor) + if err != nil { + return watchReadOutput{}, err + } + maxMessages, maxBytes, err := m.applyReadLimits(req.MaxMessages, req.MaxBytes) + if err != nil { + return watchReadOutput{}, err + } + + m.mu.Lock() + defer m.mu.Unlock() + w, err := m.lookupLocked(req.Caller, req.SubscriptionID) + if err != nil { + return watchReadOutput{}, err + } + + messages := make([]watchMessageOutput, 0, maxMessages) + bytes := 0 + nextCursor := strconv.FormatInt(w.cursor, 10) + for _, buffered := range w.buffer { + messageCursor, _ := strconv.ParseInt(buffered.cursor, 10, 64) + if messageCursor <= cursor { + continue + } + if len(messages) >= maxMessages { + break + } + if len(messages) > 0 && bytes+buffered.size > maxBytes { + break + } + bytes += buffered.size + nextCursor = buffered.cursor + messages = append(messages, watchMessageOutput{ + Cursor: buffered.cursor, + Topic: buffered.msg.Topic, + Payload: buffered.msg.Payload, + PayloadEncoding: buffered.msg.PayloadEncoding, + Retained: buffered.msg.Retained, + QoS: buffered.msg.QoS, + ReceivedAt: buffered.msg.ReceivedAt, + }) + } + + return watchReadOutput{ + SubscriptionID: w.id, + Status: w.status, + Messages: messages, + Count: len(messages), + NextCursor: nextCursor, + DroppedCount: w.droppedCount, + BufferWatermark: w.watermark(), + ExpiresAt: w.expiresAt, + LastError: w.lastError, + }, nil +} + +func (m *watchManager) status(req watchStatusRequest) (watchStatusOutput, error) { + if err := validateWatchCaller(req.Caller); err != nil { + return watchStatusOutput{}, err + } + + m.mu.Lock() + defer m.mu.Unlock() + w, err := m.lookupLocked(req.Caller, req.SubscriptionID) + if err != nil { + return watchStatusOutput{}, err + } + out := watchStatusOutput{ + SubscriptionID: w.id, + Status: w.status, + TopicFilter: w.topicFilter, + MessageCount: w.messageCount, + DroppedCount: w.droppedCount, + OldestCursor: w.oldestCursor(), + NewestCursor: strconv.FormatInt(w.cursor, 10), + ExpiresAt: w.expiresAt, + LastError: w.lastError, + BufferWatermark: w.watermark(), + } + if !w.lastMessage.IsZero() { + last := w.lastMessage + out.LastMessageAt = &last + } + return out, nil +} + +func (m *watchManager) stop(req watchStopRequest) (watchStopOutput, error) { + if err := validateWatchCaller(req.Caller); err != nil { + return watchStopOutput{}, err + } + + m.mu.Lock() + w, err := m.lookupLocked(req.Caller, req.SubscriptionID) + if err != nil { + m.mu.Unlock() + return watchStopOutput{}, err + } + out := watchStopOutput{ + SubscriptionID: w.id, + Status: watchStatusStopped, + StoppedReason: "user_requested", + MessageCount: w.messageCount, + DroppedCount: w.droppedCount, + } + w.stopped = true + w.status = watchStatusStopped + cancel := w.cancel + m.mu.Unlock() + + if cancel != nil { + cancel() + } + m.remove(req.Caller.SessionID, req.SubscriptionID) + return out, nil +} + +func (m *watchManager) applyStartLimits(req watchStartRequest) (int, int, int, error) { + ttlS := req.TTLS + if ttlS == 0 { + ttlS = m.cfg.WatchDefaultTTLS + } + if ttlS <= 0 { + return 0, 0, 0, &mqttbus.BusError{Code: mqttbus.CodeInvalidArgument, Message: "ttl_seconds must be greater than zero"} + } + if ttlS > m.cfg.WatchMaxTTLS { + return 0, 0, 0, &mqttbus.BusError{Code: mqttbus.CodeInvalidArgument, Message: fmt.Sprintf("ttl_seconds exceeds cap %d", m.cfg.WatchMaxTTLS)} + } + + bufferMessages := req.BufferMaxMessages + if bufferMessages == 0 { + bufferMessages = m.cfg.WatchDefaultBufferMessages + } + if bufferMessages <= 0 { + return 0, 0, 0, &mqttbus.BusError{Code: mqttbus.CodeInvalidArgument, Message: "buffer_max_messages must be greater than zero"} + } + if bufferMessages > m.cfg.WatchMaxBufferMessages { + return 0, 0, 0, &mqttbus.BusError{Code: mqttbus.CodeInvalidArgument, Message: fmt.Sprintf("buffer_max_messages exceeds cap %d", m.cfg.WatchMaxBufferMessages)} + } + + bufferBytes := req.BufferMaxBytes + if bufferBytes == 0 { + bufferBytes = m.cfg.WatchDefaultBufferBytes + } + if bufferBytes <= 0 { + return 0, 0, 0, &mqttbus.BusError{Code: mqttbus.CodeInvalidArgument, Message: "buffer_max_bytes must be greater than zero"} + } + if bufferBytes > m.cfg.WatchMaxBufferBytes { + return 0, 0, 0, &mqttbus.BusError{Code: mqttbus.CodeInvalidArgument, Message: fmt.Sprintf("buffer_max_bytes exceeds cap %d", m.cfg.WatchMaxBufferBytes)} + } + return ttlS, bufferMessages, bufferBytes, nil +} + +func (m *watchManager) applyReadLimits(maxMessages, maxBytes int) (int, int, error) { + if maxMessages == 0 { + maxMessages = m.cfg.WatchDefaultBufferMessages + } + if maxMessages <= 0 { + return 0, 0, &mqttbus.BusError{Code: mqttbus.CodeInvalidArgument, Message: "max_messages must be greater than zero"} + } + if maxMessages > m.cfg.WatchMaxBufferMessages { + return 0, 0, &mqttbus.BusError{Code: mqttbus.CodeInvalidArgument, Message: fmt.Sprintf("max_messages exceeds cap %d", m.cfg.WatchMaxBufferMessages)} + } + if maxBytes == 0 { + maxBytes = m.cfg.WatchDefaultBufferBytes + } + if maxBytes <= 0 { + return 0, 0, &mqttbus.BusError{Code: mqttbus.CodeInvalidArgument, Message: "max_bytes must be greater than zero"} + } + if maxBytes > m.cfg.WatchMaxBufferBytes { + return 0, 0, &mqttbus.BusError{Code: mqttbus.CodeInvalidArgument, Message: fmt.Sprintf("max_bytes exceeds cap %d", m.cfg.WatchMaxBufferBytes)} + } + return maxMessages, maxBytes, nil +} + +func (m *watchManager) startWait() time.Duration { + timeout := m.cfg.MQTT.ConnectTimeout + m.cfg.MQTT.SubscribeTimeout + time.Second + if timeout <= time.Second { + return 11 * time.Second + } + return timeout +} + +func (m *watchManager) startOutput(sessionID, subscriptionID string) watchStartOutput { + m.mu.Lock() + defer m.mu.Unlock() + w := m.watches[sessionID][subscriptionID] + if w == nil { + return watchStartOutput{} + } + return watchStartOutput{ + SubscriptionID: w.id, + Status: w.status, + TopicFilter: w.topicFilter, + Cursor: strconv.FormatInt(w.cursor, 10), + ExpiresAt: w.expiresAt, + RecommendedReadAfterSeconds: 30, + Limits: watchLimitsOutput{ + TTLSeconds: int(w.expiresAt.Sub(w.createdAt).Seconds()), + BufferMaxMessages: w.maxMessages, + BufferMaxBytes: w.maxBytes, + OverflowPolicy: "drop_oldest", + }, + } +} + +func (m *watchManager) markRunning(sessionID, subscriptionID string) { + m.mu.Lock() + defer m.mu.Unlock() + if w := m.watches[sessionID][subscriptionID]; w != nil && w.status == watchStatusStarting { + w.status = watchStatusRunning + } +} + +func (m *watchManager) recordMessage(sessionID, subscriptionID string, msg mqttbus.Message) { + m.mu.Lock() + defer m.mu.Unlock() + w := m.watches[sessionID][subscriptionID] + if w == nil { + return + } + w.cursor++ + w.messageCount++ + w.lastMessage = msg.ReceivedAt + size := len(msg.Topic) + len(msg.Payload) + w.buffer = append(w.buffer, bufferedWatchMessage{ + cursor: strconv.FormatInt(w.cursor, 10), + size: size, + msg: msg, + }) + w.bufferBytes += size + droppedCount := int64(0) + for len(w.buffer) > w.maxMessages || w.bufferBytes > w.maxBytes { + dropped := w.buffer[0] + w.buffer = w.buffer[1:] + w.bufferBytes -= dropped.size + w.droppedCount++ + droppedCount++ + } + if m.cfg.Metrics != nil { + m.cfg.Metrics.RecordWatchMessage() + m.cfg.Metrics.RecordWatchDrop(droppedCount) + } +} + +func (m *watchManager) finish(sessionID, subscriptionID string, result mqttbus.StreamResult, err error) { + m.mu.Lock() + w := m.watches[sessionID][subscriptionID] + if w == nil { + m.mu.Unlock() + return + } + endWatch := false + if w.active { + w.active = false + m.activeTotal-- + endWatch = true + } + switch { + case w.stopped: + w.status = watchStatusStopped + case err != nil: + w.status = watchStatusFailed + w.lastError = &errorBody{Code: errorCode(err), Message: publicMessage(err)} + case result.StoppedReason == mqttbus.StoppedMaxDuration: + w.status = watchStatusExpired + case result.StoppedReason == mqttbus.StoppedMaxMessages: + w.status = watchStatusFailed + w.lastError = &errorBody{Code: codeBufferOverflow, Message: "watch stream reached internal message cap"} + default: + w.status = watchStatusFailed + if result.StoppedReason != "" { + w.lastError = &errorBody{Code: result.StoppedReason, Message: "watch stream stopped"} + } + } + w.finishedAt = m.now() + m.mu.Unlock() + if endWatch && m.cfg.Metrics != nil { + m.cfg.Metrics.EndWatch() + } + + if w.status == watchStatusExpired || w.status == watchStatusFailed { + time.AfterFunc(m.retention, func() { + m.remove(sessionID, subscriptionID) + }) + } +} + +func (m *watchManager) lookupLocked(caller auth.Caller, subscriptionID string) (*watch, error) { + if strings.TrimSpace(subscriptionID) == "" { + return nil, &mqttbus.BusError{Code: codeSubscriptionNotFound, Message: "subscription_id is required"} + } + sessionWatches := m.watches[caller.SessionID] + if sessionWatches == nil { + return nil, &mqttbus.BusError{Code: codeSubscriptionNotFound, Message: "subscription is not active on this MCP session; it may have expired, been stopped, or been lost due to pod restart"} + } + w := sessionWatches[subscriptionID] + if w == nil { + return nil, &mqttbus.BusError{Code: codeSubscriptionNotFound, Message: "subscription is not active on this MCP session; it may have expired, been stopped, or been lost due to pod restart"} + } + if w.authKey != callerAuthKey(caller) { + return nil, &mqttbus.BusError{Code: codeSubscriptionNotOwner, Message: "caller does not own this subscription"} + } + return w, nil +} + +func (m *watchManager) remove(sessionID, subscriptionID string) { + m.mu.Lock() + sessionWatches := m.watches[sessionID] + if sessionWatches == nil || sessionWatches[subscriptionID] == nil { + m.mu.Unlock() + return + } + w := sessionWatches[subscriptionID] + endWatch := false + if w.active { + w.active = false + m.activeTotal-- + endWatch = true + } + delete(sessionWatches, subscriptionID) + m.total-- + if len(sessionWatches) == 0 { + delete(m.watches, sessionID) + } + m.mu.Unlock() + if endWatch && m.cfg.Metrics != nil { + m.cfg.Metrics.EndWatch() + } +} + +func activeSessionCount(sessionWatches map[string]*watch) int { + count := 0 + for _, w := range sessionWatches { + if w != nil && w.active { + count++ + } + } + return count +} + +func (w *watch) oldestCursor() string { + if len(w.buffer) == 0 { + return strconv.FormatInt(w.cursor, 10) + } + return w.buffer[0].cursor +} + +func (w *watch) watermark() watchWatermark { + return watchWatermark{ + OldestCursor: w.oldestCursor(), + NewestCursor: strconv.FormatInt(w.cursor, 10), + } +} + +func validateWatchCaller(caller auth.Caller) error { + if strings.TrimSpace(caller.SessionID) == "" { + return &mqttbus.BusError{Code: codeStatefulSessionRequired, Message: "background subscriptions require Mcp-Session-Id stateful routing"} + } + if strings.TrimSpace(caller.Bearer) == "" { + return &mqttbus.BusError{Code: mqttbus.CodeMissingBearer, Message: "missing caller bearer; gateway should pass Authorization through"} + } + return nil +} + +func callerAuthKey(caller auth.Caller) string { + return strings.Join([]string{ + caller.Tenant, + caller.Issuer, + caller.Subject, + caller.SpiffeID, + }, "\x00") +} + +func parseCursor(cursor string) (int64, error) { + if strings.TrimSpace(cursor) == "" { + return 0, nil + } + parsed, err := strconv.ParseInt(cursor, 10, 64) + if err != nil || parsed < 0 { + return 0, &mqttbus.BusError{Code: mqttbus.CodeInvalidArgument, Message: "cursor must be a non-negative integer string"} + } + return parsed, nil +} + +func randomSubscriptionID() string { + var raw [8]byte + if _, err := rand.Read(raw[:]); err != nil { + return "sub_" + strconv.FormatInt(time.Now().UnixNano(), 36) + } + return "sub_" + hex.EncodeToString(raw[:]) +} diff --git a/mcp/dsx-exchange-mcp/internal/server/watch_test.go b/mcp/dsx-exchange-mcp/internal/server/watch_test.go new file mode 100644 index 0000000..1628d9e --- /dev/null +++ b/mcp/dsx-exchange-mcp/internal/server/watch_test.go @@ -0,0 +1,139 @@ +// Copyright 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +package server + +import ( + "context" + "testing" + "time" + + "github.com/NVIDIA/dsx-exchange/mcp/dsx-exchange-mcp/internal/auth" + "github.com/NVIDIA/dsx-exchange/mcp/dsx-exchange-mcp/internal/mqttbus" +) + +func TestWatchManagerLifecycleReadOverflowAndStop(t *testing.T) { + cfg := Config{ + WatchDefaultTTLS: 30, + WatchMaxTTLS: 60, + WatchDefaultBufferMessages: 2, + WatchMaxBufferMessages: 10, + WatchDefaultBufferBytes: 1024, + WatchMaxBufferBytes: 2048, + WatchMaxPerSession: 2, + WatchMaxPerPod: 10, + } + normalizeConfig(&cfg) + m := newWatchManager(cfg) + m.newID = func() string { return "sub_test" } + m.retention = time.Millisecond + m.runner = fakeStreamRunner([]mqttbus.Message{ + {Topic: "BMS/v1/PUB/Value/Rack/RackPower/a", Payload: `{"value":1}`, PayloadEncoding: "utf8", ReceivedAt: time.Unix(1, 0)}, + {Topic: "BMS/v1/PUB/Value/Rack/RackPower/a", Payload: `{"value":2}`, PayloadEncoding: "utf8", ReceivedAt: time.Unix(2, 0)}, + {Topic: "BMS/v1/PUB/Value/Rack/RackPower/a", Payload: `{"value":3}`, PayloadEncoding: "utf8", ReceivedAt: time.Unix(3, 0)}, + }) + caller := testCaller() + + start, err := m.start(watchStartRequest{ + Caller: caller, + TopicFilter: "BMS/v1/PUB/Value/Rack/RackPower/#", + }) + if err != nil { + t.Fatalf("start returned error: %v", err) + } + if start.SubscriptionID != "sub_test" || start.Status != watchStatusRunning { + t.Fatalf("start = %#v, want running sub_test", start) + } + + read, err := m.read(watchReadRequest{ + Caller: caller, + SubscriptionID: "sub_test", + Cursor: "0", + MaxMessages: 10, + MaxBytes: 2048, + }) + if err != nil { + t.Fatalf("read returned error: %v", err) + } + if read.Count != 2 { + t.Fatalf("read count = %d, want 2", read.Count) + } + if read.Messages[0].Cursor != "2" || read.Messages[1].Cursor != "3" { + t.Fatalf("read cursors = %#v, want retained messages 2 and 3", read.Messages) + } + if read.DroppedCount != 1 { + t.Fatalf("dropped_count = %d, want 1", read.DroppedCount) + } + + status, err := m.status(watchStatusRequest{ + Caller: caller, + SubscriptionID: "sub_test", + }) + if err != nil { + t.Fatalf("status returned error: %v", err) + } + if status.MessageCount != 3 || status.DroppedCount != 1 { + t.Fatalf("status = %#v, want 3 messages and 1 drop", status) + } + + stop, err := m.stop(watchStopRequest{ + Caller: caller, + SubscriptionID: "sub_test", + }) + if err != nil { + t.Fatalf("stop returned error: %v", err) + } + if stop.Status != watchStatusStopped { + t.Fatalf("stop status = %q, want stopped", stop.Status) + } + + _, err = m.read(watchReadRequest{ + Caller: caller, + SubscriptionID: "sub_test", + }) + if got := mqttbus.ErrorCode(err); got != codeSubscriptionNotFound { + t.Fatalf("read after stop code = %q, want %q", got, codeSubscriptionNotFound) + } +} + +func TestWatchManagerRequiresStatefulSession(t *testing.T) { + cfg := Config{} + normalizeConfig(&cfg) + m := newWatchManager(cfg) + caller := testCaller() + caller.SessionID = "" + + _, err := m.start(watchStartRequest{ + Caller: caller, + TopicFilter: "BMS/v1/PUB/Value/Rack/RackPower/#", + }) + if got := mqttbus.ErrorCode(err); got != codeStatefulSessionRequired { + t.Fatalf("start without session code = %q, want %q", got, codeStatefulSessionRequired) + } +} + +func fakeStreamRunner(messages []mqttbus.Message) streamRunner { + return func(ctx context.Context, _ mqttbus.Config, _, _ string, opts mqttbus.StreamOptions, onMessage func(mqttbus.Message) error) (mqttbus.StreamResult, error) { + for _, msg := range messages { + if err := onMessage(msg); err != nil { + return mqttbus.StreamResult{}, err + } + } + if opts.OnSubscribed != nil { + opts.OnSubscribed() + } + <-ctx.Done() + return mqttbus.StreamResult{Count: len(messages), StoppedReason: mqttbus.StoppedCallerCancel}, ctx.Err() + } +} + +func testCaller() auth.Caller { + return auth.Caller{ + Bearer: "test-token", + SessionID: "session-1", + Tenant: "tenant-1", + Issuer: "issuer-1", + Subject: "subject-1", + SpiffeID: "spiffe://test", + } +} diff --git a/mcp/dsx-exchange-mcp/internal/server/watch_tools.go b/mcp/dsx-exchange-mcp/internal/server/watch_tools.go new file mode 100644 index 0000000..a7de081 --- /dev/null +++ b/mcp/dsx-exchange-mcp/internal/server/watch_tools.go @@ -0,0 +1,263 @@ +// Copyright 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +package server + +import ( + "context" + "encoding/json" + "fmt" + "log/slog" + "strings" + "time" + + "github.com/modelcontextprotocol/go-sdk/mcp" + + "github.com/NVIDIA/dsx-exchange/mcp/dsx-exchange-mcp/internal/auth" + "github.com/NVIDIA/dsx-exchange/mcp/dsx-exchange-mcp/internal/mqttbus" + "github.com/NVIDIA/dsx-exchange/mcp/dsx-exchange-mcp/internal/schemaindex" +) + +const ( + toolStartSubscription = "dsx_exchange_start_subscription" + toolReadSubscription = "dsx_exchange_read_subscription" + toolStatusSubscription = "dsx_exchange_subscription_status" + toolStopSubscription = "dsx_exchange_stop_subscription" +) + +type startSubscriptionInput struct { + TopicFilter string `json:"topic_filter,omitempty" jsonschema:"Explicit MQTT topic filter to watch. Use either topic_filter or selector, not both."` + Selector findTopicsInput `json:"selector,omitempty" jsonschema:"AsyncAPI selector used to derive one topic filter when the caller does not know the raw MQTT path."` + TTLSeconds int `json:"ttl_seconds,omitempty" jsonschema:"Watch TTL in seconds. Defaults to configured value and is capped by MCP_WATCH_MAX_TTL_S."` + BufferMaxMessages int `json:"buffer_max_messages,omitempty" jsonschema:"Maximum messages retained in the pod-local ring buffer."` + BufferMaxBytes int `json:"buffer_max_bytes,omitempty" jsonschema:"Maximum payload/topic bytes retained in the pod-local ring buffer."` +} + +type readSubscriptionInput struct { + SubscriptionID string `json:"subscription_id" jsonschema:"Subscription ID returned by dsx_exchange_start_subscription."` + Cursor string `json:"cursor,omitempty" jsonschema:"Last cursor seen by the caller. Empty means read from the beginning of the retained local buffer."` + MaxMessages int `json:"max_messages,omitempty" jsonschema:"Maximum messages to return from the local buffer."` + MaxBytes int `json:"max_bytes,omitempty" jsonschema:"Maximum topic/payload bytes to return from the local buffer."` +} + +type subscriptionIDInput struct { + SubscriptionID string `json:"subscription_id" jsonschema:"Subscription ID returned by dsx_exchange_start_subscription."` +} + +func registerWatchTools(s *mcp.Server, cfg Config, watches *watchManager) { + mcp.AddTool(s, &mcp.Tool{ + Name: toolStartSubscription, + Description: "Start a pod-local background MQTT watch and return a subscription_id immediately after the initial MQTT subscribe succeeds or is accepted as starting. " + + "The caller bearer is used only in memory as the MQTT password. Broker/auth-callout enforces topic ACLs. " + + "Requires stateful MCP routing via Mcp-Session-Id. The watch is lost on owning pod restart or session loss.", + }, func(ctx context.Context, _ *mcp.CallToolRequest, in startSubscriptionInput) (*mcp.CallToolResult, watchStartOutput, error) { + return startSubscriptionTool(ctx, cfg, watches, in) + }) + + mcp.AddTool(s, &mcp.Tool{ + Name: toolReadSubscription, + Description: "Read a bounded batch of messages from a pod-local background watch by cursor. " + + "This reads only the owning pod's in-memory ring buffer; if the session or pod-local state was lost, the tool returns subscription_not_found or session_lost.", + }, func(ctx context.Context, _ *mcp.CallToolRequest, in readSubscriptionInput) (*mcp.CallToolResult, watchReadOutput, error) { + return readSubscriptionTool(ctx, cfg, watches, in) + }) + + mcp.AddTool(s, &mcp.Tool{ + Name: toolStatusSubscription, + Description: "Return pod-local status, counters, watermarks, expiry, and last error for a background watch owned by the current Mcp-Session-Id.", + }, func(ctx context.Context, _ *mcp.CallToolRequest, in subscriptionIDInput) (*mcp.CallToolResult, watchStatusOutput, error) { + return statusSubscriptionTool(ctx, cfg, watches, in) + }) + + mcp.AddTool(s, &mcp.Tool{ + Name: toolStopSubscription, + Description: "Stop a pod-local background watch owned by the current Mcp-Session-Id, disconnect its MQTT stream, and release the local buffer.", + }, func(ctx context.Context, _ *mcp.CallToolRequest, in subscriptionIDInput) (*mcp.CallToolResult, watchStopOutput, error) { + return stopSubscriptionTool(ctx, cfg, watches, in) + }) +} + +func startSubscriptionTool(ctx context.Context, cfg Config, watches *watchManager, in startSubscriptionInput) (*mcp.CallToolResult, watchStartOutput, error) { + start := time.Now() + caller := auth.FromContext(ctx) + if cfg.Metrics != nil { + cfg.Metrics.BeginToolCall() + defer cfg.Metrics.EndToolCall() + } + + topicFilter, err := resolveSubscriptionTopic(cfg, in) + if err != nil { + recordWatchAudit(toolStartSubscription, caller, "", "", 0, start, err, cfg) + return toolError[watchStartOutput](mqttbus.ErrorCode(err), publicMessage(err)) + } + + out, err := watches.start(watchStartRequest{ + Caller: caller, + TopicFilter: topicFilter, + TTLS: in.TTLSeconds, + BufferMaxMessages: in.BufferMaxMessages, + BufferMaxBytes: in.BufferMaxBytes, + }) + recordWatchAudit(toolStartSubscription, caller, out.SubscriptionID, topicFilter, 0, start, err, cfg) + if err != nil { + return toolError[watchStartOutput](mqttbus.ErrorCode(err), publicMessage(err)) + } + return toolOK("started subscription "+out.SubscriptionID, out) +} + +func readSubscriptionTool(ctx context.Context, cfg Config, watches *watchManager, in readSubscriptionInput) (*mcp.CallToolResult, watchReadOutput, error) { + start := time.Now() + caller := auth.FromContext(ctx) + if cfg.Metrics != nil { + cfg.Metrics.BeginToolCall() + defer cfg.Metrics.EndToolCall() + } + out, err := watches.read(watchReadRequest{ + Caller: caller, + SubscriptionID: in.SubscriptionID, + Cursor: in.Cursor, + MaxMessages: in.MaxMessages, + MaxBytes: in.MaxBytes, + }) + recordWatchAudit(toolReadSubscription, caller, in.SubscriptionID, "", out.Count, start, err, cfg) + if err != nil { + return toolError[watchReadOutput](mqttbus.ErrorCode(err), publicMessage(err)) + } + return toolOK(fmt.Sprintf("read %d subscription messages", out.Count), out) +} + +func statusSubscriptionTool(ctx context.Context, cfg Config, watches *watchManager, in subscriptionIDInput) (*mcp.CallToolResult, watchStatusOutput, error) { + start := time.Now() + caller := auth.FromContext(ctx) + if cfg.Metrics != nil { + cfg.Metrics.BeginToolCall() + defer cfg.Metrics.EndToolCall() + } + out, err := watches.status(watchStatusRequest{ + Caller: caller, + SubscriptionID: in.SubscriptionID, + }) + recordWatchAudit(toolStatusSubscription, caller, in.SubscriptionID, out.TopicFilter, 0, start, err, cfg) + if err != nil { + return toolError[watchStatusOutput](mqttbus.ErrorCode(err), publicMessage(err)) + } + return toolOK("subscription status "+out.Status, out) +} + +func stopSubscriptionTool(ctx context.Context, cfg Config, watches *watchManager, in subscriptionIDInput) (*mcp.CallToolResult, watchStopOutput, error) { + start := time.Now() + caller := auth.FromContext(ctx) + if cfg.Metrics != nil { + cfg.Metrics.BeginToolCall() + defer cfg.Metrics.EndToolCall() + } + out, err := watches.stop(watchStopRequest{ + Caller: caller, + SubscriptionID: in.SubscriptionID, + }) + recordWatchAudit(toolStopSubscription, caller, in.SubscriptionID, "", 0, start, err, cfg) + if err != nil { + return toolError[watchStopOutput](mqttbus.ErrorCode(err), publicMessage(err)) + } + return toolOK("stopped subscription "+out.SubscriptionID, out) +} + +func resolveSubscriptionTopic(cfg Config, in startSubscriptionInput) (string, error) { + topicFilter := strings.TrimSpace(in.TopicFilter) + hasSelector := selectorPresent(in.Selector) + if topicFilter != "" && hasSelector { + return "", &mqttbus.BusError{Code: mqttbus.CodeInvalidArgument, Message: "use either topic_filter or selector, not both"} + } + if topicFilter != "" { + if err := mqttbus.ValidateTopicFilter(topicFilter); err != nil { + return "", err + } + return topicFilter, nil + } + if !hasSelector { + return "", &mqttbus.BusError{Code: mqttbus.CodeInvalidArgument, Message: "topic_filter or selector is required"} + } + idx, err := schemaindex.Default() + if err != nil { + return "", &mqttbus.BusError{Code: mqttbus.CodeInternalError, Message: err.Error()} + } + matches := idx.Search(schemaindex.SearchOptions{ + Domain: in.Selector.Domain, + Query: in.Selector.Query, + Role: in.Selector.Role, + ObjectType: in.Selector.ObjectType, + PointType: in.Selector.PointType, + OperationAction: in.Selector.OperationAction, + Limit: cfg.FindTopicsMaxLimit, + }) + if len(matches) == 0 { + return "", &mqttbus.BusError{Code: codeSchemaTopicNotFound, Message: "selector did not match any AsyncAPI topic"} + } + if len(matches) > 1 { + return "", &mqttbus.BusError{Code: codeSchemaTopicAmbiguous, Message: fmt.Sprintf("selector matched %d AsyncAPI topics; call dsx_exchange_find_topics and choose a topic_filter", len(matches))} + } + if err := mqttbus.ValidateTopicFilter(matches[0].TopicFilter); err != nil { + return "", err + } + return matches[0].TopicFilter, nil +} + +func selectorPresent(in findTopicsInput) bool { + return strings.TrimSpace(in.Domain) != "" || + strings.TrimSpace(in.Query) != "" || + strings.TrimSpace(in.Role) != "" || + strings.TrimSpace(in.ObjectType) != "" || + strings.TrimSpace(in.PointType) != "" || + strings.TrimSpace(in.OperationAction) != "" +} + +func toolOK[T any](summary string, out T) (*mcp.CallToolResult, T, error) { + raw, _ := json.Marshal(out) + return &mcp.CallToolResult{ + Content: []mcp.Content{ + &mcp.TextContent{Text: summary}, + &mcp.TextContent{Text: string(raw)}, + }, + }, out, nil +} + +func toolError[T any](code, message string) (*mcp.CallToolResult, T, error) { + var zero T + if code == "" { + code = mqttbus.CodeInternalError + } + body := structuredError{Error: errorBody{Code: code, Message: message}} + raw, _ := json.Marshal(body) + return &mcp.CallToolResult{ + IsError: true, + Content: []mcp.Content{&mcp.TextContent{Text: string(raw)}}, + }, zero, nil +} + +func recordWatchAudit(tool string, caller auth.Caller, subscriptionID, topicFilter string, messages int, start time.Time, err error, cfg Config) { + code := errorCode(err) + duration := time.Since(start) + if cfg.Metrics != nil { + cfg.Metrics.RecordToolCall(tool, code, "", duration, messages) + } + decision := "allowed" + if code != "" { + decision = "error" + } + slog.Info("watch tool invocation", + "audit", true, + "tool", tool, + "caller_tenant", caller.Tenant, + "caller_issuer", caller.Issuer, + "caller_subject", caller.Subject, + "caller_spiffe_id", caller.SpiffeID, + "mcp_session_id", caller.SessionID, + "bearer_present", caller.Bearer != "", + "subscription_id", subscriptionID, + "topic_filter", topicFilter, + "decision", decision, + "message_count", messages, + "duration_ms", duration.Milliseconds(), + "error_code", code, + ) +} diff --git a/mcp/dsx-exchange-mcp/internal/specs/data/.gitkeep b/mcp/dsx-exchange-mcp/internal/specs/data/.gitkeep new file mode 100644 index 0000000..429c72e --- /dev/null +++ b/mcp/dsx-exchange-mcp/internal/specs/data/.gitkeep @@ -0,0 +1,3 @@ +# Copyright 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + diff --git a/mcp/dsx-exchange-mcp/internal/specs/specs.go b/mcp/dsx-exchange-mcp/internal/specs/specs.go new file mode 100644 index 0000000..e56095d --- /dev/null +++ b/mcp/dsx-exchange-mcp/internal/specs/specs.go @@ -0,0 +1,72 @@ +// Copyright 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +// Package specs exposes the embedded DSX Exchange AsyncAPI documents. +package specs + +import ( + "bytes" + "fmt" + "io/fs" + "path" + "sort" + "strings" + "sync" + + "github.com/NVIDIA/dsx-exchange/mcp/dsx-exchange-mcp/schemas" +) + +var ( + once sync.Once + domains []string + contents map[string][]byte +) + +func load() { + contents = map[string][]byte{} + _ = fs.WalkDir(schemas.FS, "asyncapi", func(p string, d fs.DirEntry, err error) error { + if err != nil || d.IsDir() { + return nil + } + ext := strings.ToLower(path.Ext(p)) + if ext != ".yaml" && ext != ".yml" && ext != ".json" { + return nil + } + body, rerr := schemas.FS.ReadFile(p) + if rerr != nil || len(body) == 0 { + return nil + } + if !bytes.Contains(body, []byte("asyncapi:")) { + return nil + } + domain := path.Base(path.Dir(p)) + if domain == "" || domain == "asyncapi" { + return nil + } + contents[domain] = body + return nil + }) + domains = make([]string, 0, len(contents)) + for k := range contents { + domains = append(domains, k) + } + sort.Strings(domains) +} + +// List returns the domain names with non-empty AsyncAPI specs. +func List() []string { + once.Do(load) + out := make([]string, len(domains)) + copy(out, domains) + return out +} + +// Read returns the raw spec bytes for a domain. +func Read(domain string) ([]byte, error) { + once.Do(load) + body, ok := contents[domain] + if !ok { + return nil, fmt.Errorf("unknown domain %q", domain) + } + return body, nil +} diff --git a/mcp/dsx-exchange-mcp/schemas/.gitignore b/mcp/dsx-exchange-mcp/schemas/.gitignore new file mode 100644 index 0000000..0daba5d --- /dev/null +++ b/mcp/dsx-exchange-mcp/schemas/.gitignore @@ -0,0 +1,10 @@ +# IDE +.idea/ +.vscode/ +*.swp +*.swo + +# Output files +dist/ + +.DS_Store diff --git a/mcp/dsx-exchange-mcp/schemas/README.md b/mcp/dsx-exchange-mcp/schemas/README.md new file mode 100644 index 0000000..50daf14 --- /dev/null +++ b/mcp/dsx-exchange-mcp/schemas/README.md @@ -0,0 +1,30 @@ +# DSX Exchange Schemas + +The DSX Event Bus itself is schema agnostic. Brokers relay subjects, and enforce prefix rules, and enforce ACLs. +Clients participating in the DSX Exchange program must publish a formal [AsyncAPI](https://asyncapi.com/) definition here covering every exposed subject and payload so downstream consumers can rely on consistent contracts and documentation. + +AsyncAPI is our chosen schema format. AsyncAPI is a Linux Foundation project analogous to OpenAPI for async systems. The specification natively models MQTT servers and channels (topics) plus publish/subscribe operations, messages, and security traits, which is sufficient to describe our MQTT endpoints. + +The schema's purpose is to expose clear, human-readable documentation for consumers. It does not drive routing, validation, or otherwise alter broker behaviour. Teams may auto-generate SDKs and diffs from those documents, but any such tooling sits outside of this repository. [Modelina](https://www.asyncapi.com/tools/modelina) may be used for model generation. Full client generation is somewhat lacking. + +The schema documentation is published at [docs.nvidia.com/dsx-exchange/schema](https://docs.nvidia.com/dsx-exchange/schema). + +## Cloud Events + +Clients that elect to emit CloudEvents should follow the official MQTT Protocol Binding so metadata (type, source, id, datacontenttype) is encoded consistently. Since DSX standardizes on MQTT 3.1.1, publishers use the structured mode defined by the binding. + +Adopting CloudEvents remains optional. The binding is simply the formally supported way to represent CloudEvents over MQTT. An [example is given here](cloud-events-example.yaml). + +## Repository Structure + +Each logical component is given a directory with a single YAML specification file in [/schemas/asyncapi](/schemas/asyncapi/). Each component's team is responsible for updating and reviewing their own schema through the standard GitHub pull request workflow. + +## Running Checks Locally + +Run the repository checks before opening a pull request: + +```bash +make check-license-headers +``` + +See `make help` for additional targets. diff --git a/mcp/dsx-exchange-mcp/schemas/asyncapi/bms/bms.yaml b/mcp/dsx-exchange-mcp/schemas/asyncapi/bms/bms.yaml new file mode 100644 index 0000000..199dceb --- /dev/null +++ b/mcp/dsx-exchange-mcp/schemas/asyncapi/bms/bms.yaml @@ -0,0 +1,7224 @@ +# Copyright 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +asyncapi: 3.1.0 + +info: + title: BMS Event Bus + version: 1.0.0 + description: | + Telemetry and control event catalog for the Building Management System (BMS) + over MQTT. Provides real-time point values and point metadata for all supported + object and point types. + + ## How to use this spec + + Each monitored point follows a **Value / Metadata** pattern: + + - **Value** messages carry the live reading (`value`, `timestamp`, `quality`). + + Subscribe to a value channel to receive real-time telemetry. Values are published whenever they change and republished every 100 seconds when they do not change. + + - **Metadata** messages describe the point (units, identifiers, relationships). + + **Always receive metadata before interpreting values.** Metadata is retained and published once at startup. It is subsequently published every 100 seconds. + + ## Topic structure + + | Publisher | Topic Type | Pattern | + | :---------- | :--------- | :--------------------------------------------------------------- | + | BMS | Value | `BMS/v1/PUB/Value/{objectType}/{pointType}/{tagPath}` | + | BMS | Metadata | `BMS/v1/PUB/Metadata/{objectType}/{pointType}/{tagPath}` | + | Integration | Value | `BMS/v1/{integration}/Value/{objectType}/{pointType}/{tagPath}` | + + The `{tagPath}` is a vendor-defined hierarchical path and may contain multiple `/` segments. Each `{tagPath}` must be unique for each point and is usually derived from the BMS system based on the BMS point name. + + Use `#` to subscribe to all topics under a given hierarchy (multi-level wildcard). + + Use `+` to match exactly one topic level (single-level wildcard). + + ## Publisher rules + + - **BMS** publishes all metadata — including for points whose values are written by integrations. + + - **BMS** publishes its own point values on `BMS/v1/PUB/Value/...`. + + - **Integrations** are any system (MQTT Client) external to the BMS. Whenever they need to directly send messages to the BMS, they publish values on `BMS/v1/{integration}/Value/...`. Integrations do **not** publish metadata. + + + ## Integration publishing contract + + > **This rule applies globally to every integration-published point across all + > objectTypes.** + + BMS publishes metadata for every monitored point — including points whose _values_ are written by external integrations. For such points the metadata payload contains an `integration` field identifying which integration owns that point's value. + + | Metadata field | Type | Meaning | + | :------------- | :--- | :------ | + | `integration` | string | Identifier of the integration that must publish the value for this point | + + ### Topic derivation rule + + The value topic is derived **methodically** from the respective metadata topic (published by BMS) that carried the `integration` field. DSX Exchange Access Control Lists will typically be created so that MQTT Clients are provided access to publish and subscribe to specific namespaces that align with the `[Publisher]`: + + BMS/v1/**[Publisher]/[TopicType]**/{objectType}/{pointType}/{tagPath} + + | Segment | Metadata topic | Value topic | + | :------ | :------------- | :---------- | + | Publisher | `PUB` | value of `integration` field in metadata | + | TopicType | `Metadata` | `Value` | + | Remainder | `{objectType}/{pointType}/{tagPath}` | `{objectType}/{pointType}/{tagPath}` | + + In other words, the Publisher integration will derive the following value topic to publish based on the corresponding BMS metadata: + + ```text + BMS/v1/PUB/Metadata/{objectType}/{pointType}/{tagPath} + ↓ replace PUB → {integration}, Metadata → Value + BMS/v1/{integration}/Value/{objectType}/{pointType}/{tagPath} + ``` + + ### Contract + + When an integration receives a BMS metadata message and the `integration` field **matches its own identifier**, the integration **MUST**: + + 1. Note the full metadata topic it arrived on. + 2. Derive the value topic by substituting `PUB` → own identifier and `Metadata` → `Value`, keeping `{objectType}/{pointType}/{tagPath}` unchanged. + 3. Publish value messages to that derived topic. + + Integrations **MUST NOT**: + + - Publish values for points whose metadata `integration` field does not match their own identifier. + + - Publish metadata (BMS is the sole metadata publisher). + + ### Example flow + + ```text + BMS publishes metadata → + Topic: BMS/v1/PUB/Metadata/CDU/LiquidTemperatureSpRequest/site1/row3/cdu5 + Payload: { ..., "integration": "MEPAI", ... } + + Integration "MEPAI" receives the metadata, recognises its identifier, + derives its value topic → + BMS/v1/PUB/Metadata/CDU/LiquidTemperatureSpRequest/site1/row3/cdu5 + ↓ PUB→MEPAI, Metadata→Value + BMS/v1/MEPAI/Value/CDU/LiquidTemperatureSpRequest/site1/row3/cdu5 + ``` + + MEPAI publishes its setpoint reading to that derived topic. + + The same contract governs all other integration-published points, including `Rack/RackLeakDetectTray`, `Rack/RackLiquidIsolationRequest`, `Rack/RackElectricalIsolationRequest`, `System/HeartbeatTimestampIntegration`, `System/HeartbeatEchoIntegration`, `CDU/LiquidTemperatureSpRequest`, and any future integration-owned pointTypes. + + + ## Metadata types and concepts + + - **objectType**: Object Types are restricted to specific strings in accordance with this AsyncAPI. They typically represent BMS equipment or devices. + + - **System**: A System can be the BMS or an Integration to the BMS. Heartbeat points and system-to-system communication point types are typically defined inside of a System object type. System Heartbeat point types are expected to operate as follows. An integration may choose to use the Echo points or not. + + All four heartbeat pointTypes require `objectName` and `objectId` in metadata to identify which System the heartbeat belongs to. By convention, an integration's `objectId` matches the same string used as its `integration` metadata field on its other points (so MEPAI1's System object has `objectId: "MEPAI1"`). + + - **HeartbeatTimestampBms** — Publisher: BMS. The BMS publishes its own timestamp every 10 seconds. One instance globally. `objectId` identifies the BMS (e.g., `"BMS"`). `integration` field: not used. + + - **HeartbeatTimestampIntegration** — Publisher: Integration. Each integration publishes its own timestamp every 10 seconds, one per integration. `objectId` identifies the integration publishing (e.g., `"MEPAI1"`). `integration` field: required (drives topic). + + - **HeartbeatEchoBms** — Publisher: BMS. The BMS reads each integration's `HeartbeatTimestampIntegration` value and re-publishes it on this point type, allowing each integration to confirm round-trip. One instance per connected integration. `objectId` identifies the integration whose timestamp is being echoed (e.g., `"MEPAI1"`). `integration` field: not used. + + - **HeartbeatEchoIntegration** — Publisher: Integration. Each integration reads the BMS's `HeartbeatTimestampBms` value and re-publishes it on this point type, allowing the BMS to confirm round-trip with that integration. One per connected integration. `objectId` identifies the BMS being echoed (e.g., `"BMS"`). `integration` field: required (drives topic). + + Naming convention: the `Bms`/`Integration` suffix indicates the publisher of the point. The party whose timestamp is being echoed is encoded in `objectId`, not in the point name. + + - **Rack**: A Rack is a special object type and has specific point types that can only be used with a Rack object type. Many integrations and MQTT Clients will only ingest data from the Rack object type. Other integrations will consider Rack data as the most important or interesting data in the AI Factory. Mechanical and Electrical design (and therefore BMS Data) at the rack is also more standardized than mechanical and electrical systems as you move out of the white space and to the gray space of the AI Factory. For these reasons, the point types associated with a Rack object type are generally more specific than any other object type, making ingesting and understanding rack data more straight-forward. + + - **PowerMeter**: A PowerMeter is a special object type and has specific point types that can only be used with a PowerMeter object type. PowerMeter point types contain the metadata needed to understand electrical power path. This data is used by integrations / MQTT Clients for power management strategies. + + - **Electrical Equipment**: This is a general category, and several object types exist in this AsyncAPI for electrical equipment. Electrical equipment point types typically use metadata to associate the object / equipment with a power meter to show where the equipment lands in the power path. + + - **Mechanical Equipment**: This is a general category, and several object types exist in this AsyncAPI for mechanical equipment. + + - **GenericObject**: This object type is reserved and should not be used unless no other object type is applicable. + + - **pointType**: Point Types are restricted to specific strings in accordance with this AsyncAPI. Point types are also restricted to specific object types. Some point types apply to multiple object types while others are restricted to specific object types. + + - **engUnit**: Engineering Units describe the units of measure for the point / topic. If engineering units are used, then state text does not apply. + + - **stateText**: State Text describes what a binary or integer value means. Each binary or integer value will have a state text identified. If state text is used, then engineering units do not apply. + + - **rackLocationName**: This is the human readable name given to a rack location. + + - **rackLocationId**: This is the unique identifier for a rack location. It can be the same as the rackLocationName if human readable and unique. This must allow the IT side integrations and OT side BMS to associate a point with the same physical rack (same identifier on both systems). + + - **objectName**: This is the human readable name given to the object. + + - **objectId**: This is the unique identifier for an object. It can be the same as the objectName if human readable and unique. + + - **associateId**: This will list the objectId of an associated object. The intent is for this object to be considered part of the other object, especially for `servesId` metadata. Commonly used to prevent parallel power paths (parallel "serves" relationships) when electrical equipment objects need to be associated with power meters. Also applicable to mechanical equipment object types to prevent liquid and air flow parallel paths where not intended. + + - **servesId**: This will list the objectId of the object that is served by this object. Creates a one-way relationship. Typically used to indicate electrical power path or liquid/air fluid flow path towards the rack. + + - **processArea**: This is used to provide more information on the point location or purpose. In general, process area metadata should be used, in conjunction with other metadata, to make points unique and allow integrations / MQTT Clients to clearly understand what a point represents in the AI factory. Example: for a CDU object type and LiquidTemperature point type, the process area metadata could include `"Secondary"` and `"Supply"`. This would make it clear that the temperature sensor was located on the secondary side of the CDU and on the supply line. + + - **phase**: This is used to identify which phase, of a 3-phase electrical system, the point is associated with. + + - **isSetpoint**: This can be set to `true` (unquoted, lowercase) to indicate that a point is a setpoint rather than a sensor reading. A setpoint is a target or ideal value that a system is trying to maintain a process variable at. + + - **scope**: Scope is used for System heartbeat points. If the BMS has multiple MQTT Clients connected to DSX Exchange, each could be publishing different data to the MQTT Broker. In this case each MQTT Client should have separate heartbeat points. Scope can be used in this case to identify what MQTT Topics / Namespace the heartbeat point is associated with — and thereby what topics are impacted when a heartbeat is lost. + + - **integration**: Integration metadata is used to indicate which integration a point is associated with. It also indicates the namespace `[Publisher]` an integration is required to write the corresponding value back to. + + +# ============================================================================= +# Servers +# ============================================================================= + +# servers: +# production: +# host: broker.example.com +# protocol: mqtt +# description: MQTT broker for BMS telemetry and control + + +# ============================================================================= +# Channels +# ============================================================================= + +channels: + + # --------------------------------------------------------------------------- + # Rack + # --------------------------------------------------------------------------- + + rackBmsValue: + address: 'BMS/v1/PUB/Value/Rack/{pointType}/{tagPath}' + description: | + Real-time values published by the BMS for all Rack monitoring points. + + **MQTT wildcard examples** + + - All rack values: `BMS/v1/PUB/Value/Rack/#` + parameters: + pointType: + enum: + - RackLiquidSupplyTemperature + - RackLiquidReturnTemperature + - RackLiquidFlow + - RackLiquidDifferentialPressure + - RackLiquidDifferentialPressureSp + - RackControlValvePosition + - RackPower + - RackLeakDetect + - RackLeakSensorFault + - RackLiquidIsolationStatus + - RackElectricalIsolationStatus + description: BMS-published Rack point type. + tagPath: + description: Vendor-defined hierarchical tag path. + messages: + valueMessage: + $ref: '#/components/messages/ValueMessage' + + rackMetadata: + address: 'BMS/v1/PUB/Metadata/Rack/{pointType}/{tagPath}' + description: | + BMS-published metadata for all Rack points. + Includes integration-owned points which carries the `integration` field. + + **MQTT wildcard examples** + + - All rack metadata: `BMS/v1/PUB/Metadata/Rack/#` + parameters: + pointType: + enum: + - RackLiquidSupplyTemperature + - RackLiquidReturnTemperature + - RackLiquidFlow + - RackLiquidDifferentialPressure + - RackLiquidDifferentialPressureSp + - RackControlValvePosition + - RackPower + - RackLeakDetect + - RackLeakSensorFault + - RackLeakDetectTray + - RackLiquidIsolationStatus + - RackElectricalIsolationStatus + - RackLiquidIsolationRequest + - RackElectricalIsolationRequest + description: Rack point type. + tagPath: + description: Must match the tagPath of the corresponding value topic exactly. + messages: + rackLiquidSupplyTemperature: + $ref: '#/components/messages/RackLiquidSupplyTemperatureMsg' + rackLiquidReturnTemperature: + $ref: '#/components/messages/RackLiquidReturnTemperatureMsg' + rackLiquidFlow: + $ref: '#/components/messages/RackLiquidFlowMsg' + rackLiquidDifferentialPressure: + $ref: '#/components/messages/RackLiquidDifferentialPressureMsg' + rackLiquidDifferentialPressureSp: + $ref: '#/components/messages/RackLiquidDifferentialPressureSpMsg' + rackControlValvePosition: + $ref: '#/components/messages/RackControlValvePositionMsg' + rackPower: + $ref: '#/components/messages/RackPowerMsg' + rackLeakDetect: + $ref: '#/components/messages/RackLeakDetectMsg' + rackLeakSensorFault: + $ref: '#/components/messages/RackLeakSensorFaultMsg' + rackLeakDetectTray: + $ref: '#/components/messages/RackLeakDetectTrayMsg' + rackLiquidIsolationStatus: + $ref: '#/components/messages/RackLiquidIsolationStatusMsg' + rackElectricalIsolationStatus: + $ref: '#/components/messages/RackElectricalIsolationStatusMsg' + rackLiquidIsolationRequest: + $ref: '#/components/messages/RackLiquidIsolationRequestMsg' + rackElectricalIsolationRequest: + $ref: '#/components/messages/RackElectricalIsolationRequestMsg' + + rackIntegrationValue: + address: 'BMS/v1/{integration}/Value/Rack/{pointType}/{tagPath}' + description: | + Values published by integrations for Rack control points. + + **MQTT wildcard examples** + + - All integration rack values: `BMS/v1/+/Value/Rack/#` + parameters: + integration: + description: Integration identifier. + pointType: + enum: + - RackLeakDetectTray + - RackLiquidIsolationRequest + - RackElectricalIsolationRequest + description: Integration-published Rack point type. + tagPath: + description: Must match the tagPath from the corresponding BMS metadata exactly. + messages: + valueMessage: + $ref: '#/components/messages/ValueMessage' + + # --------------------------------------------------------------------------- + # PowerMeter + # --------------------------------------------------------------------------- + + powerMeterValue: + address: 'BMS/v1/PUB/Value/PowerMeter/{pointType}/{tagPath}' + description: | + Real-time values published by the BMS for all PowerMeter points. + + **MQTT wildcard examples** + + - All power meter values: `BMS/v1/PUB/Value/PowerMeter/#` + parameters: + pointType: + enum: + - Voltage + - PowerFactor + - Frequency + - ApparentPower + - ActivePower + - Current + - CurrentLimit + - PhaseCurrent + - GenericPoint + description: PowerMeter point type. + tagPath: + description: Vendor-defined hierarchical tag path. + messages: + valueMessage: + $ref: '#/components/messages/ValueMessage' + + powerMeterMetadata: + address: 'BMS/v1/PUB/Metadata/PowerMeter/{pointType}/{tagPath}' + description: | + BMS-published metadata for all PowerMeter points. + + **MQTT wildcard examples** + + - All power meter metadata: `BMS/v1/PUB/Metadata/PowerMeter/#` + parameters: + pointType: + enum: + - Voltage + - PowerFactor + - Frequency + - ApparentPower + - ActivePower + - Current + - CurrentLimit + - PhaseCurrent + - GenericPoint + description: PowerMeter point type. + tagPath: + description: Vendor-defined hierarchical tag path. + messages: + voltage: + $ref: '#/components/messages/PowerMeterVoltageMsg' + powerFactor: + $ref: '#/components/messages/PowerMeterPowerFactorMsg' + frequency: + $ref: '#/components/messages/PowerMeterFrequencyMsg' + apparentPower: + $ref: '#/components/messages/PowerMeterApparentPowerMsg' + activePower: + $ref: '#/components/messages/PowerMeterActivePowerMsg' + current: + $ref: '#/components/messages/PowerMeterCurrentMsg' + currentLimit: + $ref: '#/components/messages/PowerMeterCurrentLimitMsg' + genericPoint: + $ref: '#/components/messages/GenericPowerMeterPointMsg' + phaseCurrent: + $ref: '#/components/messages/PowerMeterPhaseCurrentMsg' + + # --------------------------------------------------------------------------- + # BESS + # --------------------------------------------------------------------------- + + bessValue: + address: 'BMS/v1/PUB/Value/BESS/{pointType}/{tagPath}' + description: | + Real-time values published by the BMS for BESS points. + Extensible: additional vendor-specific pointTypes may be present. + + **MQTT wildcard examples** + + - All BESS values: `BMS/v1/PUB/Value/BESS/#` + parameters: + pointType: + enum: + - Status + - Available + - GenericPoint + description: BMS-published BESS point type. + tagPath: + description: Vendor-defined hierarchical tag path. + messages: + valueMessage: + $ref: '#/components/messages/ValueMessage' + + bessMetadata: + address: 'BMS/v1/PUB/Metadata/BESS/{pointType}/{tagPath}' + description: | + BMS-published metadata for BESS points. + + **MQTT wildcard examples** + + - All BESS metadata: `BMS/v1/PUB/Metadata/BESS/#` + parameters: + pointType: + enum: + - Status + - Available + - GenericPoint + description: BESS point type. + tagPath: + description: Vendor-defined hierarchical tag path. + messages: + status: + $ref: '#/components/messages/BESSStatusMsg' + genericPoint: + $ref: '#/components/messages/GenericEquipmentPointMsg' + available: + $ref: '#/components/messages/BESSAvailableMsg' + + # --------------------------------------------------------------------------- + # UPS + # --------------------------------------------------------------------------- + + upsValue: + address: 'BMS/v1/PUB/Value/UPS/{pointType}/{tagPath}' + description: | + Real-time values published by the BMS for UPS points. + Extensible: additional vendor-specific pointTypes may be present. + + **MQTT wildcard examples** + + - All UPS values: `BMS/v1/PUB/Value/UPS/#` + parameters: + pointType: + enum: + - Status + - Available + - GenericPoint + description: BMS-published UPS point type. + tagPath: + description: Vendor-defined hierarchical tag path. + messages: + valueMessage: + $ref: '#/components/messages/ValueMessage' + + upsMetadata: + address: 'BMS/v1/PUB/Metadata/UPS/{pointType}/{tagPath}' + description: | + BMS-published metadata for UPS points. + + **MQTT wildcard examples** + + - All UPS metadata: `BMS/v1/PUB/Metadata/UPS/#` + parameters: + pointType: + enum: + - Status + - Available + - GenericPoint + description: UPS point type. + tagPath: + description: Vendor-defined hierarchical tag path. + messages: + status: + $ref: '#/components/messages/UPSStatusMsg' + genericPoint: + $ref: '#/components/messages/GenericEquipmentPointMsg' + available: + $ref: '#/components/messages/UPSAvailableMsg' + + # --------------------------------------------------------------------------- + # ATS + # --------------------------------------------------------------------------- + + atsValue: + address: 'BMS/v1/PUB/Value/ATS/{pointType}/{tagPath}' + description: | + Real-time values published by the BMS for ATS points. + Extensible: additional vendor-specific pointTypes may be present. + + **MQTT wildcard examples** + + - All ATS values: `BMS/v1/PUB/Value/ATS/#` + parameters: + pointType: + enum: + - Status + - Available + - GenericPoint + description: BMS-published ATS point type. + tagPath: + description: Vendor-defined hierarchical tag path. + messages: + valueMessage: + $ref: '#/components/messages/ValueMessage' + + atsMetadata: + address: 'BMS/v1/PUB/Metadata/ATS/{pointType}/{tagPath}' + description: | + BMS-published metadata for ATS points. + + **MQTT wildcard examples** + + - All ATS metadata: `BMS/v1/PUB/Metadata/ATS/#` + parameters: + pointType: + enum: + - Status + - Available + - GenericPoint + description: ATS point type. + tagPath: + description: Vendor-defined hierarchical tag path. + messages: + status: + $ref: '#/components/messages/ATSStatusMsg' + genericPoint: + $ref: '#/components/messages/GenericEquipmentPointMsg' + available: + $ref: '#/components/messages/ATSAvailableMsg' + + # --------------------------------------------------------------------------- + # Generator + # --------------------------------------------------------------------------- + + generatorValue: + address: 'BMS/v1/PUB/Value/Generator/{pointType}/{tagPath}' + description: | + Real-time values published by the BMS for Generator points. + Extensible: additional vendor-specific pointTypes may be present. + + **MQTT wildcard examples** + + - All Generator values: `BMS/v1/PUB/Value/Generator/#` + parameters: + pointType: + enum: + - Status + - Available + - GenericPoint + description: BMS-published Generator point type. + tagPath: + description: Vendor-defined hierarchical tag path. + messages: + valueMessage: + $ref: '#/components/messages/ValueMessage' + + generatorMetadata: + address: 'BMS/v1/PUB/Metadata/Generator/{pointType}/{tagPath}' + description: | + BMS-published metadata for Generator points. + + **MQTT wildcard examples** + + - All Generator metadata: `BMS/v1/PUB/Metadata/Generator/#` + parameters: + pointType: + enum: + - Status + - Available + - GenericPoint + description: Generator point type. + tagPath: + description: Vendor-defined hierarchical tag path. + messages: + status: + $ref: '#/components/messages/GeneratorStatusMsg' + genericPoint: + $ref: '#/components/messages/GenericEquipmentPointMsg' + available: + $ref: '#/components/messages/GeneratorAvailableMsg' + + # --------------------------------------------------------------------------- + # Shunt + # --------------------------------------------------------------------------- + + shuntValue: + address: 'BMS/v1/PUB/Value/Shunt/{pointType}/{tagPath}' + description: | + Real-time values published by the BMS for Shunt points. + + **MQTT wildcard examples** + + - All Shunt values: `BMS/v1/PUB/Value/Shunt/#` + parameters: + pointType: + enum: + - Status + - Available + - GenericPoint + description: BMS-published Shunt point type. + tagPath: + description: Vendor-defined hierarchical tag path. + messages: + valueMessage: + $ref: '#/components/messages/ValueMessage' + + shuntMetadata: + address: 'BMS/v1/PUB/Metadata/Shunt/{pointType}/{tagPath}' + description: | + BMS-published metadata for Shunt points. + + **MQTT wildcard examples** + + - All Shunt metadata: `BMS/v1/PUB/Metadata/Shunt/#` + parameters: + pointType: + enum: + - Status + - Available + - GenericPoint + description: Shunt point type. + tagPath: + description: Vendor-defined hierarchical tag path. + messages: + status: + $ref: '#/components/messages/ShuntStatusMsg' + genericPoint: + $ref: '#/components/messages/GenericEquipmentPointMsg' + available: + $ref: '#/components/messages/ShuntAvailableMsg' + + # --------------------------------------------------------------------------- + # Breaker + # --------------------------------------------------------------------------- + + breakerValue: + address: 'BMS/v1/PUB/Value/Breaker/{pointType}/{tagPath}' + description: | + Real-time values published by the BMS for Breaker points. + + **MQTT wildcard examples** + + - All Breaker values: `BMS/v1/PUB/Value/Breaker/#` + parameters: + pointType: + enum: + - Status + - Available + - GenericPoint + description: BMS-published Breaker point type. + tagPath: + description: Vendor-defined hierarchical tag path. + messages: + valueMessage: + $ref: '#/components/messages/ValueMessage' + + breakerMetadata: + address: 'BMS/v1/PUB/Metadata/Breaker/{pointType}/{tagPath}' + description: | + BMS-published metadata for Breaker points. + + **MQTT wildcard examples** + + - All Breaker metadata: `BMS/v1/PUB/Metadata/Breaker/#` + parameters: + pointType: + enum: + - Status + - Available + - GenericPoint + description: Breaker point type. + tagPath: + description: Vendor-defined hierarchical tag path. + messages: + status: + $ref: '#/components/messages/BreakerStatusMsg' + genericPoint: + $ref: '#/components/messages/GenericEquipmentPointMsg' + available: + $ref: '#/components/messages/BreakerAvailableMsg' + + # --------------------------------------------------------------------------- + # CDU + # --------------------------------------------------------------------------- + + cduValue: + address: 'BMS/v1/PUB/Value/CDU/{pointType}/{tagPath}' + description: | + Real-time values published by the BMS for CDU points. + + **MQTT wildcard examples** + + - All CDU values: `BMS/v1/PUB/Value/CDU/#` + parameters: + pointType: + enum: + - LiquidTemperature + - LiquidDifferentialPressure + - LiquidFlow + - LiquidPressure + - Status + - Available + - ValvePosition + - PumpSpeed + - FanSpeed + - DamperPosition + - AirTemperature + - AirDifferentialPressure + - AirRelativeHumidity + - AirFlow + - AirPressure + - LeakDetect + - GenericPoint + description: BMS-published CDU point type. + tagPath: + description: Vendor-defined hierarchical tag path. + messages: + valueMessage: + $ref: '#/components/messages/ValueMessage' + + cduMetadata: + address: 'BMS/v1/PUB/Metadata/CDU/{pointType}/{tagPath}' + description: | + BMS-published metadata for CDU points. + Includes integration-owned points which carries the `integration` field. + + **MQTT wildcard examples** + + - All CDU metadata: `BMS/v1/PUB/Metadata/CDU/#` + parameters: + pointType: + enum: + - LiquidTemperature + - LiquidDifferentialPressure + - LiquidFlow + - LiquidPressure + - Status + - Available + - ValvePosition + - PumpSpeed + - FanSpeed + - DamperPosition + - AirTemperature + - AirDifferentialPressure + - AirRelativeHumidity + - AirFlow + - AirPressure + - LeakDetect + - LiquidTemperatureSpRequest + - GenericPoint + description: CDU point type. + tagPath: + description: Vendor-defined hierarchical tag path. + messages: + liquidTemperature: + $ref: '#/components/messages/CDULiquidTemperatureMsg' + liquidDifferentialPressure: + $ref: '#/components/messages/CDULiquidDifferentialPressureMsg' + liquidFlow: + $ref: '#/components/messages/CDULiquidFlowMsg' + liquidPressure: + $ref: '#/components/messages/CDULiquidPressureMsg' + status: + $ref: '#/components/messages/CDUStatusMsg' + available: + $ref: '#/components/messages/CDUAvailableMsg' + valvePosition: + $ref: '#/components/messages/CDUValvePositionMsg' + pumpSpeed: + $ref: '#/components/messages/CDUPumpSpeedMsg' + fanSpeed: + $ref: '#/components/messages/CDUFanSpeedMsg' + damperPosition: + $ref: '#/components/messages/CDUDamperPositionMsg' + airTemperature: + $ref: '#/components/messages/CDUAirTemperatureMsg' + airDifferentialPressure: + $ref: '#/components/messages/CDUAirDifferentialPressureMsg' + airRelativeHumidity: + $ref: '#/components/messages/CDUAirRelativeHumidityMsg' + airFlow: + $ref: '#/components/messages/CDUAirFlowMsg' + airPressure: + $ref: '#/components/messages/CDUAirPressureMsg' + leakDetect: + $ref: '#/components/messages/CDULeakDetectMsg' + liquidTemperatureSpRequest: + $ref: '#/components/messages/LiquidTemperatureSpRequestMsg' + genericPoint: + $ref: '#/components/messages/GenericEquipmentPointMsg' + + cduIntegrationValue: + address: 'BMS/v1/{integration}/Value/CDU/{pointType}/{tagPath}' + description: | + Values published by integrations for CDU control points. + Subscribe to `cduMetadata` first and read `integration` for the exact topic. + + **MQTT wildcard examples** + + - All integration CDU values: `BMS/v1/+/Value/CDU/#` + parameters: + integration: + description: Integration identifier. + pointType: + enum: + - LiquidTemperatureSpRequest + description: Integration-published CDU point type. + tagPath: + description: Must match the tagPath from the corresponding BMS metadata exactly. + messages: + valueMessage: + $ref: '#/components/messages/ValueMessage' + + # --------------------------------------------------------------------------- + # CoolingTower + # --------------------------------------------------------------------------- + + coolingTowerValue: + address: 'BMS/v1/PUB/Value/CoolingTower/{pointType}/{tagPath}' + description: | + Real-time values published by the BMS for CoolingTower points. + + **MQTT wildcard examples** + + - All CoolingTower values: `BMS/v1/PUB/Value/CoolingTower/#` + parameters: + pointType: + enum: + - LiquidTemperature + - LiquidDifferentialPressure + - LiquidFlow + - LiquidPressure + - Status + - Available + - ValvePosition + - PumpSpeed + - FanSpeed + - DamperPosition + - AirTemperature + - AirDifferentialPressure + - AirRelativeHumidity + - AirFlow + - AirPressure + - LeakDetect + - GenericPoint + description: BMS-published CoolingTower point type. + tagPath: + description: Vendor-defined hierarchical tag path. + messages: + valueMessage: + $ref: '#/components/messages/ValueMessage' + + coolingTowerMetadata: + address: 'BMS/v1/PUB/Metadata/CoolingTower/{pointType}/{tagPath}' + description: | + BMS-published metadata for CoolingTower points. + + **MQTT wildcard examples** + + - All CoolingTower metadata: `BMS/v1/PUB/Metadata/CoolingTower/#` + parameters: + pointType: + enum: + - LiquidTemperature + - LiquidDifferentialPressure + - LiquidFlow + - LiquidPressure + - Status + - Available + - ValvePosition + - PumpSpeed + - FanSpeed + - DamperPosition + - AirTemperature + - AirDifferentialPressure + - AirRelativeHumidity + - AirFlow + - AirPressure + - LeakDetect + - GenericPoint + description: CoolingTower point type. + tagPath: + description: Vendor-defined hierarchical tag path. + messages: + liquidTemperature: + $ref: '#/components/messages/CoolingTowerLiquidTemperatureMsg' + liquidDifferentialPressure: + $ref: '#/components/messages/CoolingTowerLiquidDifferentialPressureMsg' + liquidFlow: + $ref: '#/components/messages/CoolingTowerLiquidFlowMsg' + liquidPressure: + $ref: '#/components/messages/CoolingTowerLiquidPressureMsg' + status: + $ref: '#/components/messages/CoolingTowerStatusMsg' + available: + $ref: '#/components/messages/CoolingTowerAvailableMsg' + valvePosition: + $ref: '#/components/messages/CoolingTowerValvePositionMsg' + pumpSpeed: + $ref: '#/components/messages/CoolingTowerPumpSpeedMsg' + fanSpeed: + $ref: '#/components/messages/CoolingTowerFanSpeedMsg' + damperPosition: + $ref: '#/components/messages/CoolingTowerDamperPositionMsg' + airTemperature: + $ref: '#/components/messages/CoolingTowerAirTemperatureMsg' + airDifferentialPressure: + $ref: '#/components/messages/CoolingTowerAirDifferentialPressureMsg' + airRelativeHumidity: + $ref: '#/components/messages/CoolingTowerAirRelativeHumidityMsg' + airFlow: + $ref: '#/components/messages/CoolingTowerAirFlowMsg' + airPressure: + $ref: '#/components/messages/CoolingTowerAirPressureMsg' + leakDetect: + $ref: '#/components/messages/CoolingTowerLeakDetectMsg' + genericPoint: + $ref: '#/components/messages/GenericEquipmentPointMsg' + + # --------------------------------------------------------------------------- + # HX + # --------------------------------------------------------------------------- + + hxValue: + address: 'BMS/v1/PUB/Value/HX/{pointType}/{tagPath}' + description: | + Real-time values published by the BMS for HX points. + + **MQTT wildcard examples** + + - All HX values: `BMS/v1/PUB/Value/HX/#` + parameters: + pointType: + enum: + - LiquidTemperature + - LiquidDifferentialPressure + - LiquidFlow + - LiquidPressure + - Status + - Available + - ValvePosition + - PumpSpeed + - FanSpeed + - DamperPosition + - AirTemperature + - AirDifferentialPressure + - AirRelativeHumidity + - AirFlow + - AirPressure + - LeakDetect + - GenericPoint + description: BMS-published HX point type. + tagPath: + description: Vendor-defined hierarchical tag path. + messages: + valueMessage: + $ref: '#/components/messages/ValueMessage' + + hxMetadata: + address: 'BMS/v1/PUB/Metadata/HX/{pointType}/{tagPath}' + description: | + BMS-published metadata for HX points. + + **MQTT wildcard examples** + + - All HX metadata: `BMS/v1/PUB/Metadata/HX/#` + parameters: + pointType: + enum: + - LiquidTemperature + - LiquidDifferentialPressure + - LiquidFlow + - LiquidPressure + - Status + - Available + - ValvePosition + - PumpSpeed + - FanSpeed + - DamperPosition + - AirTemperature + - AirDifferentialPressure + - AirRelativeHumidity + - AirFlow + - AirPressure + - LeakDetect + - GenericPoint + description: HX point type. + tagPath: + description: Vendor-defined hierarchical tag path. + messages: + liquidTemperature: + $ref: '#/components/messages/HXLiquidTemperatureMsg' + liquidDifferentialPressure: + $ref: '#/components/messages/HXLiquidDifferentialPressureMsg' + liquidFlow: + $ref: '#/components/messages/HXLiquidFlowMsg' + liquidPressure: + $ref: '#/components/messages/HXLiquidPressureMsg' + status: + $ref: '#/components/messages/HXStatusMsg' + available: + $ref: '#/components/messages/HXAvailableMsg' + valvePosition: + $ref: '#/components/messages/HXValvePositionMsg' + pumpSpeed: + $ref: '#/components/messages/HXPumpSpeedMsg' + fanSpeed: + $ref: '#/components/messages/HXFanSpeedMsg' + damperPosition: + $ref: '#/components/messages/HXDamperPositionMsg' + airTemperature: + $ref: '#/components/messages/HXAirTemperatureMsg' + airDifferentialPressure: + $ref: '#/components/messages/HXAirDifferentialPressureMsg' + airRelativeHumidity: + $ref: '#/components/messages/HXAirRelativeHumidityMsg' + airFlow: + $ref: '#/components/messages/HXAirFlowMsg' + airPressure: + $ref: '#/components/messages/HXAirPressureMsg' + leakDetect: + $ref: '#/components/messages/HXLeakDetectMsg' + genericPoint: + $ref: '#/components/messages/GenericEquipmentPointMsg' + + # --------------------------------------------------------------------------- + # CRAH + # --------------------------------------------------------------------------- + + crahValue: + address: 'BMS/v1/PUB/Value/CRAH/{pointType}/{tagPath}' + description: | + Real-time values published by the BMS for CRAH points. + + **MQTT wildcard examples** + + - All CRAH values: `BMS/v1/PUB/Value/CRAH/#` + parameters: + pointType: + enum: + - LiquidTemperature + - LiquidDifferentialPressure + - LiquidFlow + - LiquidPressure + - Status + - Available + - ValvePosition + - PumpSpeed + - FanSpeed + - DamperPosition + - AirTemperature + - AirDifferentialPressure + - AirRelativeHumidity + - AirFlow + - AirPressure + - LeakDetect + - GenericPoint + description: BMS-published CRAH point type. + tagPath: + description: Vendor-defined hierarchical tag path. + messages: + valueMessage: + $ref: '#/components/messages/ValueMessage' + + crahMetadata: + address: 'BMS/v1/PUB/Metadata/CRAH/{pointType}/{tagPath}' + description: | + BMS-published metadata for CRAH points. + + **MQTT wildcard examples** + + - All CRAH metadata: `BMS/v1/PUB/Metadata/CRAH/#` + parameters: + pointType: + enum: + - LiquidTemperature + - LiquidDifferentialPressure + - LiquidFlow + - LiquidPressure + - Status + - Available + - ValvePosition + - PumpSpeed + - FanSpeed + - DamperPosition + - AirTemperature + - AirDifferentialPressure + - AirRelativeHumidity + - AirFlow + - AirPressure + - LeakDetect + - GenericPoint + description: CRAH point type. + tagPath: + description: Vendor-defined hierarchical tag path. + messages: + liquidTemperature: + $ref: '#/components/messages/CRAHLiquidTemperatureMsg' + liquidDifferentialPressure: + $ref: '#/components/messages/CRAHLiquidDifferentialPressureMsg' + liquidFlow: + $ref: '#/components/messages/CRAHLiquidFlowMsg' + liquidPressure: + $ref: '#/components/messages/CRAHLiquidPressureMsg' + status: + $ref: '#/components/messages/CRAHStatusMsg' + available: + $ref: '#/components/messages/CRAHAvailableMsg' + valvePosition: + $ref: '#/components/messages/CRAHValvePositionMsg' + pumpSpeed: + $ref: '#/components/messages/CRAHPumpSpeedMsg' + fanSpeed: + $ref: '#/components/messages/CRAHFanSpeedMsg' + damperPosition: + $ref: '#/components/messages/CRAHDamperPositionMsg' + airTemperature: + $ref: '#/components/messages/CRAHAirTemperatureMsg' + airDifferentialPressure: + $ref: '#/components/messages/CRAHAirDifferentialPressureMsg' + airRelativeHumidity: + $ref: '#/components/messages/CRAHAirRelativeHumidityMsg' + airFlow: + $ref: '#/components/messages/CRAHAirFlowMsg' + airPressure: + $ref: '#/components/messages/CRAHAirPressureMsg' + leakDetect: + $ref: '#/components/messages/CRAHLeakDetectMsg' + genericPoint: + $ref: '#/components/messages/GenericEquipmentPointMsg' + + # --------------------------------------------------------------------------- + # CRAC + # --------------------------------------------------------------------------- + + cracValue: + address: 'BMS/v1/PUB/Value/CRAC/{pointType}/{tagPath}' + description: | + Real-time values published by the BMS for CRAC points. + + **MQTT wildcard examples** + + - All CRAC values: `BMS/v1/PUB/Value/CRAC/#` + parameters: + pointType: + enum: + - LiquidTemperature + - LiquidDifferentialPressure + - LiquidFlow + - LiquidPressure + - Status + - Available + - ValvePosition + - PumpSpeed + - FanSpeed + - DamperPosition + - AirTemperature + - AirDifferentialPressure + - AirRelativeHumidity + - AirFlow + - AirPressure + - LeakDetect + - GenericPoint + description: BMS-published CRAC point type. + tagPath: + description: Vendor-defined hierarchical tag path. + messages: + valueMessage: + $ref: '#/components/messages/ValueMessage' + + cracMetadata: + address: 'BMS/v1/PUB/Metadata/CRAC/{pointType}/{tagPath}' + description: | + BMS-published metadata for CRAC points. + + **MQTT wildcard examples** + + - All CRAC metadata: `BMS/v1/PUB/Metadata/CRAC/#` + parameters: + pointType: + enum: + - LiquidTemperature + - LiquidDifferentialPressure + - LiquidFlow + - LiquidPressure + - Status + - Available + - ValvePosition + - PumpSpeed + - FanSpeed + - DamperPosition + - AirTemperature + - AirDifferentialPressure + - AirRelativeHumidity + - AirFlow + - AirPressure + - LeakDetect + - GenericPoint + description: CRAC point type. + tagPath: + description: Vendor-defined hierarchical tag path. + messages: + liquidTemperature: + $ref: '#/components/messages/CRACLiquidTemperatureMsg' + liquidDifferentialPressure: + $ref: '#/components/messages/CRACLiquidDifferentialPressureMsg' + liquidFlow: + $ref: '#/components/messages/CRACLiquidFlowMsg' + liquidPressure: + $ref: '#/components/messages/CRACLiquidPressureMsg' + status: + $ref: '#/components/messages/CRACStatusMsg' + available: + $ref: '#/components/messages/CRACAvailableMsg' + valvePosition: + $ref: '#/components/messages/CRACValvePositionMsg' + pumpSpeed: + $ref: '#/components/messages/CRACPumpSpeedMsg' + fanSpeed: + $ref: '#/components/messages/CRACFanSpeedMsg' + damperPosition: + $ref: '#/components/messages/CRACDamperPositionMsg' + airTemperature: + $ref: '#/components/messages/CRACAirTemperatureMsg' + airDifferentialPressure: + $ref: '#/components/messages/CRACAirDifferentialPressureMsg' + airRelativeHumidity: + $ref: '#/components/messages/CRACAirRelativeHumidityMsg' + airFlow: + $ref: '#/components/messages/CRACAirFlowMsg' + airPressure: + $ref: '#/components/messages/CRACAirPressureMsg' + leakDetect: + $ref: '#/components/messages/CRACLeakDetectMsg' + genericPoint: + $ref: '#/components/messages/GenericEquipmentPointMsg' + + # --------------------------------------------------------------------------- + # AHU + # --------------------------------------------------------------------------- + + ahuValue: + address: 'BMS/v1/PUB/Value/AHU/{pointType}/{tagPath}' + description: | + Real-time values published by the BMS for AHU points. + + **MQTT wildcard examples** + + - All AHU values: `BMS/v1/PUB/Value/AHU/#` + parameters: + pointType: + enum: + - LiquidTemperature + - LiquidDifferentialPressure + - LiquidFlow + - LiquidPressure + - Status + - Available + - ValvePosition + - PumpSpeed + - FanSpeed + - DamperPosition + - AirTemperature + - AirDifferentialPressure + - AirRelativeHumidity + - AirFlow + - AirPressure + - LeakDetect + - GenericPoint + description: BMS-published AHU point type. + tagPath: + description: Vendor-defined hierarchical tag path. + messages: + valueMessage: + $ref: '#/components/messages/ValueMessage' + + ahuMetadata: + address: 'BMS/v1/PUB/Metadata/AHU/{pointType}/{tagPath}' + description: | + BMS-published metadata for AHU points. + + **MQTT wildcard examples** + + - All AHU metadata: `BMS/v1/PUB/Metadata/AHU/#` + parameters: + pointType: + enum: + - LiquidTemperature + - LiquidDifferentialPressure + - LiquidFlow + - LiquidPressure + - Status + - Available + - ValvePosition + - PumpSpeed + - FanSpeed + - DamperPosition + - AirTemperature + - AirDifferentialPressure + - AirRelativeHumidity + - AirFlow + - AirPressure + - LeakDetect + - GenericPoint + description: AHU point type. + tagPath: + description: Vendor-defined hierarchical tag path. + messages: + liquidTemperature: + $ref: '#/components/messages/AHULiquidTemperatureMsg' + liquidDifferentialPressure: + $ref: '#/components/messages/AHULiquidDifferentialPressureMsg' + liquidFlow: + $ref: '#/components/messages/AHULiquidFlowMsg' + liquidPressure: + $ref: '#/components/messages/AHULiquidPressureMsg' + status: + $ref: '#/components/messages/AHUStatusMsg' + available: + $ref: '#/components/messages/AHUAvailableMsg' + valvePosition: + $ref: '#/components/messages/AHUValvePositionMsg' + pumpSpeed: + $ref: '#/components/messages/AHUPumpSpeedMsg' + fanSpeed: + $ref: '#/components/messages/AHUFanSpeedMsg' + damperPosition: + $ref: '#/components/messages/AHUDamperPositionMsg' + airTemperature: + $ref: '#/components/messages/AHUAirTemperatureMsg' + airDifferentialPressure: + $ref: '#/components/messages/AHUAirDifferentialPressureMsg' + airRelativeHumidity: + $ref: '#/components/messages/AHUAirRelativeHumidityMsg' + airFlow: + $ref: '#/components/messages/AHUAirFlowMsg' + airPressure: + $ref: '#/components/messages/AHUAirPressureMsg' + leakDetect: + $ref: '#/components/messages/AHULeakDetectMsg' + genericPoint: + $ref: '#/components/messages/GenericEquipmentPointMsg' + + # --------------------------------------------------------------------------- + # Chiller + # --------------------------------------------------------------------------- + + chillerValue: + address: 'BMS/v1/PUB/Value/Chiller/{pointType}/{tagPath}' + description: | + Real-time values published by the BMS for Chiller points. + + **MQTT wildcard examples** + + - All Chiller values: `BMS/v1/PUB/Value/Chiller/#` + parameters: + pointType: + enum: + - LiquidTemperature + - LiquidDifferentialPressure + - LiquidFlow + - LiquidPressure + - Status + - Available + - ValvePosition + - PumpSpeed + - FanSpeed + - DamperPosition + - AirTemperature + - AirDifferentialPressure + - AirRelativeHumidity + - AirFlow + - AirPressure + - LeakDetect + - GenericPoint + description: BMS-published Chiller point type. + tagPath: + description: Vendor-defined hierarchical tag path. + messages: + valueMessage: + $ref: '#/components/messages/ValueMessage' + + chillerMetadata: + address: 'BMS/v1/PUB/Metadata/Chiller/{pointType}/{tagPath}' + description: | + BMS-published metadata for Chiller points. + + **MQTT wildcard examples** + + - All Chiller metadata: `BMS/v1/PUB/Metadata/Chiller/#` + parameters: + pointType: + enum: + - LiquidTemperature + - LiquidDifferentialPressure + - LiquidFlow + - LiquidPressure + - Status + - Available + - ValvePosition + - PumpSpeed + - FanSpeed + - DamperPosition + - AirTemperature + - AirDifferentialPressure + - AirRelativeHumidity + - AirFlow + - AirPressure + - LeakDetect + - GenericPoint + description: Chiller point type. + tagPath: + description: Vendor-defined hierarchical tag path. + messages: + liquidTemperature: + $ref: '#/components/messages/ChillerLiquidTemperatureMsg' + liquidDifferentialPressure: + $ref: '#/components/messages/ChillerLiquidDifferentialPressureMsg' + liquidFlow: + $ref: '#/components/messages/ChillerLiquidFlowMsg' + liquidPressure: + $ref: '#/components/messages/ChillerLiquidPressureMsg' + status: + $ref: '#/components/messages/ChillerStatusMsg' + available: + $ref: '#/components/messages/ChillerAvailableMsg' + valvePosition: + $ref: '#/components/messages/ChillerValvePositionMsg' + pumpSpeed: + $ref: '#/components/messages/ChillerPumpSpeedMsg' + fanSpeed: + $ref: '#/components/messages/ChillerFanSpeedMsg' + damperPosition: + $ref: '#/components/messages/ChillerDamperPositionMsg' + airTemperature: + $ref: '#/components/messages/ChillerAirTemperatureMsg' + airDifferentialPressure: + $ref: '#/components/messages/ChillerAirDifferentialPressureMsg' + airRelativeHumidity: + $ref: '#/components/messages/ChillerAirRelativeHumidityMsg' + airFlow: + $ref: '#/components/messages/ChillerAirFlowMsg' + airPressure: + $ref: '#/components/messages/ChillerAirPressureMsg' + leakDetect: + $ref: '#/components/messages/ChillerLeakDetectMsg' + genericPoint: + $ref: '#/components/messages/GenericEquipmentPointMsg' + + # --------------------------------------------------------------------------- + # Valve + # --------------------------------------------------------------------------- + + valveValue: + address: 'BMS/v1/PUB/Value/Valve/{pointType}/{tagPath}' + description: | + Real-time values published by the BMS for Valve points. + + **MQTT wildcard examples** + + - All Valve values: `BMS/v1/PUB/Value/Valve/#` + parameters: + pointType: + enum: + - ValvePosition + - Available + - GenericPoint + description: BMS-published Valve point type. + tagPath: + description: Vendor-defined hierarchical tag path. + messages: + valueMessage: + $ref: '#/components/messages/ValueMessage' + + valveMetadata: + address: 'BMS/v1/PUB/Metadata/Valve/{pointType}/{tagPath}' + description: | + BMS-published metadata for Valve points. + + **MQTT wildcard examples** + + - All Valve metadata: `BMS/v1/PUB/Metadata/Valve/#` + parameters: + pointType: + enum: + - ValvePosition + - Available + - GenericPoint + description: Valve point type. + tagPath: + description: Vendor-defined hierarchical tag path. + messages: + valvePosition: + $ref: '#/components/messages/ValveValvePositionMsg' + available: + $ref: '#/components/messages/ValveAvailableMsg' + genericPoint: + $ref: '#/components/messages/GenericEquipmentPointMsg' + + # --------------------------------------------------------------------------- + # Pump + # --------------------------------------------------------------------------- + + pumpValue: + address: 'BMS/v1/PUB/Value/Pump/{pointType}/{tagPath}' + description: | + Real-time values published by the BMS for Pump points. + + **MQTT wildcard examples** + + - All Pump values: `BMS/v1/PUB/Value/Pump/#` + parameters: + pointType: + enum: + - PumpSpeed + - Available + - GenericPoint + description: BMS-published Pump point type. + tagPath: + description: Vendor-defined hierarchical tag path. + messages: + valueMessage: + $ref: '#/components/messages/ValueMessage' + + pumpMetadata: + address: 'BMS/v1/PUB/Metadata/Pump/{pointType}/{tagPath}' + description: | + BMS-published metadata for Pump points. + + **MQTT wildcard examples** + + - All Pump metadata: `BMS/v1/PUB/Metadata/Pump/#` + parameters: + pointType: + enum: + - PumpSpeed + - Available + - GenericPoint + description: Pump point type. + tagPath: + description: Vendor-defined hierarchical tag path. + messages: + pumpSpeed: + $ref: '#/components/messages/PumpPumpSpeedMsg' + available: + $ref: '#/components/messages/PumpAvailableMsg' + genericPoint: + $ref: '#/components/messages/GenericEquipmentPointMsg' + + # --------------------------------------------------------------------------- + # Fan + # --------------------------------------------------------------------------- + + fanValue: + address: 'BMS/v1/PUB/Value/Fan/{pointType}/{tagPath}' + description: | + Real-time values published by the BMS for Fan points. + + **MQTT wildcard examples** + + - All Fan values: `BMS/v1/PUB/Value/Fan/#` + parameters: + pointType: + enum: + - FanSpeed + - Available + - GenericPoint + description: BMS-published Fan point type. + tagPath: + description: Vendor-defined hierarchical tag path. + messages: + valueMessage: + $ref: '#/components/messages/ValueMessage' + + fanMetadata: + address: 'BMS/v1/PUB/Metadata/Fan/{pointType}/{tagPath}' + description: | + BMS-published metadata for Fan points. + + **MQTT wildcard examples** + + - All Fan metadata: `BMS/v1/PUB/Metadata/Fan/#` + parameters: + pointType: + enum: + - FanSpeed + - Available + - GenericPoint + description: Fan point type. + tagPath: + description: Vendor-defined hierarchical tag path. + messages: + fanSpeed: + $ref: '#/components/messages/FanFanSpeedMsg' + available: + $ref: '#/components/messages/FanAvailableMsg' + genericPoint: + $ref: '#/components/messages/GenericEquipmentPointMsg' + + # --------------------------------------------------------------------------- + # Damper + # --------------------------------------------------------------------------- + + damperValue: + address: 'BMS/v1/PUB/Value/Damper/{pointType}/{tagPath}' + description: | + Real-time values published by the BMS for Damper points. + + **MQTT wildcard examples** + + - All Damper values: `BMS/v1/PUB/Value/Damper/#` + parameters: + pointType: + enum: + - DamperPosition + - Available + - GenericPoint + description: BMS-published Damper point type. + tagPath: + description: Vendor-defined hierarchical tag path. + messages: + valueMessage: + $ref: '#/components/messages/ValueMessage' + + damperMetadata: + address: 'BMS/v1/PUB/Metadata/Damper/{pointType}/{tagPath}' + description: | + BMS-published metadata for Damper points. + + **MQTT wildcard examples** + + - All Damper metadata: `BMS/v1/PUB/Metadata/Damper/#` + parameters: + pointType: + enum: + - DamperPosition + - Available + - GenericPoint + description: Damper point type. + tagPath: + description: Vendor-defined hierarchical tag path. + messages: + damperPosition: + $ref: '#/components/messages/DamperDamperPositionMsg' + available: + $ref: '#/components/messages/DamperAvailableMsg' + genericPoint: + $ref: '#/components/messages/GenericEquipmentPointMsg' + + # --------------------------------------------------------------------------- + # Sensor + # --------------------------------------------------------------------------- + + sensorValue: + address: 'BMS/v1/PUB/Value/Sensor/{pointType}/{tagPath}' + description: | + Real-time values published by the BMS for Sensor points. + + **MQTT wildcard examples** + + - All Sensor values: `BMS/v1/PUB/Value/Sensor/#` + parameters: + pointType: + enum: + - LiquidTemperature + - LiquidDifferentialPressure + - LiquidFlow + - LiquidPressure + - AirTemperature + - AirDifferentialPressure + - AirRelativeHumidity + - AirFlow + - AirPressure + - LeakDetect + - Sound + - Available + - GenericPoint + description: BMS-published Sensor point type. + tagPath: + description: Vendor-defined hierarchical tag path. + messages: + valueMessage: + $ref: '#/components/messages/ValueMessage' + + sensorMetadata: + address: 'BMS/v1/PUB/Metadata/Sensor/{pointType}/{tagPath}' + description: | + BMS-published metadata for Sensor points. + + **MQTT wildcard examples** + + - All Sensor metadata: `BMS/v1/PUB/Metadata/Sensor/#` + parameters: + pointType: + enum: + - LiquidTemperature + - LiquidDifferentialPressure + - LiquidFlow + - LiquidPressure + - AirTemperature + - AirDifferentialPressure + - AirRelativeHumidity + - AirFlow + - AirPressure + - LeakDetect + - Sound + - Available + - GenericPoint + description: Sensor point type. + tagPath: + description: Vendor-defined hierarchical tag path. + messages: + liquidTemperature: + $ref: '#/components/messages/SensorLiquidTemperatureMsg' + liquidDifferentialPressure: + $ref: '#/components/messages/SensorLiquidDifferentialPressureMsg' + liquidFlow: + $ref: '#/components/messages/SensorLiquidFlowMsg' + liquidPressure: + $ref: '#/components/messages/SensorLiquidPressureMsg' + airTemperature: + $ref: '#/components/messages/SensorAirTemperatureMsg' + airDifferentialPressure: + $ref: '#/components/messages/SensorAirDifferentialPressureMsg' + airRelativeHumidity: + $ref: '#/components/messages/SensorAirRelativeHumidityMsg' + airFlow: + $ref: '#/components/messages/SensorAirFlowMsg' + airPressure: + $ref: '#/components/messages/SensorAirPressureMsg' + leakDetect: + $ref: '#/components/messages/SensorLeakDetectMsg' + available: + $ref: '#/components/messages/SensorAvailableMsg' + genericPoint: + $ref: '#/components/messages/GenericEquipmentPointMsg' + sound: + $ref: '#/components/messages/SensorSoundMsg' + + + # --------------------------------------------------------------------------- + # Tank + # --------------------------------------------------------------------------- + + tankValue: + address: 'BMS/v1/PUB/Value/Tank/{pointType}/{tagPath}' + description: | + Real-time values published by the BMS for Tank points. + Supports both liquid and air tanks. + + **MQTT wildcard examples** + + - All Tank values: `BMS/v1/PUB/Value/Tank/#` + parameters: + pointType: + enum: + - LiquidTemperature + - LiquidDifferentialPressure + - LiquidFlow + - LiquidPressure + - Status + - Available + - ValvePosition + - PumpSpeed + - FanSpeed + - DamperPosition + - AirTemperature + - AirDifferentialPressure + - AirRelativeHumidity + - AirFlow + - AirPressure + - LeakDetect + - GenericPoint + description: BMS-published Tank point type. + tagPath: + description: Vendor-defined hierarchical tag path. + messages: + valueMessage: + $ref: '#/components/messages/ValueMessage' + + tankMetadata: + address: 'BMS/v1/PUB/Metadata/Tank/{pointType}/{tagPath}' + description: | + BMS-published metadata for Tank points. + Includes integration-owned points which carries the `integration` field. + + **MQTT wildcard examples** + + - All Tank metadata: `BMS/v1/PUB/Metadata/Tank/#` + parameters: + pointType: + enum: + - LiquidTemperature + - LiquidDifferentialPressure + - LiquidFlow + - LiquidPressure + - Status + - Available + - ValvePosition + - PumpSpeed + - FanSpeed + - DamperPosition + - AirTemperature + - AirDifferentialPressure + - AirRelativeHumidity + - AirFlow + - AirPressure + - LeakDetect + - GenericPoint + description: Tank point type. + tagPath: + description: Vendor-defined hierarchical tag path. + messages: + liquidTemperature: + $ref: '#/components/messages/TankLiquidTemperatureMsg' + liquidDifferentialPressure: + $ref: '#/components/messages/TankLiquidDifferentialPressureMsg' + liquidFlow: + $ref: '#/components/messages/TankLiquidFlowMsg' + liquidPressure: + $ref: '#/components/messages/TankLiquidPressureMsg' + status: + $ref: '#/components/messages/TankStatusMsg' + available: + $ref: '#/components/messages/TankAvailableMsg' + valvePosition: + $ref: '#/components/messages/TankValvePositionMsg' + pumpSpeed: + $ref: '#/components/messages/TankPumpSpeedMsg' + fanSpeed: + $ref: '#/components/messages/TankFanSpeedMsg' + damperPosition: + $ref: '#/components/messages/TankDamperPositionMsg' + airTemperature: + $ref: '#/components/messages/TankAirTemperatureMsg' + airDifferentialPressure: + $ref: '#/components/messages/TankAirDifferentialPressureMsg' + airRelativeHumidity: + $ref: '#/components/messages/TankAirRelativeHumidityMsg' + airFlow: + $ref: '#/components/messages/TankAirFlowMsg' + airPressure: + $ref: '#/components/messages/TankAirPressureMsg' + leakDetect: + $ref: '#/components/messages/TankLeakDetectMsg' + genericPoint: + $ref: '#/components/messages/GenericEquipmentPointMsg' + + # --------------------------------------------------------------------------- + # GenericObject + # --------------------------------------------------------------------------- + + genericObjectValue: + address: 'BMS/v1/PUB/Value/GenericObject/{pointType}/{tagPath}' + description: | + Real-time values published by the BMS for GenericObject points. + Use for any equipment type not covered by a named objectType. + + **MQTT wildcard examples** + + - All GenericObject values: `BMS/v1/PUB/Value/GenericObject/#` + parameters: + pointType: + enum: + - LiquidTemperature + - LiquidDifferentialPressure + - LiquidFlow + - LiquidPressure + - Status + - Available + - ValvePosition + - PumpSpeed + - FanSpeed + - DamperPosition + - AirTemperature + - AirDifferentialPressure + - AirRelativeHumidity + - AirFlow + - AirPressure + - LeakDetect + - Sound + - GenericPoint + description: BMS-published GenericObject point type. + tagPath: + description: Vendor-defined hierarchical tag path. + messages: + valueMessage: + $ref: '#/components/messages/ValueMessage' + + genericObjectMetadata: + address: 'BMS/v1/PUB/Metadata/GenericObject/{pointType}/{tagPath}' + description: | + BMS-published metadata for GenericObject points. + Includes integration-owned points which carries the `integration` field. + + **MQTT wildcard examples** + + - All GenericObject metadata: `BMS/v1/PUB/Metadata/GenericObject/#` + parameters: + pointType: + enum: + - LiquidTemperature + - LiquidDifferentialPressure + - LiquidFlow + - LiquidPressure + - Status + - Available + - ValvePosition + - PumpSpeed + - FanSpeed + - DamperPosition + - AirTemperature + - AirDifferentialPressure + - AirRelativeHumidity + - AirFlow + - AirPressure + - LeakDetect + - LiquidTemperatureSpRequest + - Sound + - GenericPoint + description: GenericObject point type. + tagPath: + description: Vendor-defined hierarchical tag path. + messages: + liquidTemperature: + $ref: '#/components/messages/GenericObjectLiquidTemperatureMsg' + liquidDifferentialPressure: + $ref: '#/components/messages/GenericObjectLiquidDifferentialPressureMsg' + liquidFlow: + $ref: '#/components/messages/GenericObjectLiquidFlowMsg' + liquidPressure: + $ref: '#/components/messages/GenericObjectLiquidPressureMsg' + status: + $ref: '#/components/messages/GenericObjectStatusMsg' + available: + $ref: '#/components/messages/GenericObjectAvailableMsg' + valvePosition: + $ref: '#/components/messages/GenericObjectValvePositionMsg' + pumpSpeed: + $ref: '#/components/messages/GenericObjectPumpSpeedMsg' + fanSpeed: + $ref: '#/components/messages/GenericObjectFanSpeedMsg' + damperPosition: + $ref: '#/components/messages/GenericObjectDamperPositionMsg' + airTemperature: + $ref: '#/components/messages/GenericObjectAirTemperatureMsg' + airDifferentialPressure: + $ref: '#/components/messages/GenericObjectAirDifferentialPressureMsg' + airRelativeHumidity: + $ref: '#/components/messages/GenericObjectAirRelativeHumidityMsg' + airFlow: + $ref: '#/components/messages/GenericObjectAirFlowMsg' + airPressure: + $ref: '#/components/messages/GenericObjectAirPressureMsg' + leakDetect: + $ref: '#/components/messages/GenericObjectLeakDetectMsg' + liquidTemperatureSpRequest: + $ref: '#/components/messages/GenericObjectLiquidTemperatureSpRequestMsg' + sound: + $ref: '#/components/messages/GenericObjectSoundMsg' + genericPoint: + $ref: '#/components/messages/GenericEquipmentPointMsg' + + genericObjectIntegrationValue: + address: 'BMS/v1/{integration}/Value/GenericObject/{pointType}/{tagPath}' + description: | + Values published by integrations for GenericObject control points. + Subscribe to `genericObjectMetadata` first and read `integration` for the exact topic. + + **MQTT wildcard examples** + + - All integration GenericObject values: `BMS/v1/+/Value/GenericObject/#` + parameters: + integration: + description: Integration identifier. + pointType: + enum: + - LiquidTemperatureSpRequest + description: Integration-published GenericObject point type. + tagPath: + description: Must match the tagPath from the corresponding BMS metadata exactly. + messages: + valueMessage: + $ref: '#/components/messages/ValueMessage' + + # --------------------------------------------------------------------------- + # System + # --------------------------------------------------------------------------- + + systemBmsValue: + address: 'BMS/v1/PUB/Value/System/{pointType}/{tagPath}' + description: | + BMS-published System values. + + **MQTT wildcard examples** + + - All System values: `BMS/v1/PUB/Value/System/#` + parameters: + pointType: + enum: + - HeartbeatTimestampBms + - HeartbeatEchoBms + - Status + - Available + - GenericPoint + description: BMS-published System point type. + tagPath: + description: Vendor-defined hierarchical tag path. + messages: + valueMessage: + $ref: '#/components/messages/ValueMessage' + + systemMetadata: + address: 'BMS/v1/PUB/Metadata/System/{pointType}/{tagPath}' + description: | + BMS-published metadata for all System point types. + + **MQTT wildcard examples** + + - All System metadata: `BMS/v1/PUB/Metadata/System/#` + parameters: + pointType: + enum: + - HeartbeatTimestampBms + - HeartbeatEchoBms + - HeartbeatTimestampIntegration + - HeartbeatEchoIntegration + - Status + - Available + - GenericPoint + description: System point type. + tagPath: + description: Vendor-defined hierarchical tag path. + messages: + heartbeatTimestampBms: + $ref: '#/components/messages/SystemHeartbeatTimestampBmsMsg' + heartbeatEchoBms: + $ref: '#/components/messages/SystemHeartbeatEchoBmsMsg' + heartbeatTimestampIntegration: + $ref: '#/components/messages/SystemHeartbeatTimestampIntegrationMsg' + heartbeatEchoIntegration: + $ref: '#/components/messages/SystemHeartbeatEchoIntegrationMsg' + status: + $ref: '#/components/messages/SystemStatusMsg' + available: + $ref: '#/components/messages/SystemAvailableMsg' + genericPoint: + $ref: '#/components/messages/GenericEquipmentPointMsg' + + systemIntegrationValue: + address: 'BMS/v1/{integration}/Value/System/{pointType}/{tagPath}' + description: | + Values published by integrations for Heartbeat points. + + **MQTT wildcard examples** + + - All integration heartbeat values: `BMS/v1/+/Value/System/#` + parameters: + integration: + description: Integration identifier. + pointType: + enum: + - HeartbeatTimestampIntegration + - HeartbeatEchoIntegration + description: Integration-published System point type. + tagPath: + description: Vendor-defined hierarchical tag path. + messages: + valueMessage: + $ref: '#/components/messages/ValueMessage' + + + +# ============================================================================= +# Operations +# ============================================================================= + +operations: + + receiveRackValue: + action: receive + channel: + $ref: '#/channels/rackBmsValue' + messages: + - $ref: '#/channels/rackBmsValue/messages/valueMessage' + description: Subscribe to real-time BMS-published values for all Rack points. + + receiveRackMetadata: + action: receive + channel: + $ref: '#/channels/rackMetadata' + messages: + - $ref: '#/channels/rackMetadata/messages/rackLiquidSupplyTemperature' + - $ref: '#/channels/rackMetadata/messages/rackLiquidReturnTemperature' + - $ref: '#/channels/rackMetadata/messages/rackLiquidFlow' + - $ref: '#/channels/rackMetadata/messages/rackLiquidDifferentialPressure' + - $ref: '#/channels/rackMetadata/messages/rackLiquidDifferentialPressureSp' + - $ref: '#/channels/rackMetadata/messages/rackControlValvePosition' + - $ref: '#/channels/rackMetadata/messages/rackPower' + - $ref: '#/channels/rackMetadata/messages/rackLeakDetect' + - $ref: '#/channels/rackMetadata/messages/rackLeakSensorFault' + - $ref: '#/channels/rackMetadata/messages/rackLeakDetectTray' + - $ref: '#/channels/rackMetadata/messages/rackLiquidIsolationStatus' + - $ref: '#/channels/rackMetadata/messages/rackElectricalIsolationStatus' + - $ref: '#/channels/rackMetadata/messages/rackLiquidIsolationRequest' + - $ref: '#/channels/rackMetadata/messages/rackElectricalIsolationRequest' + description: Subscribe to BMS-published metadata for all Rack point types. + + publishRackIntegrationValue: + action: send + channel: + $ref: '#/channels/rackIntegrationValue' + messages: + - $ref: '#/channels/rackIntegrationValue/messages/valueMessage' + description: Publish integration values for Rack control points. + + receivePowerMeterValue: + action: receive + channel: + $ref: '#/channels/powerMeterValue' + messages: + - $ref: '#/channels/powerMeterValue/messages/valueMessage' + description: Subscribe to real-time BMS-published values for all PowerMeter points. + + receivePowerMeterMetadata: + action: receive + channel: + $ref: '#/channels/powerMeterMetadata' + messages: + - $ref: '#/channels/powerMeterMetadata/messages/voltage' + - $ref: '#/channels/powerMeterMetadata/messages/powerFactor' + - $ref: '#/channels/powerMeterMetadata/messages/frequency' + - $ref: '#/channels/powerMeterMetadata/messages/apparentPower' + - $ref: '#/channels/powerMeterMetadata/messages/activePower' + - $ref: '#/channels/powerMeterMetadata/messages/current' + - $ref: '#/channels/powerMeterMetadata/messages/currentLimit' + - $ref: '#/channels/powerMeterMetadata/messages/phaseCurrent' + - $ref: '#/channels/powerMeterMetadata/messages/genericPoint' + description: Subscribe to BMS-published metadata for all PowerMeter points. + + receiveBESSValue: + action: receive + channel: + $ref: '#/channels/bessValue' + messages: + - $ref: '#/channels/bessValue/messages/valueMessage' + description: Subscribe to real-time BMS-published values for BESS points. + + receiveBESSMetadata: + action: receive + channel: + $ref: '#/channels/bessMetadata' + messages: + - $ref: '#/channels/bessMetadata/messages/status' + - $ref: '#/channels/bessMetadata/messages/available' + - $ref: '#/channels/bessMetadata/messages/genericPoint' + description: Subscribe to BMS-published metadata for BESS points. + + receiveUPSValue: + action: receive + channel: + $ref: '#/channels/upsValue' + messages: + - $ref: '#/channels/upsValue/messages/valueMessage' + description: Subscribe to real-time BMS-published values for UPS points. + + receiveUPSMetadata: + action: receive + channel: + $ref: '#/channels/upsMetadata' + messages: + - $ref: '#/channels/upsMetadata/messages/status' + - $ref: '#/channels/upsMetadata/messages/available' + - $ref: '#/channels/upsMetadata/messages/genericPoint' + description: Subscribe to BMS-published metadata for UPS points. + + receiveATSValue: + action: receive + channel: + $ref: '#/channels/atsValue' + messages: + - $ref: '#/channels/atsValue/messages/valueMessage' + description: Subscribe to real-time BMS-published values for ATS points. + + receiveATSMetadata: + action: receive + channel: + $ref: '#/channels/atsMetadata' + messages: + - $ref: '#/channels/atsMetadata/messages/status' + - $ref: '#/channels/atsMetadata/messages/available' + - $ref: '#/channels/atsMetadata/messages/genericPoint' + description: Subscribe to BMS-published metadata for ATS points. + + receiveGeneratorValue: + action: receive + channel: + $ref: '#/channels/generatorValue' + messages: + - $ref: '#/channels/generatorValue/messages/valueMessage' + description: Subscribe to real-time BMS-published values for Generator points. + + receiveGeneratorMetadata: + action: receive + channel: + $ref: '#/channels/generatorMetadata' + messages: + - $ref: '#/channels/generatorMetadata/messages/status' + - $ref: '#/channels/generatorMetadata/messages/available' + - $ref: '#/channels/generatorMetadata/messages/genericPoint' + description: Subscribe to BMS-published metadata for Generator points. + + receiveShuntValue: + action: receive + channel: + $ref: '#/channels/shuntValue' + messages: + - $ref: '#/channels/shuntValue/messages/valueMessage' + description: Subscribe to real-time BMS-published values for Shunt points. + + receiveShuntMetadata: + action: receive + channel: + $ref: '#/channels/shuntMetadata' + messages: + - $ref: '#/channels/shuntMetadata/messages/status' + - $ref: '#/channels/shuntMetadata/messages/available' + - $ref: '#/channels/shuntMetadata/messages/genericPoint' + description: Subscribe to BMS-published metadata for Shunt points. + + receiveBreakerValue: + action: receive + channel: + $ref: '#/channels/breakerValue' + messages: + - $ref: '#/channels/breakerValue/messages/valueMessage' + description: Subscribe to real-time BMS-published values for Breaker points. + + receiveBreakerMetadata: + action: receive + channel: + $ref: '#/channels/breakerMetadata' + messages: + - $ref: '#/channels/breakerMetadata/messages/status' + - $ref: '#/channels/breakerMetadata/messages/available' + - $ref: '#/channels/breakerMetadata/messages/genericPoint' + description: Subscribe to BMS-published metadata for Breaker points. + + receiveCDUValue: + action: receive + channel: + $ref: '#/channels/cduValue' + messages: + - $ref: '#/channels/cduValue/messages/valueMessage' + description: Subscribe to real-time BMS-published values for CDU points. + + receiveCDUMetadata: + action: receive + channel: + $ref: '#/channels/cduMetadata' + messages: + - $ref: '#/channels/cduMetadata/messages/liquidTemperature' + - $ref: '#/channels/cduMetadata/messages/liquidDifferentialPressure' + - $ref: '#/channels/cduMetadata/messages/liquidFlow' + - $ref: '#/channels/cduMetadata/messages/liquidPressure' + - $ref: '#/channels/cduMetadata/messages/status' + - $ref: '#/channels/cduMetadata/messages/available' + - $ref: '#/channels/cduMetadata/messages/valvePosition' + - $ref: '#/channels/cduMetadata/messages/pumpSpeed' + - $ref: '#/channels/cduMetadata/messages/fanSpeed' + - $ref: '#/channels/cduMetadata/messages/damperPosition' + - $ref: '#/channels/cduMetadata/messages/airTemperature' + - $ref: '#/channels/cduMetadata/messages/airDifferentialPressure' + - $ref: '#/channels/cduMetadata/messages/airRelativeHumidity' + - $ref: '#/channels/cduMetadata/messages/airFlow' + - $ref: '#/channels/cduMetadata/messages/airPressure' + - $ref: '#/channels/cduMetadata/messages/leakDetect' + - $ref: '#/channels/cduMetadata/messages/liquidTemperatureSpRequest' + - $ref: '#/channels/cduMetadata/messages/genericPoint' + description: Subscribe to BMS-published metadata for CDU points. + + publishCDUIntegrationValue: + action: send + channel: + $ref: '#/channels/cduIntegrationValue' + messages: + - $ref: '#/channels/cduIntegrationValue/messages/valueMessage' + description: Publish integration values for CDU control points. + + receiveCoolingTowerValue: + action: receive + channel: + $ref: '#/channels/coolingTowerValue' + messages: + - $ref: '#/channels/coolingTowerValue/messages/valueMessage' + description: Subscribe to real-time BMS-published values for CoolingTower points. + + receiveCoolingTowerMetadata: + action: receive + channel: + $ref: '#/channels/coolingTowerMetadata' + messages: + - $ref: '#/channels/coolingTowerMetadata/messages/liquidTemperature' + - $ref: '#/channels/coolingTowerMetadata/messages/liquidDifferentialPressure' + - $ref: '#/channels/coolingTowerMetadata/messages/liquidFlow' + - $ref: '#/channels/coolingTowerMetadata/messages/liquidPressure' + - $ref: '#/channels/coolingTowerMetadata/messages/status' + - $ref: '#/channels/coolingTowerMetadata/messages/available' + - $ref: '#/channels/coolingTowerMetadata/messages/valvePosition' + - $ref: '#/channels/coolingTowerMetadata/messages/pumpSpeed' + - $ref: '#/channels/coolingTowerMetadata/messages/fanSpeed' + - $ref: '#/channels/coolingTowerMetadata/messages/damperPosition' + - $ref: '#/channels/coolingTowerMetadata/messages/airTemperature' + - $ref: '#/channels/coolingTowerMetadata/messages/airDifferentialPressure' + - $ref: '#/channels/coolingTowerMetadata/messages/airRelativeHumidity' + - $ref: '#/channels/coolingTowerMetadata/messages/airFlow' + - $ref: '#/channels/coolingTowerMetadata/messages/airPressure' + - $ref: '#/channels/coolingTowerMetadata/messages/leakDetect' + - $ref: '#/channels/coolingTowerMetadata/messages/genericPoint' + description: Subscribe to BMS-published metadata for CoolingTower points. + + receiveHXValue: + action: receive + channel: + $ref: '#/channels/hxValue' + messages: + - $ref: '#/channels/hxValue/messages/valueMessage' + description: Subscribe to real-time BMS-published values for HX points. + + receiveHXMetadata: + action: receive + channel: + $ref: '#/channels/hxMetadata' + messages: + - $ref: '#/channels/hxMetadata/messages/liquidTemperature' + - $ref: '#/channels/hxMetadata/messages/liquidDifferentialPressure' + - $ref: '#/channels/hxMetadata/messages/liquidFlow' + - $ref: '#/channels/hxMetadata/messages/liquidPressure' + - $ref: '#/channels/hxMetadata/messages/status' + - $ref: '#/channels/hxMetadata/messages/available' + - $ref: '#/channels/hxMetadata/messages/valvePosition' + - $ref: '#/channels/hxMetadata/messages/pumpSpeed' + - $ref: '#/channels/hxMetadata/messages/fanSpeed' + - $ref: '#/channels/hxMetadata/messages/damperPosition' + - $ref: '#/channels/hxMetadata/messages/airTemperature' + - $ref: '#/channels/hxMetadata/messages/airDifferentialPressure' + - $ref: '#/channels/hxMetadata/messages/airRelativeHumidity' + - $ref: '#/channels/hxMetadata/messages/airFlow' + - $ref: '#/channels/hxMetadata/messages/airPressure' + - $ref: '#/channels/hxMetadata/messages/leakDetect' + - $ref: '#/channels/hxMetadata/messages/genericPoint' + description: Subscribe to BMS-published metadata for HX points. + + receiveCRAHValue: + action: receive + channel: + $ref: '#/channels/crahValue' + messages: + - $ref: '#/channels/crahValue/messages/valueMessage' + description: Subscribe to real-time BMS-published values for CRAH points. + + receiveCRAHMetadata: + action: receive + channel: + $ref: '#/channels/crahMetadata' + messages: + - $ref: '#/channels/crahMetadata/messages/liquidTemperature' + - $ref: '#/channels/crahMetadata/messages/liquidDifferentialPressure' + - $ref: '#/channels/crahMetadata/messages/liquidFlow' + - $ref: '#/channels/crahMetadata/messages/liquidPressure' + - $ref: '#/channels/crahMetadata/messages/status' + - $ref: '#/channels/crahMetadata/messages/available' + - $ref: '#/channels/crahMetadata/messages/valvePosition' + - $ref: '#/channels/crahMetadata/messages/pumpSpeed' + - $ref: '#/channels/crahMetadata/messages/fanSpeed' + - $ref: '#/channels/crahMetadata/messages/damperPosition' + - $ref: '#/channels/crahMetadata/messages/airTemperature' + - $ref: '#/channels/crahMetadata/messages/airDifferentialPressure' + - $ref: '#/channels/crahMetadata/messages/airRelativeHumidity' + - $ref: '#/channels/crahMetadata/messages/airFlow' + - $ref: '#/channels/crahMetadata/messages/airPressure' + - $ref: '#/channels/crahMetadata/messages/leakDetect' + - $ref: '#/channels/crahMetadata/messages/genericPoint' + description: Subscribe to BMS-published metadata for CRAH points. + + receiveCRACValue: + action: receive + channel: + $ref: '#/channels/cracValue' + messages: + - $ref: '#/channels/cracValue/messages/valueMessage' + description: Subscribe to real-time BMS-published values for CRAC points. + + receiveCRACMetadata: + action: receive + channel: + $ref: '#/channels/cracMetadata' + messages: + - $ref: '#/channels/cracMetadata/messages/liquidTemperature' + - $ref: '#/channels/cracMetadata/messages/liquidDifferentialPressure' + - $ref: '#/channels/cracMetadata/messages/liquidFlow' + - $ref: '#/channels/cracMetadata/messages/liquidPressure' + - $ref: '#/channels/cracMetadata/messages/status' + - $ref: '#/channels/cracMetadata/messages/available' + - $ref: '#/channels/cracMetadata/messages/valvePosition' + - $ref: '#/channels/cracMetadata/messages/pumpSpeed' + - $ref: '#/channels/cracMetadata/messages/fanSpeed' + - $ref: '#/channels/cracMetadata/messages/damperPosition' + - $ref: '#/channels/cracMetadata/messages/airTemperature' + - $ref: '#/channels/cracMetadata/messages/airDifferentialPressure' + - $ref: '#/channels/cracMetadata/messages/airRelativeHumidity' + - $ref: '#/channels/cracMetadata/messages/airFlow' + - $ref: '#/channels/cracMetadata/messages/airPressure' + - $ref: '#/channels/cracMetadata/messages/leakDetect' + - $ref: '#/channels/cracMetadata/messages/genericPoint' + description: Subscribe to BMS-published metadata for CRAC points. + + receiveAHUValue: + action: receive + channel: + $ref: '#/channels/ahuValue' + messages: + - $ref: '#/channels/ahuValue/messages/valueMessage' + description: Subscribe to real-time BMS-published values for AHU points. + + receiveAHUMetadata: + action: receive + channel: + $ref: '#/channels/ahuMetadata' + messages: + - $ref: '#/channels/ahuMetadata/messages/liquidTemperature' + - $ref: '#/channels/ahuMetadata/messages/liquidDifferentialPressure' + - $ref: '#/channels/ahuMetadata/messages/liquidFlow' + - $ref: '#/channels/ahuMetadata/messages/liquidPressure' + - $ref: '#/channels/ahuMetadata/messages/status' + - $ref: '#/channels/ahuMetadata/messages/available' + - $ref: '#/channels/ahuMetadata/messages/valvePosition' + - $ref: '#/channels/ahuMetadata/messages/pumpSpeed' + - $ref: '#/channels/ahuMetadata/messages/fanSpeed' + - $ref: '#/channels/ahuMetadata/messages/damperPosition' + - $ref: '#/channels/ahuMetadata/messages/airTemperature' + - $ref: '#/channels/ahuMetadata/messages/airDifferentialPressure' + - $ref: '#/channels/ahuMetadata/messages/airRelativeHumidity' + - $ref: '#/channels/ahuMetadata/messages/airFlow' + - $ref: '#/channels/ahuMetadata/messages/airPressure' + - $ref: '#/channels/ahuMetadata/messages/leakDetect' + - $ref: '#/channels/ahuMetadata/messages/genericPoint' + description: Subscribe to BMS-published metadata for AHU points. + + receiveChillerValue: + action: receive + channel: + $ref: '#/channels/chillerValue' + messages: + - $ref: '#/channels/chillerValue/messages/valueMessage' + description: Subscribe to real-time BMS-published values for Chiller points. + + receiveChillerMetadata: + action: receive + channel: + $ref: '#/channels/chillerMetadata' + messages: + - $ref: '#/channels/chillerMetadata/messages/liquidTemperature' + - $ref: '#/channels/chillerMetadata/messages/liquidDifferentialPressure' + - $ref: '#/channels/chillerMetadata/messages/liquidFlow' + - $ref: '#/channels/chillerMetadata/messages/liquidPressure' + - $ref: '#/channels/chillerMetadata/messages/status' + - $ref: '#/channels/chillerMetadata/messages/available' + - $ref: '#/channels/chillerMetadata/messages/valvePosition' + - $ref: '#/channels/chillerMetadata/messages/pumpSpeed' + - $ref: '#/channels/chillerMetadata/messages/fanSpeed' + - $ref: '#/channels/chillerMetadata/messages/damperPosition' + - $ref: '#/channels/chillerMetadata/messages/airTemperature' + - $ref: '#/channels/chillerMetadata/messages/airDifferentialPressure' + - $ref: '#/channels/chillerMetadata/messages/airRelativeHumidity' + - $ref: '#/channels/chillerMetadata/messages/airFlow' + - $ref: '#/channels/chillerMetadata/messages/airPressure' + - $ref: '#/channels/chillerMetadata/messages/leakDetect' + - $ref: '#/channels/chillerMetadata/messages/genericPoint' + description: Subscribe to BMS-published metadata for Chiller points. + + receiveValveValue: + action: receive + channel: + $ref: '#/channels/valveValue' + messages: + - $ref: '#/channels/valveValue/messages/valueMessage' + description: Subscribe to real-time BMS-published values for Valve points. + + receiveValveMetadata: + action: receive + channel: + $ref: '#/channels/valveMetadata' + messages: + - $ref: '#/channels/valveMetadata/messages/valvePosition' + - $ref: '#/channels/valveMetadata/messages/available' + - $ref: '#/channels/valveMetadata/messages/genericPoint' + description: Subscribe to BMS-published metadata for Valve points. + + receivePumpValue: + action: receive + channel: + $ref: '#/channels/pumpValue' + messages: + - $ref: '#/channels/pumpValue/messages/valueMessage' + description: Subscribe to real-time BMS-published values for Pump points. + + receivePumpMetadata: + action: receive + channel: + $ref: '#/channels/pumpMetadata' + messages: + - $ref: '#/channels/pumpMetadata/messages/pumpSpeed' + - $ref: '#/channels/pumpMetadata/messages/available' + - $ref: '#/channels/pumpMetadata/messages/genericPoint' + description: Subscribe to BMS-published metadata for Pump points. + + receiveFanValue: + action: receive + channel: + $ref: '#/channels/fanValue' + messages: + - $ref: '#/channels/fanValue/messages/valueMessage' + description: Subscribe to real-time BMS-published values for Fan points. + + receiveFanMetadata: + action: receive + channel: + $ref: '#/channels/fanMetadata' + messages: + - $ref: '#/channels/fanMetadata/messages/fanSpeed' + - $ref: '#/channels/fanMetadata/messages/available' + - $ref: '#/channels/fanMetadata/messages/genericPoint' + description: Subscribe to BMS-published metadata for Fan points. + + receiveDamperValue: + action: receive + channel: + $ref: '#/channels/damperValue' + messages: + - $ref: '#/channels/damperValue/messages/valueMessage' + description: Subscribe to real-time BMS-published values for Damper points. + + receiveDamperMetadata: + action: receive + channel: + $ref: '#/channels/damperMetadata' + messages: + - $ref: '#/channels/damperMetadata/messages/damperPosition' + - $ref: '#/channels/damperMetadata/messages/available' + - $ref: '#/channels/damperMetadata/messages/genericPoint' + description: Subscribe to BMS-published metadata for Damper points. + + receiveSensorValue: + action: receive + channel: + $ref: '#/channels/sensorValue' + messages: + - $ref: '#/channels/sensorValue/messages/valueMessage' + description: Subscribe to real-time BMS-published values for Sensor points. + + receiveSensorMetadata: + action: receive + channel: + $ref: '#/channels/sensorMetadata' + messages: + - $ref: '#/channels/sensorMetadata/messages/liquidTemperature' + - $ref: '#/channels/sensorMetadata/messages/liquidDifferentialPressure' + - $ref: '#/channels/sensorMetadata/messages/liquidFlow' + - $ref: '#/channels/sensorMetadata/messages/liquidPressure' + - $ref: '#/channels/sensorMetadata/messages/airTemperature' + - $ref: '#/channels/sensorMetadata/messages/airDifferentialPressure' + - $ref: '#/channels/sensorMetadata/messages/airRelativeHumidity' + - $ref: '#/channels/sensorMetadata/messages/airFlow' + - $ref: '#/channels/sensorMetadata/messages/airPressure' + - $ref: '#/channels/sensorMetadata/messages/leakDetect' + - $ref: '#/channels/sensorMetadata/messages/available' + - $ref: '#/channels/sensorMetadata/messages/sound' + - $ref: '#/channels/sensorMetadata/messages/genericPoint' + description: Subscribe to BMS-published metadata for Sensor points. + + + receiveTankValue: + action: receive + channel: + $ref: '#/channels/tankValue' + messages: + - $ref: '#/channels/tankValue/messages/valueMessage' + description: Subscribe to real-time BMS-published values for Tank points. + + receiveTankMetadata: + action: receive + channel: + $ref: '#/channels/tankMetadata' + messages: + - $ref: '#/channels/tankMetadata/messages/liquidTemperature' + - $ref: '#/channels/tankMetadata/messages/liquidDifferentialPressure' + - $ref: '#/channels/tankMetadata/messages/liquidFlow' + - $ref: '#/channels/tankMetadata/messages/liquidPressure' + - $ref: '#/channels/tankMetadata/messages/status' + - $ref: '#/channels/tankMetadata/messages/available' + - $ref: '#/channels/tankMetadata/messages/valvePosition' + - $ref: '#/channels/tankMetadata/messages/pumpSpeed' + - $ref: '#/channels/tankMetadata/messages/fanSpeed' + - $ref: '#/channels/tankMetadata/messages/damperPosition' + - $ref: '#/channels/tankMetadata/messages/airTemperature' + - $ref: '#/channels/tankMetadata/messages/airDifferentialPressure' + - $ref: '#/channels/tankMetadata/messages/airRelativeHumidity' + - $ref: '#/channels/tankMetadata/messages/airFlow' + - $ref: '#/channels/tankMetadata/messages/airPressure' + - $ref: '#/channels/tankMetadata/messages/leakDetect' + - $ref: '#/channels/tankMetadata/messages/genericPoint' + description: Subscribe to BMS-published metadata for Tank points. + + receiveGenericObjectValue: + action: receive + channel: + $ref: '#/channels/genericObjectValue' + messages: + - $ref: '#/channels/genericObjectValue/messages/valueMessage' + description: Subscribe to real-time BMS-published values for GenericObject points. + + receiveGenericObjectMetadata: + action: receive + channel: + $ref: '#/channels/genericObjectMetadata' + messages: + - $ref: '#/channels/genericObjectMetadata/messages/liquidTemperature' + - $ref: '#/channels/genericObjectMetadata/messages/liquidDifferentialPressure' + - $ref: '#/channels/genericObjectMetadata/messages/liquidFlow' + - $ref: '#/channels/genericObjectMetadata/messages/liquidPressure' + - $ref: '#/channels/genericObjectMetadata/messages/status' + - $ref: '#/channels/genericObjectMetadata/messages/available' + - $ref: '#/channels/genericObjectMetadata/messages/valvePosition' + - $ref: '#/channels/genericObjectMetadata/messages/pumpSpeed' + - $ref: '#/channels/genericObjectMetadata/messages/fanSpeed' + - $ref: '#/channels/genericObjectMetadata/messages/damperPosition' + - $ref: '#/channels/genericObjectMetadata/messages/airTemperature' + - $ref: '#/channels/genericObjectMetadata/messages/airDifferentialPressure' + - $ref: '#/channels/genericObjectMetadata/messages/airRelativeHumidity' + - $ref: '#/channels/genericObjectMetadata/messages/airFlow' + - $ref: '#/channels/genericObjectMetadata/messages/airPressure' + - $ref: '#/channels/genericObjectMetadata/messages/leakDetect' + - $ref: '#/channels/genericObjectMetadata/messages/liquidTemperatureSpRequest' + - $ref: '#/channels/genericObjectMetadata/messages/sound' + - $ref: '#/channels/genericObjectMetadata/messages/genericPoint' + description: Subscribe to BMS-published metadata for GenericObject points. + + publishGenericObjectIntegrationValue: + action: send + channel: + $ref: '#/channels/genericObjectIntegrationValue' + messages: + - $ref: '#/channels/genericObjectIntegrationValue/messages/valueMessage' + description: Publish integration values for GenericObject control points. + + receiveSystemBmsValue: + action: receive + channel: + $ref: '#/channels/systemBmsValue' + messages: + - $ref: '#/channels/systemBmsValue/messages/valueMessage' + description: Subscribe to BMS System values (heartbeat timestamps, status, etc.). + + receiveSystemMetadata: + action: receive + channel: + $ref: '#/channels/systemMetadata' + messages: + - $ref: '#/channels/systemMetadata/messages/heartbeatTimestampBms' + - $ref: '#/channels/systemMetadata/messages/heartbeatEchoBms' + - $ref: '#/channels/systemMetadata/messages/heartbeatTimestampIntegration' + - $ref: '#/channels/systemMetadata/messages/heartbeatEchoIntegration' + description: Subscribe to System metadata for all System point types. + + publishSystemIntegrationValue: + action: send + channel: + $ref: '#/channels/systemIntegrationValue' + messages: + - $ref: '#/channels/systemIntegrationValue/messages/valueMessage' + description: Publish integration System values to topics derived from BMS metadata. + + + + +# ============================================================================= +# Components +# ============================================================================= + +components: + + messages: + + ValueMessage: + name: ValueMessage + title: Point Value + description: | + Live value message. Payload envelope is identical for all point types. + The semantic meaning of `value` is determined by the corresponding + metadata message. + payload: + type: object + required: + - value + - timestamp + - quality + properties: + value: + oneOf: + - type: number + - type: 'null' + description: > + Live reading for the point (float). May be null when the BMS + has no valid reading available. + examples: + - 22.96215 + - 0.239 + - null + - 1 + + timestamp: + type: integer + description: Unix timestamp in epoch milliseconds (source event time). + examples: + - 1743620423000 + quality: + type: integer + description: > + `1` = good quality. Any other integer indicates value is not trustworthy + examples: + - 1 + - 0 + additionalProperties: false + + # Rack + RackLiquidSupplyTemperatureMsg: + name: RackLiquidSupplyTemperatureMsg + title: Rack RackLiquidSupplyTemperature Metadata + payload: + $ref: '#/components/schemas/RackLiquidSupplyTemperatureMetadata' + RackLiquidReturnTemperatureMsg: + name: RackLiquidReturnTemperatureMsg + title: Rack RackLiquidReturnTemperature Metadata + payload: + $ref: '#/components/schemas/RackLiquidReturnTemperatureMetadata' + RackLiquidFlowMsg: + name: RackLiquidFlowMsg + title: Rack RackLiquidFlow Metadata + payload: + $ref: '#/components/schemas/RackLiquidFlowMetadata' + RackLiquidDifferentialPressureMsg: + name: RackLiquidDifferentialPressureMsg + title: Rack RackLiquidDifferentialPressure Metadata + payload: + $ref: '#/components/schemas/RackLiquidDifferentialPressureMetadata' + RackControlValvePositionMsg: + name: RackControlValvePositionMsg + title: Rack RackControlValvePosition Metadata + payload: + $ref: '#/components/schemas/RackControlValvePositionMetadata' + RackPowerMsg: + name: RackPowerMsg + title: Rack RackPower Metadata + payload: + $ref: '#/components/schemas/RackPowerMetadata' + RackLeakDetectMsg: + name: RackLeakDetectMsg + title: Rack RackLeakDetect Metadata + payload: + $ref: '#/components/schemas/RackLeakDetectMetadata' + RackLeakSensorFaultMsg: + name: RackLeakSensorFaultMsg + title: Rack RackLeakSensorFault Metadata + payload: + $ref: '#/components/schemas/RackLeakSensorFaultMetadata' + RackLiquidIsolationStatusMsg: + name: RackLiquidIsolationStatusMsg + title: Rack RackLiquidIsolationStatus Metadata + payload: + $ref: '#/components/schemas/RackLiquidIsolationStatusMetadata' + RackElectricalIsolationStatusMsg: + name: RackElectricalIsolationStatusMsg + title: Rack RackElectricalIsolationStatus Metadata + payload: + $ref: '#/components/schemas/RackElectricalIsolationStatusMetadata' + RackLeakDetectTrayMsg: + name: RackLeakDetectTrayMsg + title: Rack RackLeakDetectTray Metadata + payload: + $ref: '#/components/schemas/RackLeakDetectTrayMetadata' + RackLiquidIsolationRequestMsg: + name: RackLiquidIsolationRequestMsg + title: Rack RackLiquidIsolationRequest Metadata + payload: + $ref: '#/components/schemas/RackLiquidIsolationRequestMetadata' + RackElectricalIsolationRequestMsg: + name: RackElectricalIsolationRequestMsg + title: Rack RackElectricalIsolationRequest Metadata + payload: + $ref: '#/components/schemas/RackElectricalIsolationRequestMetadata' + + # PowerMeter + PowerMeterVoltageMsg: + name: PowerMeterVoltageMsg + title: PowerMeter Voltage Metadata + payload: + $ref: '#/components/schemas/PowerMeterVoltageMetadata' + PowerMeterPowerFactorMsg: + name: PowerMeterPowerFactorMsg + title: PowerMeter PowerFactor Metadata + payload: + $ref: '#/components/schemas/PowerMeterPowerFactorMetadata' + PowerMeterFrequencyMsg: + name: PowerMeterFrequencyMsg + title: PowerMeter Frequency Metadata + payload: + $ref: '#/components/schemas/PowerMeterFrequencyMetadata' + PowerMeterApparentPowerMsg: + name: PowerMeterApparentPowerMsg + title: PowerMeter ApparentPower Metadata + payload: + $ref: '#/components/schemas/PowerMeterApparentPowerMetadata' + PowerMeterActivePowerMsg: + name: PowerMeterActivePowerMsg + title: PowerMeter ActivePower Metadata + payload: + $ref: '#/components/schemas/PowerMeterActivePowerMetadata' + PowerMeterCurrentMsg: + name: PowerMeterCurrentMsg + title: PowerMeter Current Metadata + payload: + $ref: '#/components/schemas/PowerMeterCurrentMetadata' + PowerMeterCurrentLimitMsg: + name: PowerMeterCurrentLimitMsg + title: PowerMeter CurrentLimit Metadata + payload: + $ref: '#/components/schemas/PowerMeterCurrentLimitMetadata' + PowerMeterPhaseCurrentMsg: + name: PowerMeterPhaseCurrentMsg + title: PowerMeter PhaseCurrent Metadata + payload: + $ref: '#/components/schemas/PowerMeterPhaseCurrentMetadata' + + # Generic Equipment + LiquidTemperatureMsg: + name: LiquidTemperatureMsg + title: LiquidTemperature Metadata + payload: + $ref: '#/components/schemas/LiquidTemperatureMetadata' + LiquidDifferentialPressureMsg: + name: LiquidDifferentialPressureMsg + title: LiquidDifferentialPressure Metadata + payload: + $ref: '#/components/schemas/LiquidDifferentialPressureMetadata' + LiquidFlowMsg: + name: LiquidFlowMsg + title: LiquidFlow Metadata + payload: + $ref: '#/components/schemas/LiquidFlowMetadata' + LiquidPressureMsg: + name: LiquidPressureMsg + title: LiquidPressure Metadata + payload: + $ref: '#/components/schemas/LiquidPressureMetadata' + StatusMsg: + name: StatusMsg + title: Status Metadata + payload: + $ref: '#/components/schemas/StatusMetadata' + AvailableMsg: + name: AvailableMsg + title: Available Metadata + payload: + $ref: '#/components/schemas/AvailableMetadata' + ValvePositionMsg: + name: ValvePositionMsg + title: ValvePosition Metadata + payload: + $ref: '#/components/schemas/ValvePositionMetadata' + PumpSpeedMsg: + name: PumpSpeedMsg + title: PumpSpeed Metadata + payload: + $ref: '#/components/schemas/PumpSpeedMetadata' + FanSpeedMsg: + name: FanSpeedMsg + title: FanSpeed Metadata + payload: + $ref: '#/components/schemas/FanSpeedMetadata' + DamperPositionMsg: + name: DamperPositionMsg + title: DamperPosition Metadata + payload: + $ref: '#/components/schemas/DamperPositionMetadata' + AirTemperatureMsg: + name: AirTemperatureMsg + title: AirTemperature Metadata + payload: + $ref: '#/components/schemas/AirTemperatureMetadata' + AirDifferentialPressureMsg: + name: AirDifferentialPressureMsg + title: AirDifferentialPressure Metadata + payload: + $ref: '#/components/schemas/AirDifferentialPressureMetadata' + AirFlowMsg: + name: AirFlowMsg + title: AirFlow Metadata + payload: + $ref: '#/components/schemas/AirFlowMetadata' + AirPressureMsg: + name: AirPressureMsg + title: AirPressure Metadata + payload: + $ref: '#/components/schemas/AirPressureMetadata' + LiquidTemperatureSpRequestMsg: + name: LiquidTemperatureSpRequestMsg + title: CDU LiquidTemperatureSpRequest Metadata + payload: + $ref: '#/components/schemas/LiquidTemperatureSpRequestMetadata' + SoundMsg: + name: SoundMsg + title: Sound Metadata + payload: + $ref: '#/components/schemas/SoundMetadata' + + # System + SystemHeartbeatTimestampBmsMsg: + name: SystemHeartbeatTimestampBmsMsg + title: HeartbeatTimestampBms Metadata + payload: + $ref: '#/components/schemas/SystemHeartbeatTimestampBmsMetadata' + SystemHeartbeatEchoBmsMsg: + name: SystemHeartbeatEchoBmsMsg + title: HeartbeatEchoBms Metadata + payload: + $ref: '#/components/schemas/SystemHeartbeatEchoBmsMetadata' + SystemHeartbeatTimestampIntegrationMsg: + name: SystemHeartbeatTimestampIntegrationMsg + title: HeartbeatTimestampIntegration Metadata + payload: + $ref: '#/components/schemas/SystemHeartbeatTimestampIntegrationMetadata' + SystemHeartbeatEchoIntegrationMsg: + name: SystemHeartbeatEchoIntegrationMsg + title: HeartbeatEchoIntegration Metadata + payload: + $ref: '#/components/schemas/SystemHeartbeatEchoIntegrationMetadata' + SystemStatusMsg: + name: SystemStatusMsg + title: System Status Metadata + payload: + $ref: '#/components/schemas/SystemStatusMetadata' + SystemAvailableMsg: + name: SystemAvailableMsg + title: System Available Metadata + payload: + $ref: '#/components/schemas/SystemAvailableMetadata' + + + # Generic Equipment + # --- Per-objectType messages (objectType constraint) --- + + BESSStatusMsg: + name: BESSStatusMsg + title: BESS Status Metadata + payload: + $ref: '#/components/schemas/BESSStatusMetadata' + + BESSAvailableMsg: + name: BESSAvailableMsg + title: BESS Available Metadata + payload: + $ref: '#/components/schemas/BESSAvailableMetadata' + + UPSStatusMsg: + name: UPSStatusMsg + title: UPS Status Metadata + payload: + $ref: '#/components/schemas/UPSStatusMetadata' + + UPSAvailableMsg: + name: UPSAvailableMsg + title: UPS Available Metadata + payload: + $ref: '#/components/schemas/UPSAvailableMetadata' + + ATSStatusMsg: + name: ATSStatusMsg + title: ATS Status Metadata + payload: + $ref: '#/components/schemas/ATSStatusMetadata' + + ATSAvailableMsg: + name: ATSAvailableMsg + title: ATS Available Metadata + payload: + $ref: '#/components/schemas/ATSAvailableMetadata' + + GeneratorStatusMsg: + name: GeneratorStatusMsg + title: Generator Status Metadata + payload: + $ref: '#/components/schemas/GeneratorStatusMetadata' + + GeneratorAvailableMsg: + name: GeneratorAvailableMsg + title: Generator Available Metadata + payload: + $ref: '#/components/schemas/GeneratorAvailableMetadata' + + ShuntStatusMsg: + name: ShuntStatusMsg + title: Shunt Status Metadata + payload: + $ref: '#/components/schemas/ShuntStatusMetadata' + + ShuntAvailableMsg: + name: ShuntAvailableMsg + title: Shunt Available Metadata + payload: + $ref: '#/components/schemas/ShuntAvailableMetadata' + + BreakerStatusMsg: + name: BreakerStatusMsg + title: Breaker Status Metadata + payload: + $ref: '#/components/schemas/BreakerStatusMetadata' + + BreakerAvailableMsg: + name: BreakerAvailableMsg + title: Breaker Available Metadata + payload: + $ref: '#/components/schemas/BreakerAvailableMetadata' + + ValveValvePositionMsg: + name: ValveValvePositionMsg + title: Valve ValvePosition Metadata + payload: + $ref: '#/components/schemas/ValveValvePositionMetadata' + + PumpPumpSpeedMsg: + name: PumpPumpSpeedMsg + title: Pump PumpSpeed Metadata + payload: + $ref: '#/components/schemas/PumpPumpSpeedMetadata' + + FanFanSpeedMsg: + name: FanFanSpeedMsg + title: Fan FanSpeed Metadata + payload: + $ref: '#/components/schemas/FanFanSpeedMetadata' + + DamperDamperPositionMsg: + name: DamperDamperPositionMsg + title: Damper DamperPosition Metadata + payload: + $ref: '#/components/schemas/DamperDamperPositionMetadata' + + SensorLiquidTemperatureMsg: + name: SensorLiquidTemperatureMsg + title: Sensor LiquidTemperature Metadata + payload: + $ref: '#/components/schemas/SensorLiquidTemperatureMetadata' + + SensorLiquidDifferentialPressureMsg: + name: SensorLiquidDifferentialPressureMsg + title: Sensor LiquidDifferentialPressure Metadata + payload: + $ref: '#/components/schemas/SensorLiquidDifferentialPressureMetadata' + + SensorLiquidFlowMsg: + name: SensorLiquidFlowMsg + title: Sensor LiquidFlow Metadata + payload: + $ref: '#/components/schemas/SensorLiquidFlowMetadata' + + SensorLiquidPressureMsg: + name: SensorLiquidPressureMsg + title: Sensor LiquidPressure Metadata + payload: + $ref: '#/components/schemas/SensorLiquidPressureMetadata' + + SensorAirTemperatureMsg: + name: SensorAirTemperatureMsg + title: Sensor AirTemperature Metadata + payload: + $ref: '#/components/schemas/SensorAirTemperatureMetadata' + + SensorAirDifferentialPressureMsg: + name: SensorAirDifferentialPressureMsg + title: Sensor AirDifferentialPressure Metadata + payload: + $ref: '#/components/schemas/SensorAirDifferentialPressureMetadata' + + SensorAirFlowMsg: + name: SensorAirFlowMsg + title: Sensor AirFlow Metadata + payload: + $ref: '#/components/schemas/SensorAirFlowMetadata' + + SensorAirPressureMsg: + name: SensorAirPressureMsg + title: Sensor AirPressure Metadata + payload: + $ref: '#/components/schemas/SensorAirPressureMetadata' + + SensorSoundMsg: + name: SensorSoundMsg + title: Sensor Sound Metadata + payload: + $ref: '#/components/schemas/SensorSoundMetadata' + + ValveAvailableMsg: + name: ValveAvailableMsg + title: Valve Available Metadata + payload: + $ref: '#/components/schemas/ValveAvailableMetadata' + + PumpAvailableMsg: + name: PumpAvailableMsg + title: Pump Available Metadata + payload: + $ref: '#/components/schemas/PumpAvailableMetadata' + + FanAvailableMsg: + name: FanAvailableMsg + title: Fan Available Metadata + payload: + $ref: '#/components/schemas/FanAvailableMetadata' + + DamperAvailableMsg: + name: DamperAvailableMsg + title: Damper Available Metadata + payload: + $ref: '#/components/schemas/DamperAvailableMetadata' + + SensorAvailableMsg: + name: SensorAvailableMsg + title: Sensor Available Metadata + payload: + $ref: '#/components/schemas/SensorAvailableMetadata' + + CDULeakDetectMsg: + name: CDULeakDetectMsg + title: CDU LeakDetect Metadata + payload: + $ref: '#/components/schemas/CDULeakDetectMetadata' + + CoolingTowerLeakDetectMsg: + name: CoolingTowerLeakDetectMsg + title: CoolingTower LeakDetect Metadata + payload: + $ref: '#/components/schemas/CoolingTowerLeakDetectMetadata' + + HXLeakDetectMsg: + name: HXLeakDetectMsg + title: HX LeakDetect Metadata + payload: + $ref: '#/components/schemas/HXLeakDetectMetadata' + + CRAHLeakDetectMsg: + name: CRAHLeakDetectMsg + title: CRAH LeakDetect Metadata + payload: + $ref: '#/components/schemas/CRAHLeakDetectMetadata' + + CRACLeakDetectMsg: + name: CRACLeakDetectMsg + title: CRAC LeakDetect Metadata + payload: + $ref: '#/components/schemas/CRACLeakDetectMetadata' + + AHULeakDetectMsg: + name: AHULeakDetectMsg + title: AHU LeakDetect Metadata + payload: + $ref: '#/components/schemas/AHULeakDetectMetadata' + + ChillerLeakDetectMsg: + name: ChillerLeakDetectMsg + title: Chiller LeakDetect Metadata + payload: + $ref: '#/components/schemas/ChillerLeakDetectMetadata' + + TankLeakDetectMsg: + name: TankLeakDetectMsg + title: Tank LeakDetect Metadata + payload: + $ref: '#/components/schemas/TankLeakDetectMetadata' + + SensorLeakDetectMsg: + name: SensorLeakDetectMsg + title: Sensor LeakDetect Metadata + payload: + $ref: '#/components/schemas/SensorLeakDetectMetadata' + + GenericObjectLeakDetectMsg: + name: GenericObjectLeakDetectMsg + title: GenericObject LeakDetect Metadata + payload: + $ref: '#/components/schemas/GenericObjectLeakDetectMetadata' + + CDUAirRelativeHumidityMsg: + name: CDUAirRelativeHumidityMsg + title: CDU AirRelativeHumidity Metadata + payload: + $ref: '#/components/schemas/CDUAirRelativeHumidityMetadata' + + CoolingTowerAirRelativeHumidityMsg: + name: CoolingTowerAirRelativeHumidityMsg + title: CoolingTower AirRelativeHumidity Metadata + payload: + $ref: '#/components/schemas/CoolingTowerAirRelativeHumidityMetadata' + + HXAirRelativeHumidityMsg: + name: HXAirRelativeHumidityMsg + title: HX AirRelativeHumidity Metadata + payload: + $ref: '#/components/schemas/HXAirRelativeHumidityMetadata' + + CRAHAirRelativeHumidityMsg: + name: CRAHAirRelativeHumidityMsg + title: CRAH AirRelativeHumidity Metadata + payload: + $ref: '#/components/schemas/CRAHAirRelativeHumidityMetadata' + + CRACAirRelativeHumidityMsg: + name: CRACAirRelativeHumidityMsg + title: CRAC AirRelativeHumidity Metadata + payload: + $ref: '#/components/schemas/CRACAirRelativeHumidityMetadata' + + AHUAirRelativeHumidityMsg: + name: AHUAirRelativeHumidityMsg + title: AHU AirRelativeHumidity Metadata + payload: + $ref: '#/components/schemas/AHUAirRelativeHumidityMetadata' + + ChillerAirRelativeHumidityMsg: + name: ChillerAirRelativeHumidityMsg + title: Chiller AirRelativeHumidity Metadata + payload: + $ref: '#/components/schemas/ChillerAirRelativeHumidityMetadata' + + TankAirRelativeHumidityMsg: + name: TankAirRelativeHumidityMsg + title: Tank AirRelativeHumidity Metadata + payload: + $ref: '#/components/schemas/TankAirRelativeHumidityMetadata' + + SensorAirRelativeHumidityMsg: + name: SensorAirRelativeHumidityMsg + title: Sensor AirRelativeHumidity Metadata + payload: + $ref: '#/components/schemas/SensorAirRelativeHumidityMetadata' + + GenericObjectAirRelativeHumidityMsg: + name: GenericObjectAirRelativeHumidityMsg + title: GenericObject AirRelativeHumidity Metadata + payload: + $ref: '#/components/schemas/GenericObjectAirRelativeHumidityMetadata' + + RackLiquidDifferentialPressureSpMsg: + name: RackLiquidDifferentialPressureSpMsg + title: Rack RackLiquidDifferentialPressureSp Metadata + payload: + $ref: '#/components/schemas/RackLiquidDifferentialPressureSpMetadata' + + CDULiquidTemperatureMsg: + name: CDULiquidTemperatureMsg + title: CDU LiquidTemperature Metadata + payload: + $ref: '#/components/schemas/CDULiquidTemperatureMetadata' + + CDULiquidDifferentialPressureMsg: + name: CDULiquidDifferentialPressureMsg + title: CDU LiquidDifferentialPressure Metadata + payload: + $ref: '#/components/schemas/CDULiquidDifferentialPressureMetadata' + + CDULiquidFlowMsg: + name: CDULiquidFlowMsg + title: CDU LiquidFlow Metadata + payload: + $ref: '#/components/schemas/CDULiquidFlowMetadata' + + CDULiquidPressureMsg: + name: CDULiquidPressureMsg + title: CDU LiquidPressure Metadata + payload: + $ref: '#/components/schemas/CDULiquidPressureMetadata' + + CDUStatusMsg: + name: CDUStatusMsg + title: CDU Status Metadata + payload: + $ref: '#/components/schemas/CDUStatusMetadata' + + CDUAvailableMsg: + name: CDUAvailableMsg + title: CDU Available Metadata + payload: + $ref: '#/components/schemas/CDUAvailableMetadata' + + CDUValvePositionMsg: + name: CDUValvePositionMsg + title: CDU ValvePosition Metadata + payload: + $ref: '#/components/schemas/CDUValvePositionMetadata' + + CDUPumpSpeedMsg: + name: CDUPumpSpeedMsg + title: CDU PumpSpeed Metadata + payload: + $ref: '#/components/schemas/CDUPumpSpeedMetadata' + + CDUFanSpeedMsg: + name: CDUFanSpeedMsg + title: CDU FanSpeed Metadata + payload: + $ref: '#/components/schemas/CDUFanSpeedMetadata' + + CDUDamperPositionMsg: + name: CDUDamperPositionMsg + title: CDU DamperPosition Metadata + payload: + $ref: '#/components/schemas/CDUDamperPositionMetadata' + + CDUAirTemperatureMsg: + name: CDUAirTemperatureMsg + title: CDU AirTemperature Metadata + payload: + $ref: '#/components/schemas/CDUAirTemperatureMetadata' + + CDUAirDifferentialPressureMsg: + name: CDUAirDifferentialPressureMsg + title: CDU AirDifferentialPressure Metadata + payload: + $ref: '#/components/schemas/CDUAirDifferentialPressureMetadata' + + CDUAirFlowMsg: + name: CDUAirFlowMsg + title: CDU AirFlow Metadata + payload: + $ref: '#/components/schemas/CDUAirFlowMetadata' + + CDUAirPressureMsg: + name: CDUAirPressureMsg + title: CDU AirPressure Metadata + payload: + $ref: '#/components/schemas/CDUAirPressureMetadata' + + CoolingTowerLiquidTemperatureMsg: + name: CoolingTowerLiquidTemperatureMsg + title: CoolingTower LiquidTemperature Metadata + payload: + $ref: '#/components/schemas/CoolingTowerLiquidTemperatureMetadata' + + CoolingTowerLiquidDifferentialPressureMsg: + name: CoolingTowerLiquidDifferentialPressureMsg + title: CoolingTower LiquidDifferentialPressure Metadata + payload: + $ref: '#/components/schemas/CoolingTowerLiquidDifferentialPressureMetadata' + + CoolingTowerLiquidFlowMsg: + name: CoolingTowerLiquidFlowMsg + title: CoolingTower LiquidFlow Metadata + payload: + $ref: '#/components/schemas/CoolingTowerLiquidFlowMetadata' + + CoolingTowerLiquidPressureMsg: + name: CoolingTowerLiquidPressureMsg + title: CoolingTower LiquidPressure Metadata + payload: + $ref: '#/components/schemas/CoolingTowerLiquidPressureMetadata' + + CoolingTowerStatusMsg: + name: CoolingTowerStatusMsg + title: CoolingTower Status Metadata + payload: + $ref: '#/components/schemas/CoolingTowerStatusMetadata' + + CoolingTowerAvailableMsg: + name: CoolingTowerAvailableMsg + title: CoolingTower Available Metadata + payload: + $ref: '#/components/schemas/CoolingTowerAvailableMetadata' + + CoolingTowerValvePositionMsg: + name: CoolingTowerValvePositionMsg + title: CoolingTower ValvePosition Metadata + payload: + $ref: '#/components/schemas/CoolingTowerValvePositionMetadata' + + CoolingTowerPumpSpeedMsg: + name: CoolingTowerPumpSpeedMsg + title: CoolingTower PumpSpeed Metadata + payload: + $ref: '#/components/schemas/CoolingTowerPumpSpeedMetadata' + + CoolingTowerFanSpeedMsg: + name: CoolingTowerFanSpeedMsg + title: CoolingTower FanSpeed Metadata + payload: + $ref: '#/components/schemas/CoolingTowerFanSpeedMetadata' + + CoolingTowerDamperPositionMsg: + name: CoolingTowerDamperPositionMsg + title: CoolingTower DamperPosition Metadata + payload: + $ref: '#/components/schemas/CoolingTowerDamperPositionMetadata' + + CoolingTowerAirTemperatureMsg: + name: CoolingTowerAirTemperatureMsg + title: CoolingTower AirTemperature Metadata + payload: + $ref: '#/components/schemas/CoolingTowerAirTemperatureMetadata' + + CoolingTowerAirDifferentialPressureMsg: + name: CoolingTowerAirDifferentialPressureMsg + title: CoolingTower AirDifferentialPressure Metadata + payload: + $ref: '#/components/schemas/CoolingTowerAirDifferentialPressureMetadata' + + CoolingTowerAirFlowMsg: + name: CoolingTowerAirFlowMsg + title: CoolingTower AirFlow Metadata + payload: + $ref: '#/components/schemas/CoolingTowerAirFlowMetadata' + + CoolingTowerAirPressureMsg: + name: CoolingTowerAirPressureMsg + title: CoolingTower AirPressure Metadata + payload: + $ref: '#/components/schemas/CoolingTowerAirPressureMetadata' + + HXLiquidTemperatureMsg: + name: HXLiquidTemperatureMsg + title: HX LiquidTemperature Metadata + payload: + $ref: '#/components/schemas/HXLiquidTemperatureMetadata' + + HXLiquidDifferentialPressureMsg: + name: HXLiquidDifferentialPressureMsg + title: HX LiquidDifferentialPressure Metadata + payload: + $ref: '#/components/schemas/HXLiquidDifferentialPressureMetadata' + + HXLiquidFlowMsg: + name: HXLiquidFlowMsg + title: HX LiquidFlow Metadata + payload: + $ref: '#/components/schemas/HXLiquidFlowMetadata' + + HXLiquidPressureMsg: + name: HXLiquidPressureMsg + title: HX LiquidPressure Metadata + payload: + $ref: '#/components/schemas/HXLiquidPressureMetadata' + + HXStatusMsg: + name: HXStatusMsg + title: HX Status Metadata + payload: + $ref: '#/components/schemas/HXStatusMetadata' + + HXAvailableMsg: + name: HXAvailableMsg + title: HX Available Metadata + payload: + $ref: '#/components/schemas/HXAvailableMetadata' + + HXValvePositionMsg: + name: HXValvePositionMsg + title: HX ValvePosition Metadata + payload: + $ref: '#/components/schemas/HXValvePositionMetadata' + + HXPumpSpeedMsg: + name: HXPumpSpeedMsg + title: HX PumpSpeed Metadata + payload: + $ref: '#/components/schemas/HXPumpSpeedMetadata' + + HXFanSpeedMsg: + name: HXFanSpeedMsg + title: HX FanSpeed Metadata + payload: + $ref: '#/components/schemas/HXFanSpeedMetadata' + + HXDamperPositionMsg: + name: HXDamperPositionMsg + title: HX DamperPosition Metadata + payload: + $ref: '#/components/schemas/HXDamperPositionMetadata' + + HXAirTemperatureMsg: + name: HXAirTemperatureMsg + title: HX AirTemperature Metadata + payload: + $ref: '#/components/schemas/HXAirTemperatureMetadata' + + HXAirDifferentialPressureMsg: + name: HXAirDifferentialPressureMsg + title: HX AirDifferentialPressure Metadata + payload: + $ref: '#/components/schemas/HXAirDifferentialPressureMetadata' + + HXAirFlowMsg: + name: HXAirFlowMsg + title: HX AirFlow Metadata + payload: + $ref: '#/components/schemas/HXAirFlowMetadata' + + HXAirPressureMsg: + name: HXAirPressureMsg + title: HX AirPressure Metadata + payload: + $ref: '#/components/schemas/HXAirPressureMetadata' + + CRAHLiquidTemperatureMsg: + name: CRAHLiquidTemperatureMsg + title: CRAH LiquidTemperature Metadata + payload: + $ref: '#/components/schemas/CRAHLiquidTemperatureMetadata' + + CRAHLiquidDifferentialPressureMsg: + name: CRAHLiquidDifferentialPressureMsg + title: CRAH LiquidDifferentialPressure Metadata + payload: + $ref: '#/components/schemas/CRAHLiquidDifferentialPressureMetadata' + + CRAHLiquidFlowMsg: + name: CRAHLiquidFlowMsg + title: CRAH LiquidFlow Metadata + payload: + $ref: '#/components/schemas/CRAHLiquidFlowMetadata' + + CRAHLiquidPressureMsg: + name: CRAHLiquidPressureMsg + title: CRAH LiquidPressure Metadata + payload: + $ref: '#/components/schemas/CRAHLiquidPressureMetadata' + + CRAHStatusMsg: + name: CRAHStatusMsg + title: CRAH Status Metadata + payload: + $ref: '#/components/schemas/CRAHStatusMetadata' + + CRAHAvailableMsg: + name: CRAHAvailableMsg + title: CRAH Available Metadata + payload: + $ref: '#/components/schemas/CRAHAvailableMetadata' + + CRAHValvePositionMsg: + name: CRAHValvePositionMsg + title: CRAH ValvePosition Metadata + payload: + $ref: '#/components/schemas/CRAHValvePositionMetadata' + + CRAHPumpSpeedMsg: + name: CRAHPumpSpeedMsg + title: CRAH PumpSpeed Metadata + payload: + $ref: '#/components/schemas/CRAHPumpSpeedMetadata' + + CRAHFanSpeedMsg: + name: CRAHFanSpeedMsg + title: CRAH FanSpeed Metadata + payload: + $ref: '#/components/schemas/CRAHFanSpeedMetadata' + + CRAHDamperPositionMsg: + name: CRAHDamperPositionMsg + title: CRAH DamperPosition Metadata + payload: + $ref: '#/components/schemas/CRAHDamperPositionMetadata' + + CRAHAirTemperatureMsg: + name: CRAHAirTemperatureMsg + title: CRAH AirTemperature Metadata + payload: + $ref: '#/components/schemas/CRAHAirTemperatureMetadata' + + CRAHAirDifferentialPressureMsg: + name: CRAHAirDifferentialPressureMsg + title: CRAH AirDifferentialPressure Metadata + payload: + $ref: '#/components/schemas/CRAHAirDifferentialPressureMetadata' + + CRAHAirFlowMsg: + name: CRAHAirFlowMsg + title: CRAH AirFlow Metadata + payload: + $ref: '#/components/schemas/CRAHAirFlowMetadata' + + CRAHAirPressureMsg: + name: CRAHAirPressureMsg + title: CRAH AirPressure Metadata + payload: + $ref: '#/components/schemas/CRAHAirPressureMetadata' + + CRACLiquidTemperatureMsg: + name: CRACLiquidTemperatureMsg + title: CRAC LiquidTemperature Metadata + payload: + $ref: '#/components/schemas/CRACLiquidTemperatureMetadata' + + CRACLiquidDifferentialPressureMsg: + name: CRACLiquidDifferentialPressureMsg + title: CRAC LiquidDifferentialPressure Metadata + payload: + $ref: '#/components/schemas/CRACLiquidDifferentialPressureMetadata' + + CRACLiquidFlowMsg: + name: CRACLiquidFlowMsg + title: CRAC LiquidFlow Metadata + payload: + $ref: '#/components/schemas/CRACLiquidFlowMetadata' + + CRACLiquidPressureMsg: + name: CRACLiquidPressureMsg + title: CRAC LiquidPressure Metadata + payload: + $ref: '#/components/schemas/CRACLiquidPressureMetadata' + + CRACStatusMsg: + name: CRACStatusMsg + title: CRAC Status Metadata + payload: + $ref: '#/components/schemas/CRACStatusMetadata' + + CRACAvailableMsg: + name: CRACAvailableMsg + title: CRAC Available Metadata + payload: + $ref: '#/components/schemas/CRACAvailableMetadata' + + CRACValvePositionMsg: + name: CRACValvePositionMsg + title: CRAC ValvePosition Metadata + payload: + $ref: '#/components/schemas/CRACValvePositionMetadata' + + CRACPumpSpeedMsg: + name: CRACPumpSpeedMsg + title: CRAC PumpSpeed Metadata + payload: + $ref: '#/components/schemas/CRACPumpSpeedMetadata' + + CRACFanSpeedMsg: + name: CRACFanSpeedMsg + title: CRAC FanSpeed Metadata + payload: + $ref: '#/components/schemas/CRACFanSpeedMetadata' + + CRACDamperPositionMsg: + name: CRACDamperPositionMsg + title: CRAC DamperPosition Metadata + payload: + $ref: '#/components/schemas/CRACDamperPositionMetadata' + + CRACAirTemperatureMsg: + name: CRACAirTemperatureMsg + title: CRAC AirTemperature Metadata + payload: + $ref: '#/components/schemas/CRACAirTemperatureMetadata' + + CRACAirDifferentialPressureMsg: + name: CRACAirDifferentialPressureMsg + title: CRAC AirDifferentialPressure Metadata + payload: + $ref: '#/components/schemas/CRACAirDifferentialPressureMetadata' + + CRACAirFlowMsg: + name: CRACAirFlowMsg + title: CRAC AirFlow Metadata + payload: + $ref: '#/components/schemas/CRACAirFlowMetadata' + + CRACAirPressureMsg: + name: CRACAirPressureMsg + title: CRAC AirPressure Metadata + payload: + $ref: '#/components/schemas/CRACAirPressureMetadata' + + AHULiquidTemperatureMsg: + name: AHULiquidTemperatureMsg + title: AHU LiquidTemperature Metadata + payload: + $ref: '#/components/schemas/AHULiquidTemperatureMetadata' + + AHULiquidDifferentialPressureMsg: + name: AHULiquidDifferentialPressureMsg + title: AHU LiquidDifferentialPressure Metadata + payload: + $ref: '#/components/schemas/AHULiquidDifferentialPressureMetadata' + + AHULiquidFlowMsg: + name: AHULiquidFlowMsg + title: AHU LiquidFlow Metadata + payload: + $ref: '#/components/schemas/AHULiquidFlowMetadata' + + AHULiquidPressureMsg: + name: AHULiquidPressureMsg + title: AHU LiquidPressure Metadata + payload: + $ref: '#/components/schemas/AHULiquidPressureMetadata' + + AHUStatusMsg: + name: AHUStatusMsg + title: AHU Status Metadata + payload: + $ref: '#/components/schemas/AHUStatusMetadata' + + AHUAvailableMsg: + name: AHUAvailableMsg + title: AHU Available Metadata + payload: + $ref: '#/components/schemas/AHUAvailableMetadata' + + AHUValvePositionMsg: + name: AHUValvePositionMsg + title: AHU ValvePosition Metadata + payload: + $ref: '#/components/schemas/AHUValvePositionMetadata' + + AHUPumpSpeedMsg: + name: AHUPumpSpeedMsg + title: AHU PumpSpeed Metadata + payload: + $ref: '#/components/schemas/AHUPumpSpeedMetadata' + + AHUFanSpeedMsg: + name: AHUFanSpeedMsg + title: AHU FanSpeed Metadata + payload: + $ref: '#/components/schemas/AHUFanSpeedMetadata' + + AHUDamperPositionMsg: + name: AHUDamperPositionMsg + title: AHU DamperPosition Metadata + payload: + $ref: '#/components/schemas/AHUDamperPositionMetadata' + + AHUAirTemperatureMsg: + name: AHUAirTemperatureMsg + title: AHU AirTemperature Metadata + payload: + $ref: '#/components/schemas/AHUAirTemperatureMetadata' + + AHUAirDifferentialPressureMsg: + name: AHUAirDifferentialPressureMsg + title: AHU AirDifferentialPressure Metadata + payload: + $ref: '#/components/schemas/AHUAirDifferentialPressureMetadata' + + AHUAirFlowMsg: + name: AHUAirFlowMsg + title: AHU AirFlow Metadata + payload: + $ref: '#/components/schemas/AHUAirFlowMetadata' + + AHUAirPressureMsg: + name: AHUAirPressureMsg + title: AHU AirPressure Metadata + payload: + $ref: '#/components/schemas/AHUAirPressureMetadata' + + ChillerLiquidTemperatureMsg: + name: ChillerLiquidTemperatureMsg + title: Chiller LiquidTemperature Metadata + payload: + $ref: '#/components/schemas/ChillerLiquidTemperatureMetadata' + + ChillerLiquidDifferentialPressureMsg: + name: ChillerLiquidDifferentialPressureMsg + title: Chiller LiquidDifferentialPressure Metadata + payload: + $ref: '#/components/schemas/ChillerLiquidDifferentialPressureMetadata' + + ChillerLiquidFlowMsg: + name: ChillerLiquidFlowMsg + title: Chiller LiquidFlow Metadata + payload: + $ref: '#/components/schemas/ChillerLiquidFlowMetadata' + + ChillerLiquidPressureMsg: + name: ChillerLiquidPressureMsg + title: Chiller LiquidPressure Metadata + payload: + $ref: '#/components/schemas/ChillerLiquidPressureMetadata' + + ChillerStatusMsg: + name: ChillerStatusMsg + title: Chiller Status Metadata + payload: + $ref: '#/components/schemas/ChillerStatusMetadata' + + ChillerAvailableMsg: + name: ChillerAvailableMsg + title: Chiller Available Metadata + payload: + $ref: '#/components/schemas/ChillerAvailableMetadata' + + ChillerValvePositionMsg: + name: ChillerValvePositionMsg + title: Chiller ValvePosition Metadata + payload: + $ref: '#/components/schemas/ChillerValvePositionMetadata' + + ChillerPumpSpeedMsg: + name: ChillerPumpSpeedMsg + title: Chiller PumpSpeed Metadata + payload: + $ref: '#/components/schemas/ChillerPumpSpeedMetadata' + + ChillerFanSpeedMsg: + name: ChillerFanSpeedMsg + title: Chiller FanSpeed Metadata + payload: + $ref: '#/components/schemas/ChillerFanSpeedMetadata' + + ChillerDamperPositionMsg: + name: ChillerDamperPositionMsg + title: Chiller DamperPosition Metadata + payload: + $ref: '#/components/schemas/ChillerDamperPositionMetadata' + + ChillerAirTemperatureMsg: + name: ChillerAirTemperatureMsg + title: Chiller AirTemperature Metadata + payload: + $ref: '#/components/schemas/ChillerAirTemperatureMetadata' + + ChillerAirDifferentialPressureMsg: + name: ChillerAirDifferentialPressureMsg + title: Chiller AirDifferentialPressure Metadata + payload: + $ref: '#/components/schemas/ChillerAirDifferentialPressureMetadata' + + ChillerAirFlowMsg: + name: ChillerAirFlowMsg + title: Chiller AirFlow Metadata + payload: + $ref: '#/components/schemas/ChillerAirFlowMetadata' + + ChillerAirPressureMsg: + name: ChillerAirPressureMsg + title: Chiller AirPressure Metadata + payload: + $ref: '#/components/schemas/ChillerAirPressureMetadata' + GenericEquipmentPointMsg: + name: GenericEquipmentPointMsg + title: GenericEquipment GenericPoint Metadata + payload: + $ref: '#/components/schemas/GenericEquipmentPointMetadata' + + # Generic PowerMeter + GenericPowerMeterPointMsg: + name: GenericPowerMeterPointMsg + title: PowerMeter GenericPoint Metadata + payload: + $ref: '#/components/schemas/GenericPowerMeterPointMetadata' + + # Tank + TankLiquidTemperatureMsg: + name: TankLiquidTemperatureMsg + title: Tank LiquidTemperature Metadata + payload: + $ref: '#/components/schemas/TankLiquidTemperatureMetadata' + + TankLiquidDifferentialPressureMsg: + name: TankLiquidDifferentialPressureMsg + title: Tank LiquidDifferentialPressure Metadata + payload: + $ref: '#/components/schemas/TankLiquidDifferentialPressureMetadata' + + TankLiquidFlowMsg: + name: TankLiquidFlowMsg + title: Tank LiquidFlow Metadata + payload: + $ref: '#/components/schemas/TankLiquidFlowMetadata' + + TankLiquidPressureMsg: + name: TankLiquidPressureMsg + title: Tank LiquidPressure Metadata + payload: + $ref: '#/components/schemas/TankLiquidPressureMetadata' + + TankStatusMsg: + name: TankStatusMsg + title: Tank Status Metadata + payload: + $ref: '#/components/schemas/TankStatusMetadata' + + TankAvailableMsg: + name: TankAvailableMsg + title: Tank Available Metadata + payload: + $ref: '#/components/schemas/TankAvailableMetadata' + + TankValvePositionMsg: + name: TankValvePositionMsg + title: Tank ValvePosition Metadata + payload: + $ref: '#/components/schemas/TankValvePositionMetadata' + + TankPumpSpeedMsg: + name: TankPumpSpeedMsg + title: Tank PumpSpeed Metadata + payload: + $ref: '#/components/schemas/TankPumpSpeedMetadata' + + TankFanSpeedMsg: + name: TankFanSpeedMsg + title: Tank FanSpeed Metadata + payload: + $ref: '#/components/schemas/TankFanSpeedMetadata' + + TankDamperPositionMsg: + name: TankDamperPositionMsg + title: Tank DamperPosition Metadata + payload: + $ref: '#/components/schemas/TankDamperPositionMetadata' + + TankAirTemperatureMsg: + name: TankAirTemperatureMsg + title: Tank AirTemperature Metadata + payload: + $ref: '#/components/schemas/TankAirTemperatureMetadata' + + TankAirDifferentialPressureMsg: + name: TankAirDifferentialPressureMsg + title: Tank AirDifferentialPressure Metadata + payload: + $ref: '#/components/schemas/TankAirDifferentialPressureMetadata' + + TankAirFlowMsg: + name: TankAirFlowMsg + title: Tank AirFlow Metadata + payload: + $ref: '#/components/schemas/TankAirFlowMetadata' + + TankAirPressureMsg: + name: TankAirPressureMsg + title: Tank AirPressure Metadata + payload: + $ref: '#/components/schemas/TankAirPressureMetadata' + + # GenericObject + GenericObjectLiquidTemperatureMsg: + name: GenericObjectLiquidTemperatureMsg + title: GenericObject LiquidTemperature Metadata + payload: + $ref: '#/components/schemas/GenericObjectLiquidTemperatureMetadata' + + GenericObjectLiquidDifferentialPressureMsg: + name: GenericObjectLiquidDifferentialPressureMsg + title: GenericObject LiquidDifferentialPressure Metadata + payload: + $ref: '#/components/schemas/GenericObjectLiquidDifferentialPressureMetadata' + + GenericObjectLiquidFlowMsg: + name: GenericObjectLiquidFlowMsg + title: GenericObject LiquidFlow Metadata + payload: + $ref: '#/components/schemas/GenericObjectLiquidFlowMetadata' + + GenericObjectLiquidPressureMsg: + name: GenericObjectLiquidPressureMsg + title: GenericObject LiquidPressure Metadata + payload: + $ref: '#/components/schemas/GenericObjectLiquidPressureMetadata' + + GenericObjectStatusMsg: + name: GenericObjectStatusMsg + title: GenericObject Status Metadata + payload: + $ref: '#/components/schemas/GenericObjectStatusMetadata' + + GenericObjectAvailableMsg: + name: GenericObjectAvailableMsg + title: GenericObject Available Metadata + payload: + $ref: '#/components/schemas/GenericObjectAvailableMetadata' + + GenericObjectValvePositionMsg: + name: GenericObjectValvePositionMsg + title: GenericObject ValvePosition Metadata + payload: + $ref: '#/components/schemas/GenericObjectValvePositionMetadata' + + GenericObjectPumpSpeedMsg: + name: GenericObjectPumpSpeedMsg + title: GenericObject PumpSpeed Metadata + payload: + $ref: '#/components/schemas/GenericObjectPumpSpeedMetadata' + + GenericObjectFanSpeedMsg: + name: GenericObjectFanSpeedMsg + title: GenericObject FanSpeed Metadata + payload: + $ref: '#/components/schemas/GenericObjectFanSpeedMetadata' + + GenericObjectDamperPositionMsg: + name: GenericObjectDamperPositionMsg + title: GenericObject DamperPosition Metadata + payload: + $ref: '#/components/schemas/GenericObjectDamperPositionMetadata' + + GenericObjectAirTemperatureMsg: + name: GenericObjectAirTemperatureMsg + title: GenericObject AirTemperature Metadata + payload: + $ref: '#/components/schemas/GenericObjectAirTemperatureMetadata' + + GenericObjectAirDifferentialPressureMsg: + name: GenericObjectAirDifferentialPressureMsg + title: GenericObject AirDifferentialPressure Metadata + payload: + $ref: '#/components/schemas/GenericObjectAirDifferentialPressureMetadata' + + GenericObjectAirFlowMsg: + name: GenericObjectAirFlowMsg + title: GenericObject AirFlow Metadata + payload: + $ref: '#/components/schemas/GenericObjectAirFlowMetadata' + + GenericObjectAirPressureMsg: + name: GenericObjectAirPressureMsg + title: GenericObject AirPressure Metadata + payload: + $ref: '#/components/schemas/GenericObjectAirPressureMetadata' + + GenericObjectLiquidTemperatureSpRequestMsg: + name: GenericObjectLiquidTemperatureSpRequestMsg + title: GenericObject LiquidTemperatureSpRequest Metadata + payload: + $ref: '#/components/schemas/GenericObjectLiquidTemperatureSpRequestMetadata' + + GenericObjectSoundMsg: + name: GenericObjectSoundMsg + title: GenericObject Sound Metadata + payload: + $ref: '#/components/schemas/GenericObjectSoundMetadata' + + + + schemas: + + # ========================================================================= + # SECTION 1 — Primitive building-block schemas + # + # Every field in an allOf/oneOf is a named $ref — no anonymous schemas. + # Naming convention: + # *Identifiers — the object-identity fields added by a base schema + # *Fields — the pointType-specific fields (pointType enum + engUnit) + # *Mode — a oneOf variant for identifier selection (named-object or associate) + # *CommonFields — optional fields shared across a category + # ========================================================================= + + # ------------------------------------------------------------------------- + # 1a. Shared primitives + # ------------------------------------------------------------------------- + + MetadataBase: + type: object + description: Minimum fields present on every metadata message. + required: + - objectType + - pointType + properties: + objectType: + type: string + description: Canonical object type. Matches the objectType MQTT topic segment. + pointType: + type: string + description: Canonical point type. Matches the pointType MQTT topic segment. + + IntegrationPublisherFields: + type: object + description: > + Fields added to metadata for integration-published points. Integrations + MUST publish values to the exact topic corresponding to `integration` — do not construct it + independently. + required: + - integration + properties: + integration: + type: string + description: Integration identifier responsible for publishing this value. + + + StateTextField: + type: object + description: Required for state/status/alarm points that carry no engineering unit. + required: + - stateText + properties: + stateText: + type: array + description: > + State label mapping. Each entry maps a numeric state value to its + human-readable label (e.g., `[{value: 0, text: "Off"}, {value: 1, text: "On"}]`). + items: + type: object + required: [value, text] + properties: + value: + type: integer + description: Numeric state value. + text: + type: string + description: Human-readable label for this state. + additionalProperties: false + + EquipmentPointEngUnit: + type: object + description: Requires a non-empty engUnit string (mutually exclusive with stateText). + required: + - engUnit + properties: + engUnit: + type: string + description: Engineering unit for the measurement. + + EquipmentMeasurementModeBase: + description: > + Base for equipment measurement metadata. Two independent XOR constraints apply: + - Identifier: named-object mode (objectName + objectId required, + associateId prohibited) XOR associate mode (associateId required, no objectName/objectId). + - Measurement: engUnit required XOR stateText required. + These two constraints are fully independent — all four combinations are valid. + Combines MetadataBase, EquipmentCommonFields, and both independent oneOf constraints. + allOf: + - $ref: '#/components/schemas/MetadataBase' + - $ref: '#/components/schemas/EquipmentCommonFields' + - oneOf: + - $ref: '#/components/schemas/EquipmentNamedObjectMode' + - $ref: '#/components/schemas/EquipmentAssociateMode' + - oneOf: + - $ref: '#/components/schemas/EquipmentPointEngUnit' + - $ref: '#/components/schemas/StateTextField' + + # ------------------------------------------------------------------------- + # 1b. Rack identifier fragment + # ------------------------------------------------------------------------- + + rackLocationIdentifiers: + type: object + description: Rack-specific identifier fields added to all Rack metadata messages. + required: + - rackLocationName + - rackLocationId + properties: + rackLocationName: + type: string + description: Human-readable rack name as defined by the BMS. + rackLocationId: + type: string + description: Stable unique identifier for the rack. + + # ------------------------------------------------------------------------- + # 1c. PowerMeter identifier fragment + # ------------------------------------------------------------------------- + + PowerMeterIdentifiers: + type: object + description: PowerMeter-specific identifier fields added to all PowerMeter metadata messages. + required: + - objectName + - objectId + - servesId + properties: + objectName: + type: string + description: Human-readable name of the electrical device. + objectId: + type: string + description: Stable unique identifier for the electrical device. + servesId: + type: array + items: + type: string + description: List of objectIds of entities served by this power meter. + + # ------------------------------------------------------------------------- + # 1d. Generic Equipment identifier fragments + # ------------------------------------------------------------------------- + + EquipmentCommonFields: + type: object + description: > + Optional fields common to all generic equipment metadata, regardless + of identifier mode. + properties: + processArea: + type: array + items: + type: string + description: > + List of process areas or sub-system locations within the + equipment + + EquipmentNamedObjectMode: + type: object + description: | + **Object Mode**: use when the object is identified directly + by name and ID. + + - `objectName` and `objectId` are **required**. + - `servesId` is **optional** in Named-object mode. + - `associateId` must **not** be present. + + Incompatible with `EquipmentAssociateMode` — validators enforce this via + the parent `oneOf`. + required: + - objectName + - objectId + properties: + objectName: + type: string + description: Human-readable equipment name. + objectId: + type: string + description: Stable unique identifier for the equipment. + servesId: + type: array + items: + type: string + description: > + Optional list of objectIds of entities this equipment serves. Only valid in Named-object mode. + Only valid in Named-object mode — must not appear in Associate mode. + not: + properties: + associateId: + type: string + required: + - associateId + + EquipmentAssociateMode: + type: object + description: | + *Associate Mode*: use when the object is referenced via an + association identifier. + + - `associateId` is **required**. + - `objectName`, `objectId`, and `servesId` must **not** be present. + + Incompatible with `EquipmentNamedObjectMode` — validators enforce this + via the parent `oneOf`. + required: + - associateId + properties: + associateId: + type: string + description: Identifier of the associated entity. + not: + anyOf: + - properties: + objectName: + type: string + required: + - objectName + - properties: + objectId: + type: string + required: + - objectId + - properties: + servesId: + type: array + items: + type: string + required: + - servesId + + EquipmentIntegrationIdentifierFields: + type: object + description: > + Extends EquipmentIntegrationMetadataBase: adds `integration` for integration-published equipment points. + required: + - integration + properties: + integration: + type: string + description: Integration responsible for publishing this value. + + + # ------------------------------------------------------------------------- + # 1e. System identifier fragments + # ------------------------------------------------------------------------- + + SystemIntegrationIdentifiers: + type: object + description: > + Required `integration` field for integration-published System + metadata messages. + required: + - integration + properties: + integration: + type: string + description: Integration identifier. + + # ========================================================================= + # SECTION 2 — Composed base schemas + # + # Each allOf element is a named $ref — no anonymous schemas. + # ========================================================================= + + RackMetadataBase: + description: > + Composed base for all BMS-published Rack metadata messages. + Combines MetadataBase with rackLocationIdentifiers. + allOf: + - $ref: '#/components/schemas/MetadataBase' + - $ref: '#/components/schemas/rackLocationIdentifiers' + + RackIntegrationMetadataBase: + description: > + Composed base for Integration-published Rack metadata messages. + Extends RackMetadataBase with IntegrationPublisherFields. + allOf: + - $ref: '#/components/schemas/RackMetadataBase' + - $ref: '#/components/schemas/IntegrationPublisherFields' + + PowerMeterMetadataBase: + description: > + Composed base for all PowerMeter metadata messages. + Combines MetadataBase with PowerMeterIdentifiers. + allOf: + - $ref: '#/components/schemas/MetadataBase' + - $ref: '#/components/schemas/PowerMeterIdentifiers' + + EquipmentMetadataBase: + description: | + Composed base for all generic equipment metadata. + + Combines MetadataBase + EquipmentCommonFields + exactly one identifier + mode from the `oneOf`: + - **ObjectMode**: objectName + objectId required, + servesId optional, associateId prohibited. + - **Associate Mode**: associateId required, all name/id/serves + fields prohibited. + allOf: + - $ref: '#/components/schemas/MetadataBase' + - $ref: '#/components/schemas/EquipmentCommonFields' + - oneOf: + - $ref: '#/components/schemas/EquipmentNamedObjectMode' + - $ref: '#/components/schemas/EquipmentAssociateMode' + + EquipmentIntegrationMetadataBase: + description: > + Extends EquipmentMetadataBase for integration-published equipment points. + Adds EquipmentIntegrationIdentifierFields (integration required). + allOf: + - $ref: '#/components/schemas/EquipmentMetadataBase' + - $ref: '#/components/schemas/EquipmentIntegrationIdentifierFields' + + SystemIntegrationMetadataBase: + description: > + Composed base for Integration-published System metadata messages. + Combines MetadataBase with SystemIntegrationIdentifiers. + allOf: + - $ref: '#/components/schemas/MetadataBase' + - $ref: '#/components/schemas/SystemIntegrationIdentifiers' + + + # ========================================================================= + # SECTION 3 — PointType enum schemas (for code generation / validation) + # ========================================================================= + + RackBmsPointType: + type: string + description: Valid pointType values for BMS-published Rack points. + enum: + - RackLiquidSupplyTemperature + - RackLiquidReturnTemperature + - RackLiquidFlow + - RackLiquidDifferentialPressure + - RackLiquidDifferentialPressureSp + - RackControlValvePosition + - RackPower + - RackLeakDetect + - RackLeakSensorFault + - RackLiquidIsolationStatus + - RackElectricalIsolationStatus + + RackIntegrationPointType: + type: string + description: Valid pointType values for Integration-published Rack points. + enum: + - RackLeakDetectTray + - RackLiquidIsolationRequest + - RackElectricalIsolationRequest + + PowerMeterPointType: + type: string + description: Valid pointType values for PowerMeter points (all BMS-published). + enum: + - Voltage + - PowerFactor + - Frequency + - ApparentPower + - ActivePower + - Current + - CurrentLimit + - PhaseCurrent + + EquipmentBmsPointType: + type: string + description: Valid pointType values for BMS-published generic equipment points. + enum: + - LiquidTemperature + - LiquidDifferentialPressure + - LiquidFlow + - LiquidPressure + - Status + - Available + - ValvePosition + - PumpSpeed + - FanSpeed + - DamperPosition + - AirTemperature + - AirDifferentialPressure + - AirFlow + - AirPressure + - Sound + - GenericPoint + + EquipmentIntegrationPointType: + type: string + description: Valid pointType values for Integration-published generic equipment points. + enum: + - LiquidTemperatureSpRequest + + EquipmentObjectType: + type: string + description: Valid objectType values for generic equipment channels. + enum: + - CDU + - CoolingTower + - HX + - CRAH + - CRAC + - AHU + - Chiller + - BESS + - UPS + - ATS + - Generator + - Shunt + - Breaker + - Valve + - Pump + - Fan + - Damper + - Sensor + - Tank + - GenericObject + + SystemBmsPointType: + type: string + description: Valid pointType values for BMS-published System points. + enum: + - HeartbeatTimestampBms + - HeartbeatEchoBms + - Status + - Available + - GenericPoint + + SystemIntegrationPointType: + type: string + description: Valid pointType values for Integration-published System points. + enum: + - HeartbeatTimestampIntegration + - HeartbeatEchoIntegration + + + # ========================================================================= + # SECTION 4 — Per-pointType field fragments + # + # Each fragment captures only what differs per pointType: + # a pointType enum constraint and (where applicable) an engUnit enum. + # These are referenced as the second allOf element in each *Metadata schema. + # ========================================================================= + + # --- Rack fields --------------------------------------------------------- + + RackLiquidSupplyTemperatureFields: + type: object + required: [engUnit] + properties: + pointType: + type: string + enum: [RackLiquidSupplyTemperature] + objectType: + type: string + enum: [Rack] + engUnit: + type: string + enum: [C] + + RackLiquidReturnTemperatureFields: + type: object + required: [engUnit] + properties: + pointType: + type: string + enum: [RackLiquidReturnTemperature] + objectType: + type: string + enum: [Rack] + engUnit: + type: string + enum: [C] + + RackLiquidFlowFields: + type: object + required: [engUnit] + properties: + pointType: + type: string + enum: [RackLiquidFlow] + objectType: + type: string + enum: [Rack] + engUnit: + type: string + enum: [LPM] + + RackLiquidDifferentialPressureFields: + type: object + required: [engUnit] + properties: + pointType: + type: string + enum: [RackLiquidDifferentialPressure] + objectType: + type: string + enum: [Rack] + engUnit: + type: string + enum: [kPa] + + RackControlValvePositionFields: + type: object + required: [engUnit] + properties: + pointType: + type: string + enum: [RackControlValvePosition] + objectType: + type: string + enum: [Rack] + engUnit: + type: string + enum: ['%'] + + RackPowerFields: + type: object + required: [engUnit] + properties: + pointType: + type: string + enum: [RackPower] + objectType: + type: string + enum: [Rack] + engUnit: + type: string + enum: [kW] + + RackLeakDetectFields: + type: object + description: '0 = No Leak, 1 = Leak. No engUnit.' + required: + - stateText + properties: + pointType: + type: string + enum: [RackLeakDetect] + objectType: + type: string + enum: [Rack] + stateText: + type: array + description: > + State label mapping for leak detection. + items: + type: object + required: [value, text] + properties: + value: + type: integer + description: Numeric state value. + text: + type: string + description: Human-readable label for this state. + additionalProperties: false + examples: + - + - value: 0 + text: "NoLeak" + - value: 1 + text: "Leak" + + RackLeakSensorFaultFields: + type: object + description: '0 = No Fault, 1 = Fault. No engUnit.' + required: + - stateText + properties: + pointType: + type: string + enum: [RackLeakSensorFault] + objectType: + type: string + enum: [Rack] + stateText: + type: array + description: > + State label mapping for leak sensor fault status. + items: + type: object + required: [value, text] + properties: + value: + type: integer + description: Numeric state value. + text: + type: string + description: Human-readable label for this state. + additionalProperties: false + examples: + - + - value: 0 + text: "NoFault" + - value: 1 + text: "Fault" + + RackLiquidIsolationStatusFields: + type: object + description: '0=NotIsolated, 1=Isolated. No engUnit.' + required: + - stateText + properties: + pointType: + type: string + enum: [RackLiquidIsolationStatus] + objectType: + type: string + enum: [Rack] + stateText: + type: array + description: > + State label mapping for liquid isolation status. + items: + type: object + required: [value, text] + properties: + value: + type: integer + description: Numeric state value. + text: + type: string + description: Human-readable label for this state. + additionalProperties: false + examples: + - + - value: 0 + text: "NotIsolated" + - value: 1 + text: "Isolated" + + RackElectricalIsolationStatusFields: + type: object + description: '0 = NotIsolated, 1 = Isolated. No engUnit.' + required: + - stateText + properties: + pointType: + type: string + enum: [RackElectricalIsolationStatus] + objectType: + type: string + enum: [Rack] + stateText: + type: array + description: > + State label mapping for electrical isolation status. + items: + type: object + required: [value, text] + properties: + value: + type: integer + description: Numeric state value. + text: + type: string + description: Human-readable label for this state. + additionalProperties: false + examples: + - + - value: 0 + text: "NotIsolated" + - value: 1 + text: "Isolated" + + RackLeakDetectTrayFields: + type: object + description: '0 = No Leak, 1 = Leak (tray sensor). No engUnit. Integration-published value.' + required: + - stateText + properties: + pointType: + type: string + enum: [RackLeakDetectTray] + objectType: + type: string + enum: [Rack] + stateText: + type: array + description: > + State label mapping for tray leak detection. + items: + type: object + required: [value, text] + properties: + value: + type: integer + description: Numeric state value. + text: + type: string + description: Human-readable label for this state. + additionalProperties: false + examples: + - + - value: 0 + text: "NoLeak" + - value: 1 + text: "Leak" + + RackLiquidIsolationRequestFields: + type: object + description: '0 = Not Requested, 1 = Requested, -1 = Unknown. Integration-published value.' + required: + - stateText + properties: + pointType: + type: string + enum: [RackLiquidIsolationRequest] + objectType: + type: string + enum: [Rack] + stateText: + type: array + description: > + State label mapping for liquid isolation request. + items: + type: object + required: [value, text] + properties: + value: + type: integer + description: Numeric state value. + text: + type: string + description: Human-readable label for this state. + additionalProperties: false + examples: + - + - value: 0 + text: "NoIsolationRequested" + - value: 1 + text: "IsolationRequested" + + RackElectricalIsolationRequestFields: + type: object + description: '0 = Not Requested, 1 = Requested. Integration-published value.' + required: + - stateText + properties: + pointType: + type: string + enum: [RackElectricalIsolationRequest] + objectType: + type: string + enum: [Rack] + stateText: + type: array + description: > + State label mapping for electrical isolation request. + items: + type: object + required: [value, text] + properties: + value: + type: integer + description: Numeric state value. + text: + type: string + description: Human-readable label for this state. + additionalProperties: false + examples: + - + - value: 0 + text: "NoIsolationRequested" + - value: 1 + text: "IsolationRequested" + + # --- PowerMeter fields --------------------------------------------------- + + VoltageFields: + type: object + required: [engUnit] + properties: + pointType: + type: string + enum: [Voltage] + objectType: + type: string + enum: [PowerMeter] + engUnit: + type: string + #enum: [V] + + PowerFactorFields: + type: object + description: Dimensionless 0–1. engUnit not required. + properties: + pointType: + type: string + enum: [PowerFactor] + objectType: + type: string + enum: [PowerMeter] + + FrequencyFields: + type: object + required: [engUnit] + properties: + pointType: + type: string + enum: [Frequency] + objectType: + type: string + enum: [PowerMeter] + engUnit: + type: string + #enum: [Hz] + + ApparentPowerFields: + type: object + required: [engUnit] + properties: + pointType: + type: string + enum: [ApparentPower] + objectType: + type: string + enum: [PowerMeter] + engUnit: + type: string + #enum: [kVA] + + ActivePowerFields: + type: object + required: [engUnit] + properties: + pointType: + type: string + enum: [ActivePower] + objectType: + type: string + enum: [PowerMeter] + engUnit: + type: string + #enum: [kW] + + CurrentFields: + type: object + required: [engUnit] + properties: + pointType: + type: string + enum: [Current] + objectType: + type: string + enum: [PowerMeter] + engUnit: + type: string + #enum: [A] + + CurrentLimitFields: + type: object + required: [engUnit] + properties: + pointType: + type: string + enum: [CurrentLimit] + objectType: + type: string + enum: [PowerMeter] + engUnit: + type: string + #enum: [A] + + PhaseCurrentFields: + type: object + description: > + PhaseCurrent additionally requires `phase` to identify which electrical + phase. Accepts letter form (A, B, C) or numeric form (1, 2, 3); both + are valid representations of the same three-phase system. + 3 metadata messages per meter, one per phase. + required: + - engUnit + - phase + properties: + pointType: + type: string + enum: [PhaseCurrent] + objectType: + type: string + enum: [PowerMeter] + engUnit: + type: string + #enum: [A] + phase: + type: string + enum: [A, B, C, "1", "2", "3"] + description: > + Electrical phase identifier. Letter form (A/B/C) and numeric form + (1/2/3) are both accepted to align with publisher conventions. + + # --- Generic Equipment fields -------------------------------------------- + # engUnit enums reflect common industry standards. + # Verify with BMS configuration for site-specific values. + + LiquidTemperatureFields: + type: object + required: [engUnit] + properties: + pointType: + type: string + enum: [LiquidTemperature] + engUnit: + type: string + enum: [C] + isSetpoint: + type: boolean + description: > + Optional. When true, indicates this point is a setpoint (a target + value written to control equipment behavior). + + LiquidDifferentialPressureFields: + type: object + description: > + Measurement fields for LiquidDifferentialPressure. Typical engUnit: kPa. + The identifier (named-object XOR associate) and measurement (engUnit XOR stateText) + constraints are independent and enforced by EquipmentMeasurementModeBase. + properties: + pointType: + type: string + enum: [LiquidDifferentialPressure] + isSetpoint: + type: boolean + description: > + Optional. When true, indicates this point is a setpoint (a target + value written to control equipment behavior). + + LiquidFlowFields: + type: object + description: > + Measurement fields for LiquidFlow. Typical engUnit: LPM. + The identifier (named-object XOR associate) and measurement (engUnit XOR stateText) + constraints are independent and enforced by EquipmentMeasurementModeBase. + properties: + pointType: + type: string + enum: [LiquidFlow] + isSetpoint: + type: boolean + description: > + Optional. When true, indicates this point is a setpoint (a target + value written to control equipment behavior). + + LiquidPressureFields: + type: object + description: > + Measurement fields for LiquidPressure. Typical engUnit: kPa. + The identifier (named-object XOR associate) and measurement (engUnit XOR stateText) + constraints are independent and enforced by EquipmentMeasurementModeBase. + properties: + pointType: + type: string + enum: [LiquidPressure] + isSetpoint: + type: boolean + description: > + Optional. When true, indicates this point is a setpoint (a target + value written to control equipment behavior). + + StatusFields: + type: object + required: + - stateText + properties: + pointType: + type: string + enum: [Status] + integration: + type: string + description: > + Optional integration identifier. When present, this integration + is responsible for publishing the value for this point. + stateText: + type: array + description: > + State label mapping for operating status (values vary by equipment vendor). + items: + type: object + required: [value, text] + properties: + value: + type: integer + description: Numeric state value. + text: + type: string + description: Human-readable label for this state. + additionalProperties: false + examples: + - + - value: 0 + text: "NotOperating" + - value: 1 + text: "Operating" + + AvailableFields: + type: object + required: + - stateText + properties: + pointType: + type: string + enum: [Available] + integration: + type: string + description: > + Optional integration identifier. When present, this integration + is responsible for publishing the value for this point. + stateText: + type: array + description: > + State label mapping for availability status (values vary by equipment vendor). + items: + type: object + required: [value, text] + properties: + value: + type: integer + description: Numeric state value. + text: + type: string + description: Human-readable label for this state. + additionalProperties: false + examples: + - + - value: 0 + text: "NotAvailable" + - value: 1 + text: "Available" + + ValvePositionFields: + type: object + description: > + Measurement fields for ValvePosition. Typical engUnit: %. + The identifier (named-object XOR associate) and measurement (engUnit XOR stateText) + constraints are independent and enforced by EquipmentMeasurementModeBase. + properties: + pointType: + type: string + enum: [ValvePosition] + + PumpSpeedFields: + type: object + description: > + Measurement fields for PumpSpeed. Typical engUnit: RPM. + The identifier (named-object XOR associate) and measurement (engUnit XOR stateText) + constraints are independent and enforced by EquipmentMeasurementModeBase. + properties: + pointType: + type: string + enum: [PumpSpeed] + + FanSpeedFields: + type: object + description: > + Measurement fields for FanSpeed. Typical engUnit: RPM. + The identifier (named-object XOR associate) and measurement (engUnit XOR stateText) + constraints are independent and enforced by EquipmentMeasurementModeBase. + properties: + pointType: + type: string + enum: [FanSpeed] + + DamperPositionFields: + type: object + description: > + Measurement fields for DamperPosition. Typical engUnit: %. + The identifier (named-object XOR associate) and measurement (engUnit XOR stateText) + constraints are independent and enforced by EquipmentMeasurementModeBase. + properties: + pointType: + type: string + enum: [DamperPosition] + + AirTemperatureFields: + type: object + required: [engUnit] + properties: + pointType: + type: string + enum: [AirTemperature] + engUnit: + type: string + enum: [C] + isSetpoint: + type: boolean + description: > + Optional. When true, indicates this point is a setpoint (a target + value written to control equipment behavior). + + AirDifferentialPressureFields: + type: object + description: > + Measurement fields for AirDifferentialPressure. Typical engUnit: Pa. + The identifier (named-object XOR associate) and measurement (engUnit XOR stateText) + constraints are independent and enforced by EquipmentMeasurementModeBase. + properties: + pointType: + type: string + enum: [AirDifferentialPressure] + isSetpoint: + type: boolean + description: > + Optional. When true, indicates this point is a setpoint (a target + value written to control equipment behavior). + + AirFlowFields: + type: object + description: > + Measurement fields for AirFlow. Typical engUnit: CFM. + The identifier (named-object XOR associate) and measurement (engUnit XOR stateText) + constraints are independent and enforced by EquipmentMeasurementModeBase. + properties: + pointType: + type: string + enum: [AirFlow] + isSetpoint: + type: boolean + description: > + Optional. When true, indicates this point is a setpoint (a target + value written to control equipment behavior). + + AirPressureFields: + type: object + description: > + Measurement fields for AirPressure. Typical engUnit: Pa. + The identifier (named-object XOR associate) and measurement (engUnit XOR stateText) + constraints are independent and enforced by EquipmentMeasurementModeBase. + properties: + pointType: + type: string + enum: [AirPressure] + isSetpoint: + type: boolean + description: > + Optional. When true, indicates this point is a setpoint (a target + value written to control equipment behavior). + LeakDetectFields: + type: object + description: '0 = No Leak, 1 = Leak. No engUnit.' + required: + - stateText + properties: + pointType: + type: string + enum: [LeakDetect] + stateText: + type: array + description: > + State label mapping for leak detection. + items: + type: object + required: [value, text] + properties: + value: + type: integer + description: Numeric state value. + text: + type: string + description: Human-readable label for this state. + additionalProperties: false + examples: + - + - value: 0 + text: "NoLeak" + - value: 1 + text: "Leak" + + AirRelativeHumidityFields: + type: object + description: > + Measurement fields for AirRelativeHumidity. Typical engUnit: %RH. + The identifier (named-object XOR associate) and measurement (engUnit XOR stateText) + constraints are independent and enforced by EquipmentMeasurementModeBase. + properties: + pointType: + type: string + enum: [AirRelativeHumidity] + isSetpoint: + type: boolean + description: > + Optional. When true, indicates this point is a setpoint (a target + value written to control equipment behavior). + + + LiquidTemperatureSpRequestFields: + type: object + description: > + Setpoint request written by an integration (e.g., MEPAI) to a CDU. + BMS publishes metadata; the integration publishes the value to respective value topic namespace identified by `integration` + required: [engUnit] + properties: + pointType: + type: string + enum: [LiquidTemperatureSpRequest] + objectType: + type: string + enum: [CDU] + engUnit: + type: string + enum: [C] + + + GenericObjectLiquidTemperatureSpRequestFields: + type: object + description: > + Setpoint request for a GenericObject. BMS publishes metadata; + the integration publishes the value to the derived topic. + required: [engUnit] + properties: + pointType: + type: string + enum: [LiquidTemperatureSpRequest] + objectType: + type: string + enum: [GenericObject] + engUnit: + type: string + enum: [C] + + SoundFields: + type: object + required: [engUnit] + properties: + pointType: + type: string + enum: [Sound] + engUnit: + type: string + # enum: [dB] + + # --- System / Heartbeat point-type fields -------------------------------- + + HeartbeatTimestampBmsFields: + type: object + description: > + BMS-published heartbeat timestamp. The BMS publishes its own + timestamp every 10 s; one instance globally, consumed by all + connected integrations. `objectName` and `objectId` identify the + BMS itself (the publisher). The `integration` field is NOT used + on this point — `integration` is reserved for value-publisher + identity, and the BMS is the publisher here. `scope` optionally + identifies which MQTT topics this heartbeat covers. + required: + - objectName + - objectId + properties: + pointType: + type: string + enum: [HeartbeatTimestampBms] + objectName: + type: string + description: Human-readable name of the BMS publishing this heartbeat. + objectId: + type: string + description: > + Stable identifier for the BMS publishing this heartbeat + (e.g., `"BMS"`). One instance per BMS. + scope: + type: string + description: Identifies which MQTT topics this heartbeat covers. + + HeartbeatEchoBmsFields: + type: object + description: > + BMS echoes back the timestamp received from a specific integration. + Published by the BMS (one instance per connected integration). The + integration whose timestamp is being echoed is identified by + `objectName` and `objectId` — by convention, `objectId` matches + the same string used as that integration's `integration` + metadata value on its other points (e.g., `objectId: "MEPAI1"`). + The `integration` field is intentionally NOT present on this + point: `integration` denotes the value-publisher elsewhere in + the spec, and the BMS is the publisher here. The integration + being echoed is encoded in `objectId`, not in `integration`. + `scope` optionally identifies which BMS MQTT-client/topic + namespace this echo is associated with — used when the BMS + runs multiple MQTT clients connected to DSX Exchange. + required: + - objectName + - objectId + properties: + pointType: + type: string + enum: [HeartbeatEchoBms] + objectName: + type: string + description: > + Human-readable name of the integration whose timestamp is + being echoed. + objectId: + type: string + description: > + Stable identifier of the integration whose timestamp is + being echoed (e.g., `"MEPAI1"`). Matches that integration's + `integration` metadata value on its other points. + scope: + type: string + description: > + Optional. Identifies which BMS MQTT-client/topic namespace + this echo is associated with. + + HeartbeatTimestampIntegrationFields: + type: object + description: > + Integration-published heartbeat timestamp. Each connected + integration publishes its own timestamp every 10 s; one instance + per integration. `objectName` and `objectId` identify the + integration publishing this heartbeat. By convention, `objectId` + matches the same string used as this integration's `integration` + metadata value on its other points (e.g., `objectId: "MEPAI1"` with + `integration: "MEPAI1"`). The `integration` field is required via + the integration metadata base and drives the value topic. + required: + - objectName + - objectId + properties: + pointType: + type: string + enum: [HeartbeatTimestampIntegration] + objectName: + type: string + description: > + Human-readable name of the integration publishing this + heartbeat. + objectId: + type: string + description: > + Stable identifier of the integration publishing this + heartbeat (e.g., `"MEPAI1"`). By convention, matches the + `integration` metadata value used by this integration on + its other points. + + HeartbeatEchoIntegrationFields: + type: object + description: > + Integration echoes the BMS timestamp. Each integration publishes + its own echo of the BMS timestamp, allowing the BMS to confirm + round-trip with that specific integration (one instance per + connected integration). The BMS whose timestamp is being echoed + is identified by `objectName` and `objectId` + (e.g., `objectId: "BMS"`). The `integration` field is required via + the integration metadata base and drives the value topic. + required: + - objectName + - objectId + properties: + pointType: + type: string + enum: [HeartbeatEchoIntegration] + objectName: + type: string + description: > + Human-readable name of the BMS whose timestamp is being echoed. + objectId: + type: string + description: > + Stable identifier of the BMS whose timestamp is being + echoed (e.g., `"BMS"`). + + + # ========================================================================= + # SECTION 5 — Fully composed per-pointType metadata schemas + # + # Each schema = base $ref + fields $ref, both named. + # No anonymous schemas anywhere in the allOf arrays. + # ========================================================================= + + # --- Rack — BMS published ------------------------------------------------ + + RackLiquidSupplyTemperatureMetadata: + allOf: + - $ref: '#/components/schemas/RackMetadataBase' + - $ref: '#/components/schemas/RackLiquidSupplyTemperatureFields' + unevaluatedProperties: false + + RackLiquidReturnTemperatureMetadata: + allOf: + - $ref: '#/components/schemas/RackMetadataBase' + - $ref: '#/components/schemas/RackLiquidReturnTemperatureFields' + unevaluatedProperties: false + + RackLiquidFlowMetadata: + allOf: + - $ref: '#/components/schemas/RackMetadataBase' + - $ref: '#/components/schemas/RackLiquidFlowFields' + unevaluatedProperties: false + + RackLiquidDifferentialPressureMetadata: + allOf: + - $ref: '#/components/schemas/RackMetadataBase' + - $ref: '#/components/schemas/RackLiquidDifferentialPressureFields' + unevaluatedProperties: false + + RackLiquidDifferentialPressureSpFields: + type: object + required: [engUnit] + properties: + pointType: + type: string + enum: [RackLiquidDifferentialPressureSp] + objectType: + type: string + enum: [Rack] + engUnit: + type: string + enum: [kPa] + isSetpoint: + type: boolean + description: > + Optional inclusion. Indicates this point is a target value written to control equipment behavior. + + RackLiquidDifferentialPressureSpMetadata: + allOf: + - $ref: '#/components/schemas/RackMetadataBase' + - $ref: '#/components/schemas/RackLiquidDifferentialPressureSpFields' + unevaluatedProperties: false + + RackControlValvePositionMetadata: + allOf: + - $ref: '#/components/schemas/RackMetadataBase' + - $ref: '#/components/schemas/RackControlValvePositionFields' + unevaluatedProperties: false + + RackPowerMetadata: + allOf: + - $ref: '#/components/schemas/RackMetadataBase' + - $ref: '#/components/schemas/RackPowerFields' + unevaluatedProperties: false + + RackLeakDetectMetadata: + allOf: + - $ref: '#/components/schemas/RackMetadataBase' + - $ref: '#/components/schemas/RackLeakDetectFields' + unevaluatedProperties: false + + RackLeakSensorFaultMetadata: + allOf: + - $ref: '#/components/schemas/RackMetadataBase' + - $ref: '#/components/schemas/RackLeakSensorFaultFields' + unevaluatedProperties: false + + RackLiquidIsolationStatusMetadata: + allOf: + - $ref: '#/components/schemas/RackMetadataBase' + - $ref: '#/components/schemas/RackLiquidIsolationStatusFields' + unevaluatedProperties: false + + RackElectricalIsolationStatusMetadata: + allOf: + - $ref: '#/components/schemas/RackMetadataBase' + - $ref: '#/components/schemas/RackElectricalIsolationStatusFields' + unevaluatedProperties: false + + # --- Rack — Integration published ---------------------------------------- + + RackLeakDetectTrayMetadata: + allOf: + - $ref: '#/components/schemas/RackIntegrationMetadataBase' + - $ref: '#/components/schemas/RackLeakDetectTrayFields' + unevaluatedProperties: false + + RackLiquidIsolationRequestMetadata: + allOf: + - $ref: '#/components/schemas/RackIntegrationMetadataBase' + - $ref: '#/components/schemas/RackLiquidIsolationRequestFields' + unevaluatedProperties: false + + RackElectricalIsolationRequestMetadata: + allOf: + - $ref: '#/components/schemas/RackIntegrationMetadataBase' + - $ref: '#/components/schemas/RackElectricalIsolationRequestFields' + unevaluatedProperties: false + + # --- PowerMeter ---------------------------------------------------------- + + PowerMeterVoltageMetadata: + allOf: + - $ref: '#/components/schemas/PowerMeterMetadataBase' + - $ref: '#/components/schemas/VoltageFields' + unevaluatedProperties: false + + PowerMeterPowerFactorMetadata: + allOf: + - $ref: '#/components/schemas/PowerMeterMetadataBase' + - $ref: '#/components/schemas/PowerFactorFields' + unevaluatedProperties: false + + PowerMeterFrequencyMetadata: + allOf: + - $ref: '#/components/schemas/PowerMeterMetadataBase' + - $ref: '#/components/schemas/FrequencyFields' + unevaluatedProperties: false + + PowerMeterApparentPowerMetadata: + allOf: + - $ref: '#/components/schemas/PowerMeterMetadataBase' + - $ref: '#/components/schemas/ApparentPowerFields' + unevaluatedProperties: false + + PowerMeterActivePowerMetadata: + allOf: + - $ref: '#/components/schemas/PowerMeterMetadataBase' + - $ref: '#/components/schemas/ActivePowerFields' + unevaluatedProperties: false + + PowerMeterCurrentMetadata: + allOf: + - $ref: '#/components/schemas/PowerMeterMetadataBase' + - $ref: '#/components/schemas/CurrentFields' + unevaluatedProperties: false + + PowerMeterCurrentLimitMetadata: + allOf: + - $ref: '#/components/schemas/PowerMeterMetadataBase' + - $ref: '#/components/schemas/CurrentLimitFields' + unevaluatedProperties: false + + PowerMeterPhaseCurrentMetadata: + allOf: + - $ref: '#/components/schemas/PowerMeterMetadataBase' + - $ref: '#/components/schemas/PhaseCurrentFields' + unevaluatedProperties: false + + # --- Generic Equipment — BMS published ----------------------------------- + + LiquidTemperatureMetadata: + allOf: + - $ref: '#/components/schemas/EquipmentMetadataBase' + - $ref: '#/components/schemas/LiquidTemperatureFields' + unevaluatedProperties: false + + LiquidDifferentialPressureMetadata: + allOf: + - $ref: '#/components/schemas/EquipmentMeasurementModeBase' + - $ref: '#/components/schemas/LiquidDifferentialPressureFields' + unevaluatedProperties: false + + LiquidFlowMetadata: + allOf: + - $ref: '#/components/schemas/EquipmentMeasurementModeBase' + - $ref: '#/components/schemas/LiquidFlowFields' + unevaluatedProperties: false + + LiquidPressureMetadata: + allOf: + - $ref: '#/components/schemas/EquipmentMeasurementModeBase' + - $ref: '#/components/schemas/LiquidPressureFields' + unevaluatedProperties: false + + StatusMetadata: + allOf: + - $ref: '#/components/schemas/EquipmentMetadataBase' + - $ref: '#/components/schemas/StatusFields' + unevaluatedProperties: false + + AvailableMetadata: + allOf: + - $ref: '#/components/schemas/EquipmentMetadataBase' + - $ref: '#/components/schemas/AvailableFields' + unevaluatedProperties: false + + ValvePositionMetadata: + allOf: + - $ref: '#/components/schemas/EquipmentMeasurementModeBase' + - $ref: '#/components/schemas/ValvePositionFields' + unevaluatedProperties: false + + PumpSpeedMetadata: + allOf: + - $ref: '#/components/schemas/EquipmentMeasurementModeBase' + - $ref: '#/components/schemas/PumpSpeedFields' + unevaluatedProperties: false + + FanSpeedMetadata: + allOf: + - $ref: '#/components/schemas/EquipmentMeasurementModeBase' + - $ref: '#/components/schemas/FanSpeedFields' + unevaluatedProperties: false + + DamperPositionMetadata: + allOf: + - $ref: '#/components/schemas/EquipmentMeasurementModeBase' + - $ref: '#/components/schemas/DamperPositionFields' + unevaluatedProperties: false + + AirTemperatureMetadata: + allOf: + - $ref: '#/components/schemas/EquipmentMetadataBase' + - $ref: '#/components/schemas/AirTemperatureFields' + unevaluatedProperties: false + + AirDifferentialPressureMetadata: + allOf: + - $ref: '#/components/schemas/EquipmentMeasurementModeBase' + - $ref: '#/components/schemas/AirDifferentialPressureFields' + unevaluatedProperties: false + + AirFlowMetadata: + allOf: + - $ref: '#/components/schemas/EquipmentMeasurementModeBase' + - $ref: '#/components/schemas/AirFlowFields' + unevaluatedProperties: false + + AirPressureMetadata: + allOf: + - $ref: '#/components/schemas/EquipmentMeasurementModeBase' + - $ref: '#/components/schemas/AirPressureFields' + unevaluatedProperties: false + + SoundMetadata: + allOf: + - $ref: '#/components/schemas/EquipmentMetadataBase' + - $ref: '#/components/schemas/SoundFields' + unevaluatedProperties: false + + # --- Generic Equipment — Integration published --------------------------- + + LiquidTemperatureSpRequestMetadata: + allOf: + - $ref: '#/components/schemas/EquipmentIntegrationMetadataBase' + - $ref: '#/components/schemas/LiquidTemperatureSpRequestFields' + unevaluatedProperties: false + + # --- System -------------------------------------------------------------- + + SystemHeartbeatTimestampBmsMetadata: + allOf: + - $ref: '#/components/schemas/MetadataBase' + - $ref: '#/components/schemas/HeartbeatTimestampBmsFields' + unevaluatedProperties: false + + SystemHeartbeatEchoBmsMetadata: + allOf: + - $ref: '#/components/schemas/MetadataBase' + - $ref: '#/components/schemas/HeartbeatEchoBmsFields' + unevaluatedProperties: false + + SystemHeartbeatTimestampIntegrationMetadata: + allOf: + - $ref: '#/components/schemas/SystemIntegrationMetadataBase' + - $ref: '#/components/schemas/HeartbeatTimestampIntegrationFields' + unevaluatedProperties: false + + SystemHeartbeatEchoIntegrationMetadata: + allOf: + - $ref: '#/components/schemas/SystemIntegrationMetadataBase' + - $ref: '#/components/schemas/HeartbeatEchoIntegrationFields' + unevaluatedProperties: false + + SystemObjectTypeFields: + type: object + properties: + objectType: + type: string + enum: [System] + + SystemStatusMetadata: + allOf: + - $ref: '#/components/schemas/EquipmentMetadataBase' + - $ref: '#/components/schemas/StatusFields' + - $ref: '#/components/schemas/SystemObjectTypeFields' + unevaluatedProperties: false + + SystemAvailableMetadata: + allOf: + - $ref: '#/components/schemas/EquipmentMetadataBase' + - $ref: '#/components/schemas/AvailableFields' + - $ref: '#/components/schemas/SystemObjectTypeFields' + unevaluatedProperties: false + + # --- Per-objectType objectType field fragments ------------------------- + BESSObjectTypeFields: + type: object + properties: + objectType: + type: string + enum: [BESS] + + UPSObjectTypeFields: + type: object + properties: + objectType: + type: string + enum: [UPS] + + ATSObjectTypeFields: + type: object + properties: + objectType: + type: string + enum: [ATS] + + GeneratorObjectTypeFields: + type: object + properties: + objectType: + type: string + enum: [Generator] + + ShuntObjectTypeFields: + type: object + properties: + objectType: + type: string + enum: [Shunt] + + BreakerObjectTypeFields: + type: object + properties: + objectType: + type: string + enum: [Breaker] + + CDUObjectTypeFields: + type: object + properties: + objectType: + type: string + enum: [CDU] + + CoolingTowerObjectTypeFields: + type: object + properties: + objectType: + type: string + enum: [CoolingTower] + + HXObjectTypeFields: + type: object + properties: + objectType: + type: string + enum: [HX] + + CRAHObjectTypeFields: + type: object + properties: + objectType: + type: string + enum: [CRAH] + + CRACObjectTypeFields: + type: object + properties: + objectType: + type: string + enum: [CRAC] + + AHUObjectTypeFields: + type: object + properties: + objectType: + type: string + enum: [AHU] + + ChillerObjectTypeFields: + type: object + properties: + objectType: + type: string + enum: [Chiller] + + ValveObjectTypeFields: + type: object + properties: + objectType: + type: string + enum: [Valve] + + PumpObjectTypeFields: + type: object + properties: + objectType: + type: string + enum: [Pump] + + FanObjectTypeFields: + type: object + properties: + objectType: + type: string + enum: [Fan] + + DamperObjectTypeFields: + type: object + properties: + objectType: + type: string + enum: [Damper] + + SensorObjectTypeFields: + type: object + properties: + objectType: + type: string + enum: [Sensor] + + TankObjectTypeFields: + type: object + properties: + objectType: + type: string + enum: [Tank] + + GenericObjectObjectTypeFields: + type: object + properties: + objectType: + type: string + enum: [GenericObject] + + + # ========================================================================= + # Per-objectType metadata schemas (objectType constraint for examples) + # ========================================================================= + + BESSStatusMetadata: + allOf: + - $ref: '#/components/schemas/EquipmentMetadataBase' + - $ref: '#/components/schemas/StatusFields' + - $ref: '#/components/schemas/BESSObjectTypeFields' + unevaluatedProperties: false + + BESSAvailableMetadata: + allOf: + - $ref: '#/components/schemas/EquipmentMetadataBase' + - $ref: '#/components/schemas/AvailableFields' + - $ref: '#/components/schemas/BESSObjectTypeFields' + unevaluatedProperties: false + + UPSStatusMetadata: + allOf: + - $ref: '#/components/schemas/EquipmentMetadataBase' + - $ref: '#/components/schemas/StatusFields' + - $ref: '#/components/schemas/UPSObjectTypeFields' + unevaluatedProperties: false + + UPSAvailableMetadata: + allOf: + - $ref: '#/components/schemas/EquipmentMetadataBase' + - $ref: '#/components/schemas/AvailableFields' + - $ref: '#/components/schemas/UPSObjectTypeFields' + unevaluatedProperties: false + + ATSStatusMetadata: + allOf: + - $ref: '#/components/schemas/EquipmentMetadataBase' + - $ref: '#/components/schemas/StatusFields' + - $ref: '#/components/schemas/ATSObjectTypeFields' + unevaluatedProperties: false + + ATSAvailableMetadata: + allOf: + - $ref: '#/components/schemas/EquipmentMetadataBase' + - $ref: '#/components/schemas/AvailableFields' + - $ref: '#/components/schemas/ATSObjectTypeFields' + unevaluatedProperties: false + + GeneratorStatusMetadata: + allOf: + - $ref: '#/components/schemas/EquipmentMetadataBase' + - $ref: '#/components/schemas/StatusFields' + - $ref: '#/components/schemas/GeneratorObjectTypeFields' + unevaluatedProperties: false + + GeneratorAvailableMetadata: + allOf: + - $ref: '#/components/schemas/EquipmentMetadataBase' + - $ref: '#/components/schemas/AvailableFields' + - $ref: '#/components/schemas/GeneratorObjectTypeFields' + unevaluatedProperties: false + + ShuntStatusMetadata: + allOf: + - $ref: '#/components/schemas/EquipmentMetadataBase' + - $ref: '#/components/schemas/StatusFields' + - $ref: '#/components/schemas/ShuntObjectTypeFields' + unevaluatedProperties: false + + ShuntAvailableMetadata: + allOf: + - $ref: '#/components/schemas/EquipmentMetadataBase' + - $ref: '#/components/schemas/AvailableFields' + - $ref: '#/components/schemas/ShuntObjectTypeFields' + unevaluatedProperties: false + + BreakerStatusMetadata: + allOf: + - $ref: '#/components/schemas/EquipmentMetadataBase' + - $ref: '#/components/schemas/StatusFields' + - $ref: '#/components/schemas/BreakerObjectTypeFields' + unevaluatedProperties: false + + BreakerAvailableMetadata: + allOf: + - $ref: '#/components/schemas/EquipmentMetadataBase' + - $ref: '#/components/schemas/AvailableFields' + - $ref: '#/components/schemas/BreakerObjectTypeFields' + unevaluatedProperties: false + + ValveValvePositionMetadata: + allOf: + - $ref: '#/components/schemas/EquipmentMeasurementModeBase' + - $ref: '#/components/schemas/ValvePositionFields' + - $ref: '#/components/schemas/ValveObjectTypeFields' + unevaluatedProperties: false + + PumpPumpSpeedMetadata: + allOf: + - $ref: '#/components/schemas/EquipmentMeasurementModeBase' + - $ref: '#/components/schemas/PumpSpeedFields' + - $ref: '#/components/schemas/PumpObjectTypeFields' + unevaluatedProperties: false + + FanFanSpeedMetadata: + allOf: + - $ref: '#/components/schemas/EquipmentMeasurementModeBase' + - $ref: '#/components/schemas/FanSpeedFields' + - $ref: '#/components/schemas/FanObjectTypeFields' + unevaluatedProperties: false + + DamperDamperPositionMetadata: + allOf: + - $ref: '#/components/schemas/EquipmentMeasurementModeBase' + - $ref: '#/components/schemas/DamperPositionFields' + - $ref: '#/components/schemas/DamperObjectTypeFields' + unevaluatedProperties: false + + ValveAvailableMetadata: + allOf: + - $ref: '#/components/schemas/EquipmentMetadataBase' + - $ref: '#/components/schemas/AvailableFields' + - $ref: '#/components/schemas/ValveObjectTypeFields' + unevaluatedProperties: false + + PumpAvailableMetadata: + allOf: + - $ref: '#/components/schemas/EquipmentMetadataBase' + - $ref: '#/components/schemas/AvailableFields' + - $ref: '#/components/schemas/PumpObjectTypeFields' + unevaluatedProperties: false + + FanAvailableMetadata: + allOf: + - $ref: '#/components/schemas/EquipmentMetadataBase' + - $ref: '#/components/schemas/AvailableFields' + - $ref: '#/components/schemas/FanObjectTypeFields' + unevaluatedProperties: false + + DamperAvailableMetadata: + allOf: + - $ref: '#/components/schemas/EquipmentMetadataBase' + - $ref: '#/components/schemas/AvailableFields' + - $ref: '#/components/schemas/DamperObjectTypeFields' + unevaluatedProperties: false + + SensorAvailableMetadata: + allOf: + - $ref: '#/components/schemas/EquipmentMetadataBase' + - $ref: '#/components/schemas/AvailableFields' + - $ref: '#/components/schemas/SensorObjectTypeFields' + unevaluatedProperties: false + + SensorLiquidTemperatureMetadata: + allOf: + - $ref: '#/components/schemas/EquipmentMetadataBase' + - $ref: '#/components/schemas/LiquidTemperatureFields' + - $ref: '#/components/schemas/SensorObjectTypeFields' + unevaluatedProperties: false + + SensorLiquidDifferentialPressureMetadata: + allOf: + - $ref: '#/components/schemas/EquipmentMeasurementModeBase' + - $ref: '#/components/schemas/LiquidDifferentialPressureFields' + - $ref: '#/components/schemas/SensorObjectTypeFields' + unevaluatedProperties: false + + SensorLiquidFlowMetadata: + allOf: + - $ref: '#/components/schemas/EquipmentMeasurementModeBase' + - $ref: '#/components/schemas/LiquidFlowFields' + - $ref: '#/components/schemas/SensorObjectTypeFields' + unevaluatedProperties: false + + SensorLiquidPressureMetadata: + allOf: + - $ref: '#/components/schemas/EquipmentMeasurementModeBase' + - $ref: '#/components/schemas/LiquidPressureFields' + - $ref: '#/components/schemas/SensorObjectTypeFields' + unevaluatedProperties: false + + SensorAirTemperatureMetadata: + allOf: + - $ref: '#/components/schemas/EquipmentMetadataBase' + - $ref: '#/components/schemas/AirTemperatureFields' + - $ref: '#/components/schemas/SensorObjectTypeFields' + unevaluatedProperties: false + + SensorAirDifferentialPressureMetadata: + allOf: + - $ref: '#/components/schemas/EquipmentMeasurementModeBase' + - $ref: '#/components/schemas/AirDifferentialPressureFields' + - $ref: '#/components/schemas/SensorObjectTypeFields' + unevaluatedProperties: false + + SensorAirFlowMetadata: + allOf: + - $ref: '#/components/schemas/EquipmentMeasurementModeBase' + - $ref: '#/components/schemas/AirFlowFields' + - $ref: '#/components/schemas/SensorObjectTypeFields' + unevaluatedProperties: false + + SensorAirPressureMetadata: + allOf: + - $ref: '#/components/schemas/EquipmentMeasurementModeBase' + - $ref: '#/components/schemas/AirPressureFields' + - $ref: '#/components/schemas/SensorObjectTypeFields' + unevaluatedProperties: false + + SensorSoundMetadata: + allOf: + - $ref: '#/components/schemas/EquipmentMetadataBase' + - $ref: '#/components/schemas/SoundFields' + - $ref: '#/components/schemas/SensorObjectTypeFields' + unevaluatedProperties: false + + SensorLeakDetectMetadata: + allOf: + - $ref: '#/components/schemas/EquipmentMetadataBase' + - $ref: '#/components/schemas/LeakDetectFields' + - $ref: '#/components/schemas/SensorObjectTypeFields' + unevaluatedProperties: false + + SensorAirRelativeHumidityMetadata: + allOf: + - $ref: '#/components/schemas/EquipmentMeasurementModeBase' + - $ref: '#/components/schemas/AirRelativeHumidityFields' + - $ref: '#/components/schemas/SensorObjectTypeFields' + unevaluatedProperties: false + + CDULiquidTemperatureMetadata: + allOf: + - $ref: '#/components/schemas/EquipmentMetadataBase' + - $ref: '#/components/schemas/LiquidTemperatureFields' + - $ref: '#/components/schemas/CDUObjectTypeFields' + unevaluatedProperties: false + + CDULiquidDifferentialPressureMetadata: + allOf: + - $ref: '#/components/schemas/EquipmentMeasurementModeBase' + - $ref: '#/components/schemas/LiquidDifferentialPressureFields' + - $ref: '#/components/schemas/CDUObjectTypeFields' + unevaluatedProperties: false + + CDULiquidFlowMetadata: + allOf: + - $ref: '#/components/schemas/EquipmentMeasurementModeBase' + - $ref: '#/components/schemas/LiquidFlowFields' + - $ref: '#/components/schemas/CDUObjectTypeFields' + unevaluatedProperties: false + + CDULiquidPressureMetadata: + allOf: + - $ref: '#/components/schemas/EquipmentMeasurementModeBase' + - $ref: '#/components/schemas/LiquidPressureFields' + - $ref: '#/components/schemas/CDUObjectTypeFields' + unevaluatedProperties: false + + CDUStatusMetadata: + allOf: + - $ref: '#/components/schemas/EquipmentMetadataBase' + - $ref: '#/components/schemas/StatusFields' + - $ref: '#/components/schemas/CDUObjectTypeFields' + unevaluatedProperties: false + + CDUAvailableMetadata: + allOf: + - $ref: '#/components/schemas/EquipmentMetadataBase' + - $ref: '#/components/schemas/AvailableFields' + - $ref: '#/components/schemas/CDUObjectTypeFields' + unevaluatedProperties: false + + CDUValvePositionMetadata: + allOf: + - $ref: '#/components/schemas/EquipmentMeasurementModeBase' + - $ref: '#/components/schemas/ValvePositionFields' + - $ref: '#/components/schemas/CDUObjectTypeFields' + unevaluatedProperties: false + + CDUPumpSpeedMetadata: + allOf: + - $ref: '#/components/schemas/EquipmentMeasurementModeBase' + - $ref: '#/components/schemas/PumpSpeedFields' + - $ref: '#/components/schemas/CDUObjectTypeFields' + unevaluatedProperties: false + + CDUFanSpeedMetadata: + allOf: + - $ref: '#/components/schemas/EquipmentMeasurementModeBase' + - $ref: '#/components/schemas/FanSpeedFields' + - $ref: '#/components/schemas/CDUObjectTypeFields' + unevaluatedProperties: false + + CDUDamperPositionMetadata: + allOf: + - $ref: '#/components/schemas/EquipmentMeasurementModeBase' + - $ref: '#/components/schemas/DamperPositionFields' + - $ref: '#/components/schemas/CDUObjectTypeFields' + unevaluatedProperties: false + + CDUAirTemperatureMetadata: + allOf: + - $ref: '#/components/schemas/EquipmentMetadataBase' + - $ref: '#/components/schemas/AirTemperatureFields' + - $ref: '#/components/schemas/CDUObjectTypeFields' + unevaluatedProperties: false + + CDUAirDifferentialPressureMetadata: + allOf: + - $ref: '#/components/schemas/EquipmentMeasurementModeBase' + - $ref: '#/components/schemas/AirDifferentialPressureFields' + - $ref: '#/components/schemas/CDUObjectTypeFields' + unevaluatedProperties: false + + CDUAirFlowMetadata: + allOf: + - $ref: '#/components/schemas/EquipmentMeasurementModeBase' + - $ref: '#/components/schemas/AirFlowFields' + - $ref: '#/components/schemas/CDUObjectTypeFields' + unevaluatedProperties: false + + CDUAirPressureMetadata: + allOf: + - $ref: '#/components/schemas/EquipmentMeasurementModeBase' + - $ref: '#/components/schemas/AirPressureFields' + - $ref: '#/components/schemas/CDUObjectTypeFields' + unevaluatedProperties: false + + CDULeakDetectMetadata: + allOf: + - $ref: '#/components/schemas/EquipmentMetadataBase' + - $ref: '#/components/schemas/LeakDetectFields' + - $ref: '#/components/schemas/CDUObjectTypeFields' + unevaluatedProperties: false + + CDUAirRelativeHumidityMetadata: + allOf: + - $ref: '#/components/schemas/EquipmentMeasurementModeBase' + - $ref: '#/components/schemas/AirRelativeHumidityFields' + - $ref: '#/components/schemas/CDUObjectTypeFields' + unevaluatedProperties: false + + CoolingTowerLiquidTemperatureMetadata: + allOf: + - $ref: '#/components/schemas/EquipmentMetadataBase' + - $ref: '#/components/schemas/LiquidTemperatureFields' + - $ref: '#/components/schemas/CoolingTowerObjectTypeFields' + unevaluatedProperties: false + + CoolingTowerLiquidDifferentialPressureMetadata: + allOf: + - $ref: '#/components/schemas/EquipmentMeasurementModeBase' + - $ref: '#/components/schemas/LiquidDifferentialPressureFields' + - $ref: '#/components/schemas/CoolingTowerObjectTypeFields' + unevaluatedProperties: false + + CoolingTowerLiquidFlowMetadata: + allOf: + - $ref: '#/components/schemas/EquipmentMeasurementModeBase' + - $ref: '#/components/schemas/LiquidFlowFields' + - $ref: '#/components/schemas/CoolingTowerObjectTypeFields' + unevaluatedProperties: false + + CoolingTowerLiquidPressureMetadata: + allOf: + - $ref: '#/components/schemas/EquipmentMeasurementModeBase' + - $ref: '#/components/schemas/LiquidPressureFields' + - $ref: '#/components/schemas/CoolingTowerObjectTypeFields' + unevaluatedProperties: false + + CoolingTowerStatusMetadata: + allOf: + - $ref: '#/components/schemas/EquipmentMetadataBase' + - $ref: '#/components/schemas/StatusFields' + - $ref: '#/components/schemas/CoolingTowerObjectTypeFields' + unevaluatedProperties: false + + CoolingTowerAvailableMetadata: + allOf: + - $ref: '#/components/schemas/EquipmentMetadataBase' + - $ref: '#/components/schemas/AvailableFields' + - $ref: '#/components/schemas/CoolingTowerObjectTypeFields' + unevaluatedProperties: false + + CoolingTowerValvePositionMetadata: + allOf: + - $ref: '#/components/schemas/EquipmentMeasurementModeBase' + - $ref: '#/components/schemas/ValvePositionFields' + - $ref: '#/components/schemas/CoolingTowerObjectTypeFields' + unevaluatedProperties: false + + CoolingTowerPumpSpeedMetadata: + allOf: + - $ref: '#/components/schemas/EquipmentMeasurementModeBase' + - $ref: '#/components/schemas/PumpSpeedFields' + - $ref: '#/components/schemas/CoolingTowerObjectTypeFields' + unevaluatedProperties: false + + CoolingTowerFanSpeedMetadata: + allOf: + - $ref: '#/components/schemas/EquipmentMeasurementModeBase' + - $ref: '#/components/schemas/FanSpeedFields' + - $ref: '#/components/schemas/CoolingTowerObjectTypeFields' + unevaluatedProperties: false + + CoolingTowerDamperPositionMetadata: + allOf: + - $ref: '#/components/schemas/EquipmentMeasurementModeBase' + - $ref: '#/components/schemas/DamperPositionFields' + - $ref: '#/components/schemas/CoolingTowerObjectTypeFields' + unevaluatedProperties: false + + CoolingTowerAirTemperatureMetadata: + allOf: + - $ref: '#/components/schemas/EquipmentMetadataBase' + - $ref: '#/components/schemas/AirTemperatureFields' + - $ref: '#/components/schemas/CoolingTowerObjectTypeFields' + unevaluatedProperties: false + + CoolingTowerAirDifferentialPressureMetadata: + allOf: + - $ref: '#/components/schemas/EquipmentMeasurementModeBase' + - $ref: '#/components/schemas/AirDifferentialPressureFields' + - $ref: '#/components/schemas/CoolingTowerObjectTypeFields' + unevaluatedProperties: false + + CoolingTowerAirFlowMetadata: + allOf: + - $ref: '#/components/schemas/EquipmentMeasurementModeBase' + - $ref: '#/components/schemas/AirFlowFields' + - $ref: '#/components/schemas/CoolingTowerObjectTypeFields' + unevaluatedProperties: false + + CoolingTowerAirPressureMetadata: + allOf: + - $ref: '#/components/schemas/EquipmentMeasurementModeBase' + - $ref: '#/components/schemas/AirPressureFields' + - $ref: '#/components/schemas/CoolingTowerObjectTypeFields' + unevaluatedProperties: false + + CoolingTowerLeakDetectMetadata: + allOf: + - $ref: '#/components/schemas/EquipmentMetadataBase' + - $ref: '#/components/schemas/LeakDetectFields' + - $ref: '#/components/schemas/CoolingTowerObjectTypeFields' + unevaluatedProperties: false + + CoolingTowerAirRelativeHumidityMetadata: + allOf: + - $ref: '#/components/schemas/EquipmentMeasurementModeBase' + - $ref: '#/components/schemas/AirRelativeHumidityFields' + - $ref: '#/components/schemas/CoolingTowerObjectTypeFields' + unevaluatedProperties: false + + HXLiquidTemperatureMetadata: + allOf: + - $ref: '#/components/schemas/EquipmentMetadataBase' + - $ref: '#/components/schemas/LiquidTemperatureFields' + - $ref: '#/components/schemas/HXObjectTypeFields' + unevaluatedProperties: false + + HXLiquidDifferentialPressureMetadata: + allOf: + - $ref: '#/components/schemas/EquipmentMeasurementModeBase' + - $ref: '#/components/schemas/LiquidDifferentialPressureFields' + - $ref: '#/components/schemas/HXObjectTypeFields' + unevaluatedProperties: false + + HXLiquidFlowMetadata: + allOf: + - $ref: '#/components/schemas/EquipmentMeasurementModeBase' + - $ref: '#/components/schemas/LiquidFlowFields' + - $ref: '#/components/schemas/HXObjectTypeFields' + unevaluatedProperties: false + + HXLiquidPressureMetadata: + allOf: + - $ref: '#/components/schemas/EquipmentMeasurementModeBase' + - $ref: '#/components/schemas/LiquidPressureFields' + - $ref: '#/components/schemas/HXObjectTypeFields' + unevaluatedProperties: false + + HXStatusMetadata: + allOf: + - $ref: '#/components/schemas/EquipmentMetadataBase' + - $ref: '#/components/schemas/StatusFields' + - $ref: '#/components/schemas/HXObjectTypeFields' + unevaluatedProperties: false + + HXAvailableMetadata: + allOf: + - $ref: '#/components/schemas/EquipmentMetadataBase' + - $ref: '#/components/schemas/AvailableFields' + - $ref: '#/components/schemas/HXObjectTypeFields' + unevaluatedProperties: false + + HXValvePositionMetadata: + allOf: + - $ref: '#/components/schemas/EquipmentMeasurementModeBase' + - $ref: '#/components/schemas/ValvePositionFields' + - $ref: '#/components/schemas/HXObjectTypeFields' + unevaluatedProperties: false + + HXPumpSpeedMetadata: + allOf: + - $ref: '#/components/schemas/EquipmentMeasurementModeBase' + - $ref: '#/components/schemas/PumpSpeedFields' + - $ref: '#/components/schemas/HXObjectTypeFields' + unevaluatedProperties: false + + HXFanSpeedMetadata: + allOf: + - $ref: '#/components/schemas/EquipmentMeasurementModeBase' + - $ref: '#/components/schemas/FanSpeedFields' + - $ref: '#/components/schemas/HXObjectTypeFields' + unevaluatedProperties: false + + HXDamperPositionMetadata: + allOf: + - $ref: '#/components/schemas/EquipmentMeasurementModeBase' + - $ref: '#/components/schemas/DamperPositionFields' + - $ref: '#/components/schemas/HXObjectTypeFields' + unevaluatedProperties: false + + HXAirTemperatureMetadata: + allOf: + - $ref: '#/components/schemas/EquipmentMetadataBase' + - $ref: '#/components/schemas/AirTemperatureFields' + - $ref: '#/components/schemas/HXObjectTypeFields' + unevaluatedProperties: false + + HXAirDifferentialPressureMetadata: + allOf: + - $ref: '#/components/schemas/EquipmentMeasurementModeBase' + - $ref: '#/components/schemas/AirDifferentialPressureFields' + - $ref: '#/components/schemas/HXObjectTypeFields' + unevaluatedProperties: false + + HXAirFlowMetadata: + allOf: + - $ref: '#/components/schemas/EquipmentMeasurementModeBase' + - $ref: '#/components/schemas/AirFlowFields' + - $ref: '#/components/schemas/HXObjectTypeFields' + unevaluatedProperties: false + + HXAirPressureMetadata: + allOf: + - $ref: '#/components/schemas/EquipmentMeasurementModeBase' + - $ref: '#/components/schemas/AirPressureFields' + - $ref: '#/components/schemas/HXObjectTypeFields' + unevaluatedProperties: false + + HXLeakDetectMetadata: + allOf: + - $ref: '#/components/schemas/EquipmentMetadataBase' + - $ref: '#/components/schemas/LeakDetectFields' + - $ref: '#/components/schemas/HXObjectTypeFields' + unevaluatedProperties: false + + HXAirRelativeHumidityMetadata: + allOf: + - $ref: '#/components/schemas/EquipmentMeasurementModeBase' + - $ref: '#/components/schemas/AirRelativeHumidityFields' + - $ref: '#/components/schemas/HXObjectTypeFields' + unevaluatedProperties: false + + CRAHLiquidTemperatureMetadata: + allOf: + - $ref: '#/components/schemas/EquipmentMetadataBase' + - $ref: '#/components/schemas/LiquidTemperatureFields' + - $ref: '#/components/schemas/CRAHObjectTypeFields' + unevaluatedProperties: false + + CRAHLiquidDifferentialPressureMetadata: + allOf: + - $ref: '#/components/schemas/EquipmentMeasurementModeBase' + - $ref: '#/components/schemas/LiquidDifferentialPressureFields' + - $ref: '#/components/schemas/CRAHObjectTypeFields' + unevaluatedProperties: false + + CRAHLiquidFlowMetadata: + allOf: + - $ref: '#/components/schemas/EquipmentMeasurementModeBase' + - $ref: '#/components/schemas/LiquidFlowFields' + - $ref: '#/components/schemas/CRAHObjectTypeFields' + unevaluatedProperties: false + + CRAHLiquidPressureMetadata: + allOf: + - $ref: '#/components/schemas/EquipmentMeasurementModeBase' + - $ref: '#/components/schemas/LiquidPressureFields' + - $ref: '#/components/schemas/CRAHObjectTypeFields' + unevaluatedProperties: false + + CRAHStatusMetadata: + allOf: + - $ref: '#/components/schemas/EquipmentMetadataBase' + - $ref: '#/components/schemas/StatusFields' + - $ref: '#/components/schemas/CRAHObjectTypeFields' + unevaluatedProperties: false + + CRAHAvailableMetadata: + allOf: + - $ref: '#/components/schemas/EquipmentMetadataBase' + - $ref: '#/components/schemas/AvailableFields' + - $ref: '#/components/schemas/CRAHObjectTypeFields' + unevaluatedProperties: false + + CRAHValvePositionMetadata: + allOf: + - $ref: '#/components/schemas/EquipmentMeasurementModeBase' + - $ref: '#/components/schemas/ValvePositionFields' + - $ref: '#/components/schemas/CRAHObjectTypeFields' + unevaluatedProperties: false + + CRAHPumpSpeedMetadata: + allOf: + - $ref: '#/components/schemas/EquipmentMeasurementModeBase' + - $ref: '#/components/schemas/PumpSpeedFields' + - $ref: '#/components/schemas/CRAHObjectTypeFields' + unevaluatedProperties: false + + CRAHFanSpeedMetadata: + allOf: + - $ref: '#/components/schemas/EquipmentMeasurementModeBase' + - $ref: '#/components/schemas/FanSpeedFields' + - $ref: '#/components/schemas/CRAHObjectTypeFields' + unevaluatedProperties: false + + CRAHDamperPositionMetadata: + allOf: + - $ref: '#/components/schemas/EquipmentMeasurementModeBase' + - $ref: '#/components/schemas/DamperPositionFields' + - $ref: '#/components/schemas/CRAHObjectTypeFields' + unevaluatedProperties: false + + CRAHAirTemperatureMetadata: + allOf: + - $ref: '#/components/schemas/EquipmentMetadataBase' + - $ref: '#/components/schemas/AirTemperatureFields' + - $ref: '#/components/schemas/CRAHObjectTypeFields' + unevaluatedProperties: false + + CRAHAirDifferentialPressureMetadata: + allOf: + - $ref: '#/components/schemas/EquipmentMeasurementModeBase' + - $ref: '#/components/schemas/AirDifferentialPressureFields' + - $ref: '#/components/schemas/CRAHObjectTypeFields' + unevaluatedProperties: false + + CRAHAirFlowMetadata: + allOf: + - $ref: '#/components/schemas/EquipmentMeasurementModeBase' + - $ref: '#/components/schemas/AirFlowFields' + - $ref: '#/components/schemas/CRAHObjectTypeFields' + unevaluatedProperties: false + + CRAHAirPressureMetadata: + allOf: + - $ref: '#/components/schemas/EquipmentMeasurementModeBase' + - $ref: '#/components/schemas/AirPressureFields' + - $ref: '#/components/schemas/CRAHObjectTypeFields' + unevaluatedProperties: false + + CRAHLeakDetectMetadata: + allOf: + - $ref: '#/components/schemas/EquipmentMetadataBase' + - $ref: '#/components/schemas/LeakDetectFields' + - $ref: '#/components/schemas/CRAHObjectTypeFields' + unevaluatedProperties: false + + CRAHAirRelativeHumidityMetadata: + allOf: + - $ref: '#/components/schemas/EquipmentMeasurementModeBase' + - $ref: '#/components/schemas/AirRelativeHumidityFields' + - $ref: '#/components/schemas/CRAHObjectTypeFields' + unevaluatedProperties: false + + CRACLiquidTemperatureMetadata: + allOf: + - $ref: '#/components/schemas/EquipmentMetadataBase' + - $ref: '#/components/schemas/LiquidTemperatureFields' + - $ref: '#/components/schemas/CRACObjectTypeFields' + unevaluatedProperties: false + + CRACLiquidDifferentialPressureMetadata: + allOf: + - $ref: '#/components/schemas/EquipmentMeasurementModeBase' + - $ref: '#/components/schemas/LiquidDifferentialPressureFields' + - $ref: '#/components/schemas/CRACObjectTypeFields' + unevaluatedProperties: false + + CRACLiquidFlowMetadata: + allOf: + - $ref: '#/components/schemas/EquipmentMeasurementModeBase' + - $ref: '#/components/schemas/LiquidFlowFields' + - $ref: '#/components/schemas/CRACObjectTypeFields' + unevaluatedProperties: false + + CRACLiquidPressureMetadata: + allOf: + - $ref: '#/components/schemas/EquipmentMeasurementModeBase' + - $ref: '#/components/schemas/LiquidPressureFields' + - $ref: '#/components/schemas/CRACObjectTypeFields' + unevaluatedProperties: false + + CRACStatusMetadata: + allOf: + - $ref: '#/components/schemas/EquipmentMetadataBase' + - $ref: '#/components/schemas/StatusFields' + - $ref: '#/components/schemas/CRACObjectTypeFields' + unevaluatedProperties: false + + CRACAvailableMetadata: + allOf: + - $ref: '#/components/schemas/EquipmentMetadataBase' + - $ref: '#/components/schemas/AvailableFields' + - $ref: '#/components/schemas/CRACObjectTypeFields' + unevaluatedProperties: false + + CRACValvePositionMetadata: + allOf: + - $ref: '#/components/schemas/EquipmentMeasurementModeBase' + - $ref: '#/components/schemas/ValvePositionFields' + - $ref: '#/components/schemas/CRACObjectTypeFields' + unevaluatedProperties: false + + CRACPumpSpeedMetadata: + allOf: + - $ref: '#/components/schemas/EquipmentMeasurementModeBase' + - $ref: '#/components/schemas/PumpSpeedFields' + - $ref: '#/components/schemas/CRACObjectTypeFields' + unevaluatedProperties: false + + CRACFanSpeedMetadata: + allOf: + - $ref: '#/components/schemas/EquipmentMeasurementModeBase' + - $ref: '#/components/schemas/FanSpeedFields' + - $ref: '#/components/schemas/CRACObjectTypeFields' + unevaluatedProperties: false + + CRACDamperPositionMetadata: + allOf: + - $ref: '#/components/schemas/EquipmentMeasurementModeBase' + - $ref: '#/components/schemas/DamperPositionFields' + - $ref: '#/components/schemas/CRACObjectTypeFields' + unevaluatedProperties: false + + CRACAirTemperatureMetadata: + allOf: + - $ref: '#/components/schemas/EquipmentMetadataBase' + - $ref: '#/components/schemas/AirTemperatureFields' + - $ref: '#/components/schemas/CRACObjectTypeFields' + unevaluatedProperties: false + + CRACAirDifferentialPressureMetadata: + allOf: + - $ref: '#/components/schemas/EquipmentMeasurementModeBase' + - $ref: '#/components/schemas/AirDifferentialPressureFields' + - $ref: '#/components/schemas/CRACObjectTypeFields' + unevaluatedProperties: false + + CRACAirFlowMetadata: + allOf: + - $ref: '#/components/schemas/EquipmentMeasurementModeBase' + - $ref: '#/components/schemas/AirFlowFields' + - $ref: '#/components/schemas/CRACObjectTypeFields' + unevaluatedProperties: false + + CRACAirPressureMetadata: + allOf: + - $ref: '#/components/schemas/EquipmentMeasurementModeBase' + - $ref: '#/components/schemas/AirPressureFields' + - $ref: '#/components/schemas/CRACObjectTypeFields' + unevaluatedProperties: false + + CRACLeakDetectMetadata: + allOf: + - $ref: '#/components/schemas/EquipmentMetadataBase' + - $ref: '#/components/schemas/LeakDetectFields' + - $ref: '#/components/schemas/CRACObjectTypeFields' + unevaluatedProperties: false + + CRACAirRelativeHumidityMetadata: + allOf: + - $ref: '#/components/schemas/EquipmentMeasurementModeBase' + - $ref: '#/components/schemas/AirRelativeHumidityFields' + - $ref: '#/components/schemas/CRACObjectTypeFields' + unevaluatedProperties: false + + AHULiquidTemperatureMetadata: + allOf: + - $ref: '#/components/schemas/EquipmentMetadataBase' + - $ref: '#/components/schemas/LiquidTemperatureFields' + - $ref: '#/components/schemas/AHUObjectTypeFields' + unevaluatedProperties: false + + AHULiquidDifferentialPressureMetadata: + allOf: + - $ref: '#/components/schemas/EquipmentMeasurementModeBase' + - $ref: '#/components/schemas/LiquidDifferentialPressureFields' + - $ref: '#/components/schemas/AHUObjectTypeFields' + unevaluatedProperties: false + + AHULiquidFlowMetadata: + allOf: + - $ref: '#/components/schemas/EquipmentMeasurementModeBase' + - $ref: '#/components/schemas/LiquidFlowFields' + - $ref: '#/components/schemas/AHUObjectTypeFields' + unevaluatedProperties: false + + AHULiquidPressureMetadata: + allOf: + - $ref: '#/components/schemas/EquipmentMeasurementModeBase' + - $ref: '#/components/schemas/LiquidPressureFields' + - $ref: '#/components/schemas/AHUObjectTypeFields' + unevaluatedProperties: false + + AHUStatusMetadata: + allOf: + - $ref: '#/components/schemas/EquipmentMetadataBase' + - $ref: '#/components/schemas/StatusFields' + - $ref: '#/components/schemas/AHUObjectTypeFields' + unevaluatedProperties: false + + AHUAvailableMetadata: + allOf: + - $ref: '#/components/schemas/EquipmentMetadataBase' + - $ref: '#/components/schemas/AvailableFields' + - $ref: '#/components/schemas/AHUObjectTypeFields' + unevaluatedProperties: false + + AHUValvePositionMetadata: + allOf: + - $ref: '#/components/schemas/EquipmentMeasurementModeBase' + - $ref: '#/components/schemas/ValvePositionFields' + - $ref: '#/components/schemas/AHUObjectTypeFields' + unevaluatedProperties: false + + AHUPumpSpeedMetadata: + allOf: + - $ref: '#/components/schemas/EquipmentMeasurementModeBase' + - $ref: '#/components/schemas/PumpSpeedFields' + - $ref: '#/components/schemas/AHUObjectTypeFields' + unevaluatedProperties: false + + AHUFanSpeedMetadata: + allOf: + - $ref: '#/components/schemas/EquipmentMeasurementModeBase' + - $ref: '#/components/schemas/FanSpeedFields' + - $ref: '#/components/schemas/AHUObjectTypeFields' + unevaluatedProperties: false + + AHUDamperPositionMetadata: + allOf: + - $ref: '#/components/schemas/EquipmentMeasurementModeBase' + - $ref: '#/components/schemas/DamperPositionFields' + - $ref: '#/components/schemas/AHUObjectTypeFields' + unevaluatedProperties: false + + AHUAirTemperatureMetadata: + allOf: + - $ref: '#/components/schemas/EquipmentMetadataBase' + - $ref: '#/components/schemas/AirTemperatureFields' + - $ref: '#/components/schemas/AHUObjectTypeFields' + unevaluatedProperties: false + + AHUAirDifferentialPressureMetadata: + allOf: + - $ref: '#/components/schemas/EquipmentMeasurementModeBase' + - $ref: '#/components/schemas/AirDifferentialPressureFields' + - $ref: '#/components/schemas/AHUObjectTypeFields' + unevaluatedProperties: false + + AHUAirFlowMetadata: + allOf: + - $ref: '#/components/schemas/EquipmentMeasurementModeBase' + - $ref: '#/components/schemas/AirFlowFields' + - $ref: '#/components/schemas/AHUObjectTypeFields' + unevaluatedProperties: false + + AHUAirPressureMetadata: + allOf: + - $ref: '#/components/schemas/EquipmentMeasurementModeBase' + - $ref: '#/components/schemas/AirPressureFields' + - $ref: '#/components/schemas/AHUObjectTypeFields' + unevaluatedProperties: false + + AHULeakDetectMetadata: + allOf: + - $ref: '#/components/schemas/EquipmentMetadataBase' + - $ref: '#/components/schemas/LeakDetectFields' + - $ref: '#/components/schemas/AHUObjectTypeFields' + unevaluatedProperties: false + + AHUAirRelativeHumidityMetadata: + allOf: + - $ref: '#/components/schemas/EquipmentMeasurementModeBase' + - $ref: '#/components/schemas/AirRelativeHumidityFields' + - $ref: '#/components/schemas/AHUObjectTypeFields' + unevaluatedProperties: false + + ChillerLiquidTemperatureMetadata: + allOf: + - $ref: '#/components/schemas/EquipmentMetadataBase' + - $ref: '#/components/schemas/LiquidTemperatureFields' + - $ref: '#/components/schemas/ChillerObjectTypeFields' + unevaluatedProperties: false + + ChillerLiquidDifferentialPressureMetadata: + allOf: + - $ref: '#/components/schemas/EquipmentMeasurementModeBase' + - $ref: '#/components/schemas/LiquidDifferentialPressureFields' + - $ref: '#/components/schemas/ChillerObjectTypeFields' + unevaluatedProperties: false + + ChillerLiquidFlowMetadata: + allOf: + - $ref: '#/components/schemas/EquipmentMeasurementModeBase' + - $ref: '#/components/schemas/LiquidFlowFields' + - $ref: '#/components/schemas/ChillerObjectTypeFields' + unevaluatedProperties: false + + ChillerLiquidPressureMetadata: + allOf: + - $ref: '#/components/schemas/EquipmentMeasurementModeBase' + - $ref: '#/components/schemas/LiquidPressureFields' + - $ref: '#/components/schemas/ChillerObjectTypeFields' + unevaluatedProperties: false + + ChillerStatusMetadata: + allOf: + - $ref: '#/components/schemas/EquipmentMetadataBase' + - $ref: '#/components/schemas/StatusFields' + - $ref: '#/components/schemas/ChillerObjectTypeFields' + unevaluatedProperties: false + + ChillerAvailableMetadata: + allOf: + - $ref: '#/components/schemas/EquipmentMetadataBase' + - $ref: '#/components/schemas/AvailableFields' + - $ref: '#/components/schemas/ChillerObjectTypeFields' + unevaluatedProperties: false + + ChillerValvePositionMetadata: + allOf: + - $ref: '#/components/schemas/EquipmentMeasurementModeBase' + - $ref: '#/components/schemas/ValvePositionFields' + - $ref: '#/components/schemas/ChillerObjectTypeFields' + unevaluatedProperties: false + + ChillerPumpSpeedMetadata: + allOf: + - $ref: '#/components/schemas/EquipmentMeasurementModeBase' + - $ref: '#/components/schemas/PumpSpeedFields' + - $ref: '#/components/schemas/ChillerObjectTypeFields' + unevaluatedProperties: false + + ChillerFanSpeedMetadata: + allOf: + - $ref: '#/components/schemas/EquipmentMeasurementModeBase' + - $ref: '#/components/schemas/FanSpeedFields' + - $ref: '#/components/schemas/ChillerObjectTypeFields' + unevaluatedProperties: false + + ChillerDamperPositionMetadata: + allOf: + - $ref: '#/components/schemas/EquipmentMeasurementModeBase' + - $ref: '#/components/schemas/DamperPositionFields' + - $ref: '#/components/schemas/ChillerObjectTypeFields' + unevaluatedProperties: false + + ChillerAirTemperatureMetadata: + allOf: + - $ref: '#/components/schemas/EquipmentMetadataBase' + - $ref: '#/components/schemas/AirTemperatureFields' + - $ref: '#/components/schemas/ChillerObjectTypeFields' + unevaluatedProperties: false + + ChillerAirDifferentialPressureMetadata: + allOf: + - $ref: '#/components/schemas/EquipmentMeasurementModeBase' + - $ref: '#/components/schemas/AirDifferentialPressureFields' + - $ref: '#/components/schemas/ChillerObjectTypeFields' + unevaluatedProperties: false + + ChillerAirFlowMetadata: + allOf: + - $ref: '#/components/schemas/EquipmentMeasurementModeBase' + - $ref: '#/components/schemas/AirFlowFields' + - $ref: '#/components/schemas/ChillerObjectTypeFields' + unevaluatedProperties: false + + ChillerAirPressureMetadata: + allOf: + - $ref: '#/components/schemas/EquipmentMeasurementModeBase' + - $ref: '#/components/schemas/AirPressureFields' + - $ref: '#/components/schemas/ChillerObjectTypeFields' + unevaluatedProperties: false + + ChillerLeakDetectMetadata: + allOf: + - $ref: '#/components/schemas/EquipmentMetadataBase' + - $ref: '#/components/schemas/LeakDetectFields' + - $ref: '#/components/schemas/ChillerObjectTypeFields' + unevaluatedProperties: false + + ChillerAirRelativeHumidityMetadata: + allOf: + - $ref: '#/components/schemas/EquipmentMeasurementModeBase' + - $ref: '#/components/schemas/AirRelativeHumidityFields' + - $ref: '#/components/schemas/ChillerObjectTypeFields' + unevaluatedProperties: false + + # --- Tank — BMS published ------------------------------------------------ + + TankLiquidTemperatureMetadata: + allOf: + - $ref: '#/components/schemas/EquipmentMetadataBase' + - $ref: '#/components/schemas/LiquidTemperatureFields' + - $ref: '#/components/schemas/TankObjectTypeFields' + unevaluatedProperties: false + + TankLiquidDifferentialPressureMetadata: + allOf: + - $ref: '#/components/schemas/EquipmentMeasurementModeBase' + - $ref: '#/components/schemas/LiquidDifferentialPressureFields' + - $ref: '#/components/schemas/TankObjectTypeFields' + unevaluatedProperties: false + + TankLiquidFlowMetadata: + allOf: + - $ref: '#/components/schemas/EquipmentMeasurementModeBase' + - $ref: '#/components/schemas/LiquidFlowFields' + - $ref: '#/components/schemas/TankObjectTypeFields' + unevaluatedProperties: false + + TankLiquidPressureMetadata: + allOf: + - $ref: '#/components/schemas/EquipmentMeasurementModeBase' + - $ref: '#/components/schemas/LiquidPressureFields' + - $ref: '#/components/schemas/TankObjectTypeFields' + unevaluatedProperties: false + + TankStatusMetadata: + allOf: + - $ref: '#/components/schemas/EquipmentMetadataBase' + - $ref: '#/components/schemas/StatusFields' + - $ref: '#/components/schemas/TankObjectTypeFields' + unevaluatedProperties: false + + TankAvailableMetadata: + allOf: + - $ref: '#/components/schemas/EquipmentMetadataBase' + - $ref: '#/components/schemas/AvailableFields' + - $ref: '#/components/schemas/TankObjectTypeFields' + unevaluatedProperties: false + + TankValvePositionMetadata: + allOf: + - $ref: '#/components/schemas/EquipmentMeasurementModeBase' + - $ref: '#/components/schemas/ValvePositionFields' + - $ref: '#/components/schemas/TankObjectTypeFields' + unevaluatedProperties: false + + TankPumpSpeedMetadata: + allOf: + - $ref: '#/components/schemas/EquipmentMeasurementModeBase' + - $ref: '#/components/schemas/PumpSpeedFields' + - $ref: '#/components/schemas/TankObjectTypeFields' + unevaluatedProperties: false + + TankFanSpeedMetadata: + allOf: + - $ref: '#/components/schemas/EquipmentMeasurementModeBase' + - $ref: '#/components/schemas/FanSpeedFields' + - $ref: '#/components/schemas/TankObjectTypeFields' + unevaluatedProperties: false + + TankDamperPositionMetadata: + allOf: + - $ref: '#/components/schemas/EquipmentMeasurementModeBase' + - $ref: '#/components/schemas/DamperPositionFields' + - $ref: '#/components/schemas/TankObjectTypeFields' + unevaluatedProperties: false + + TankAirTemperatureMetadata: + allOf: + - $ref: '#/components/schemas/EquipmentMetadataBase' + - $ref: '#/components/schemas/AirTemperatureFields' + - $ref: '#/components/schemas/TankObjectTypeFields' + unevaluatedProperties: false + + TankAirDifferentialPressureMetadata: + allOf: + - $ref: '#/components/schemas/EquipmentMeasurementModeBase' + - $ref: '#/components/schemas/AirDifferentialPressureFields' + - $ref: '#/components/schemas/TankObjectTypeFields' + unevaluatedProperties: false + + TankAirFlowMetadata: + allOf: + - $ref: '#/components/schemas/EquipmentMeasurementModeBase' + - $ref: '#/components/schemas/AirFlowFields' + - $ref: '#/components/schemas/TankObjectTypeFields' + unevaluatedProperties: false + + TankAirPressureMetadata: + allOf: + - $ref: '#/components/schemas/EquipmentMeasurementModeBase' + - $ref: '#/components/schemas/AirPressureFields' + - $ref: '#/components/schemas/TankObjectTypeFields' + unevaluatedProperties: false + + TankLeakDetectMetadata: + allOf: + - $ref: '#/components/schemas/EquipmentMetadataBase' + - $ref: '#/components/schemas/LeakDetectFields' + - $ref: '#/components/schemas/TankObjectTypeFields' + unevaluatedProperties: false + + TankAirRelativeHumidityMetadata: + allOf: + - $ref: '#/components/schemas/EquipmentMeasurementModeBase' + - $ref: '#/components/schemas/AirRelativeHumidityFields' + - $ref: '#/components/schemas/TankObjectTypeFields' + unevaluatedProperties: false + + # --- Tank — Integration published ---------------------------------------- + + # --- GenericObject — BMS published ---------------------------------------- + + GenericObjectLiquidTemperatureMetadata: + allOf: + - $ref: '#/components/schemas/EquipmentMetadataBase' + - $ref: '#/components/schemas/LiquidTemperatureFields' + - $ref: '#/components/schemas/GenericObjectObjectTypeFields' + unevaluatedProperties: false + + GenericObjectLiquidDifferentialPressureMetadata: + allOf: + - $ref: '#/components/schemas/EquipmentMeasurementModeBase' + - $ref: '#/components/schemas/LiquidDifferentialPressureFields' + - $ref: '#/components/schemas/GenericObjectObjectTypeFields' + unevaluatedProperties: false + + GenericObjectLiquidFlowMetadata: + allOf: + - $ref: '#/components/schemas/EquipmentMeasurementModeBase' + - $ref: '#/components/schemas/LiquidFlowFields' + - $ref: '#/components/schemas/GenericObjectObjectTypeFields' + unevaluatedProperties: false + + GenericObjectLiquidPressureMetadata: + allOf: + - $ref: '#/components/schemas/EquipmentMeasurementModeBase' + - $ref: '#/components/schemas/LiquidPressureFields' + - $ref: '#/components/schemas/GenericObjectObjectTypeFields' + unevaluatedProperties: false + + GenericObjectStatusMetadata: + allOf: + - $ref: '#/components/schemas/EquipmentMetadataBase' + - $ref: '#/components/schemas/StatusFields' + - $ref: '#/components/schemas/GenericObjectObjectTypeFields' + unevaluatedProperties: false + + GenericObjectAvailableMetadata: + allOf: + - $ref: '#/components/schemas/EquipmentMetadataBase' + - $ref: '#/components/schemas/AvailableFields' + - $ref: '#/components/schemas/GenericObjectObjectTypeFields' + unevaluatedProperties: false + + GenericObjectValvePositionMetadata: + allOf: + - $ref: '#/components/schemas/EquipmentMeasurementModeBase' + - $ref: '#/components/schemas/ValvePositionFields' + - $ref: '#/components/schemas/GenericObjectObjectTypeFields' + unevaluatedProperties: false + + GenericObjectPumpSpeedMetadata: + allOf: + - $ref: '#/components/schemas/EquipmentMeasurementModeBase' + - $ref: '#/components/schemas/PumpSpeedFields' + - $ref: '#/components/schemas/GenericObjectObjectTypeFields' + unevaluatedProperties: false + + GenericObjectFanSpeedMetadata: + allOf: + - $ref: '#/components/schemas/EquipmentMeasurementModeBase' + - $ref: '#/components/schemas/FanSpeedFields' + - $ref: '#/components/schemas/GenericObjectObjectTypeFields' + unevaluatedProperties: false + + GenericObjectDamperPositionMetadata: + allOf: + - $ref: '#/components/schemas/EquipmentMeasurementModeBase' + - $ref: '#/components/schemas/DamperPositionFields' + - $ref: '#/components/schemas/GenericObjectObjectTypeFields' + unevaluatedProperties: false + + GenericObjectAirTemperatureMetadata: + allOf: + - $ref: '#/components/schemas/EquipmentMetadataBase' + - $ref: '#/components/schemas/AirTemperatureFields' + - $ref: '#/components/schemas/GenericObjectObjectTypeFields' + unevaluatedProperties: false + + GenericObjectAirDifferentialPressureMetadata: + allOf: + - $ref: '#/components/schemas/EquipmentMeasurementModeBase' + - $ref: '#/components/schemas/AirDifferentialPressureFields' + - $ref: '#/components/schemas/GenericObjectObjectTypeFields' + unevaluatedProperties: false + + GenericObjectAirFlowMetadata: + allOf: + - $ref: '#/components/schemas/EquipmentMeasurementModeBase' + - $ref: '#/components/schemas/AirFlowFields' + - $ref: '#/components/schemas/GenericObjectObjectTypeFields' + unevaluatedProperties: false + + GenericObjectAirPressureMetadata: + allOf: + - $ref: '#/components/schemas/EquipmentMeasurementModeBase' + - $ref: '#/components/schemas/AirPressureFields' + - $ref: '#/components/schemas/GenericObjectObjectTypeFields' + unevaluatedProperties: false + + GenericObjectSoundMetadata: + allOf: + - $ref: '#/components/schemas/EquipmentMetadataBase' + - $ref: '#/components/schemas/SoundFields' + - $ref: '#/components/schemas/GenericObjectObjectTypeFields' + unevaluatedProperties: false + + GenericObjectLeakDetectMetadata: + allOf: + - $ref: '#/components/schemas/EquipmentMetadataBase' + - $ref: '#/components/schemas/LeakDetectFields' + - $ref: '#/components/schemas/GenericObjectObjectTypeFields' + unevaluatedProperties: false + + GenericObjectAirRelativeHumidityMetadata: + allOf: + - $ref: '#/components/schemas/EquipmentMeasurementModeBase' + - $ref: '#/components/schemas/AirRelativeHumidityFields' + - $ref: '#/components/schemas/GenericObjectObjectTypeFields' + unevaluatedProperties: false + + # --- GenericObject — Integration published -------------------------------- + + GenericObjectLiquidTemperatureSpRequestMetadata: + allOf: + - $ref: '#/components/schemas/EquipmentIntegrationMetadataBase' + - $ref: '#/components/schemas/GenericObjectLiquidTemperatureSpRequestFields' + unevaluatedProperties: false + + # ========================================================================= + # GenericPoint — field fragment and composed schemas + # ========================================================================= + + GenericPointFields: + type: object + description: > + Field fragment for a vendor-specific or unmapped GenericPoint. + `processArea` is required. + + + `engUnit` and `stateText` are both optional but **mutually exclusive** — + include at most one. See the two variants below. + required: + - processArea + properties: + pointType: + type: string + enum: [GenericPoint] + processArea: + type: array + items: + type: string + description: > + Required for GenericPoint. Describes the measurement context. + phase: + type: string + enum: [A, B, C, "1", "2", "3"] + description: > + Optional electrical phase identifier. Use for phase-specific + generic measurements on power-capable equipment. Letter form + (A/B/C) and numeric form (1/2/3) are both accepted. + integration: + type: string + description: > + Optional integration identifier. When present, this integration + is responsible for publishing the value for this point. + The value topic is derived using the standard topic derivation rule. + isSetpoint: + type: boolean + description: > + Optional. When true, indicates this point is a setpoint (a target + value written to control equipment behavior). + anyOf: + - title: Measurement + description: > + Continuous measurement with a known engineering unit. + When `engUnit` is present, `stateText` must be absent. + properties: + engUnit: + type: string + description: > + Engineering unit for the measurement. + not: + required: [stateText] + - title: State + description: > + Binary or enumerated state point. + When `stateText` is present, `engUnit` must be absent. + properties: + stateText: + type: array + description: > + State label mapping. Each entry maps a numeric state value to its + human-readable label (e.g., `[{value: 0, text: "Off"}, {value: 1, text: "On"}]`). + items: + type: object + required: [value, text] + properties: + value: + type: integer + description: Numeric state value. + text: + type: string + description: Human-readable label for this state. + additionalProperties: false + not: + required: [engUnit] + + + GenericEquipmentPointMetadata: + description: | + Metadata for a GenericPoint on any generic equipment objectType. + + Follows the standard equipment identifier modes: + - **Named-object mode**: objectName + objectId required; servesId + optional; associateId prohibited. + - **Associate mode**: associateId required; objectName/objectId/servesId + prohibited. + + Unlike typed points, `engUnit` and `stateText` are both optional. + `processArea` is required. + allOf: + - $ref: '#/components/schemas/EquipmentMetadataBase' + - $ref: '#/components/schemas/GenericPointFields' + unevaluatedProperties: false + + GenericPowerMeterPointMetadata: + description: | + Metadata for a GenericPoint on PowerMeter equipment. + Inherits the standard PowerMeter identifiers + (objectName, objectId, servesId all required). + `processArea` is required. `engUnit`, `stateText`, `phase`, and + `integration` are optional. + allOf: + - $ref: '#/components/schemas/PowerMeterMetadataBase' + - $ref: '#/components/schemas/GenericPointFields' + unevaluatedProperties: false diff --git a/mcp/dsx-exchange-mcp/schemas/asyncapi/launch-layer/notifications.yaml b/mcp/dsx-exchange-mcp/schemas/asyncapi/launch-layer/notifications.yaml new file mode 100644 index 0000000..4540794 --- /dev/null +++ b/mcp/dsx-exchange-mcp/schemas/asyncapi/launch-layer/notifications.yaml @@ -0,0 +1,2 @@ +# Copyright 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 diff --git a/mcp/dsx-exchange-mcp/schemas/asyncapi/mission-control/leak-response.yaml b/mcp/dsx-exchange-mcp/schemas/asyncapi/mission-control/leak-response.yaml new file mode 100644 index 0000000..4540794 --- /dev/null +++ b/mcp/dsx-exchange-mcp/schemas/asyncapi/mission-control/leak-response.yaml @@ -0,0 +1,2 @@ +# Copyright 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 diff --git a/mcp/dsx-exchange-mcp/schemas/asyncapi/monitoring/break-fix.yaml b/mcp/dsx-exchange-mcp/schemas/asyncapi/monitoring/break-fix.yaml new file mode 100644 index 0000000..4540794 --- /dev/null +++ b/mcp/dsx-exchange-mcp/schemas/asyncapi/monitoring/break-fix.yaml @@ -0,0 +1,2 @@ +# Copyright 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 diff --git a/mcp/dsx-exchange-mcp/schemas/asyncapi/nico/nico.yaml b/mcp/dsx-exchange-mcp/schemas/asyncapi/nico/nico.yaml new file mode 100644 index 0000000..0e2420f --- /dev/null +++ b/mcp/dsx-exchange-mcp/schemas/asyncapi/nico/nico.yaml @@ -0,0 +1,1796 @@ +# Copyright 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +asyncapi: 3.1.0 +info: + title: NICo Managed Host State Event Bus + version: 1.0.0 + description: > + AsyncAPI specification for NICo Managed Host State change events. + This spec defines the message structure for publishing managed host state + transitions and related metadata including DPU states, host initialization, + validation, assignment, and reprovisioning states. + + The MQTT topic path should follow the following format: + + - **State Changes**: NICO/v1/machine/{machineId}/state + +servers: + production: + host: broker.example.com + protocol: mqtt + description: MQTT broker for NICo managed host state events + +channels: + managedHostState: + address: "NICO/v1/machine/{machineId}/state" + parameters: + machineId: + description: Unique identifier for the managed host machine. + messages: + stateChangeMessage: + $ref: "#/components/messages/ManagedHostStateChangeMessage" + +operations: + publishManagedHostStateChange: + action: send + channel: + $ref: "#/channels/managedHostState" + messages: + - $ref: "#/channels/managedHostState/messages/stateChangeMessage" + description: > + Publish managed host state change events when a host transitions + between states in the lifecycle. + + subscribeManagedHostStateChange: + action: receive + channel: + $ref: "#/channels/managedHostState" + messages: + - $ref: "#/channels/managedHostState/messages/stateChangeMessage" + description: > + Subscribe to managed host state change events for monitoring, + orchestration, and automation purposes. + +components: + messages: + ManagedHostStateChangeMessage: + name: ManagedHostStateChangeMessage + title: Managed Host State Change + payload: + type: object + required: + - machine_id + - managed_host_state + - timestamp + properties: + machine_id: + type: string + description: Unique identifier for the managed host machine. + timestamp: + type: string + format: date-time + description: ISO 8601 timestamp of the state change. + managed_host_state: + $ref: "#/components/schemas/ManagedHostState" + description: The managed host state object with state name and details. + + schemas: + ManagedHostState: + description: + Possible Machine state-machine implementation Possible ManagedHost state-machine implementation Only DPU machine + field in DB will contain state. Host will be empty. DPU state field will be used to derive state for DPU and Host both. + oneOf: + - description: Dpu was discovered by a site-explorer and is being configuring via redfish. + type: object + required: + - dpu_states + - state + properties: + dpu_states: + $ref: "#/components/schemas/DpuDiscoveringStates" + state: + type: string + enum: + - dpudiscoveringstate + - description: DPU is not yet ready. + type: object + required: + - dpu_states + - state + properties: + dpu_states: + $ref: "#/components/schemas/DpuInitStates" + state: + type: string + enum: + - dpuinit + - description: DPU is ready, Host is not yet Ready. + type: object + required: + - machine_state + - state + properties: + machine_state: + $ref: "#/components/schemas/MachineState" + state: + type: string + enum: + - hostinit + - description: Host validation state for machine and DPU validation + type: object + required: + - state + - validation_state + properties: + state: + type: string + enum: + - validation + validation_state: + $ref: "#/components/schemas/ValidationState" + - description: Host is Ready for instance creation. + type: object + required: + - state + properties: + state: + type: string + enum: + - ready + - description: Host is assigned to an Instance. + type: object + required: + - instance_state + - state + properties: + instance_state: + $ref: "#/components/schemas/InstanceState" + state: + type: string + enum: + - assigned + - description: Some cleanup is going on. + type: object + required: + - cleanup_state + - state + properties: + cleanup_state: + $ref: "#/components/schemas/CleanupState" + state: + type: string + enum: + - waitingforcleanup + - description: + A forced deletion process has been triggered by the admin CLI State controller will no longer manage the + Machine + type: object + required: + - state + properties: + state: + type: string + enum: + - forcedeletion + - description: A dummy state used to create DPU in beginning. State will sync to Init when host will be created. + type: object + required: + - state + properties: + state: + type: string + enum: + - created + - description: Machine moved to failed state. Recovery will be based on FailedCause + type: object + required: + - details + - machine_id + - state + properties: + details: + $ref: "#/components/schemas/FailureDetails" + machine_id: + $ref: "#/components/schemas/MachineId" + retry_count: + default: 0 + type: integer + format: uint32 + minimum: 0.0 + state: + type: string + enum: + - failed + - description: State used to indicate that DPU reprovisioning is going on. + type: object + required: + - dpu_states + - state + properties: + dpu_states: + $ref: "#/components/schemas/DpuReprovisionStates" + state: + type: string + enum: + - dpureprovision + - description: State used to indicate that host reprovisioning is going on + type: object + required: + - reprovision_state + - state + properties: + reprovision_state: + $ref: "#/components/schemas/HostReprovisionState" + retry_count: + default: 0 + type: integer + format: uint32 + minimum: 0.0 + state: + type: string + enum: + - hostreprovision + - description: + State used to indicate the API is currently waiting on the machine to send attestation measurements, or waiting + for measurements to match a valid/approved measurement bundle, before continuing on towards a Ready state. + type: object + required: + - measuring_state + - state + properties: + measuring_state: + $ref: "#/components/schemas/MeasuringState" + state: + type: string + enum: + - measuring + - type: object + required: + - measuring_state + - state + properties: + measuring_state: + $ref: "#/components/schemas/MeasuringState" + state: + type: string + enum: + - postassignedmeasuring + - type: object + required: + - bom_validating_state + - state + properties: + bom_validating_state: + $ref: "#/components/schemas/BomValidating" + state: + type: string + enum: + - bomvalidating + BmcFirmwareUpgradeSubstate: + oneOf: + - type: object + required: + - bmcfirmwareupdatesubstate + properties: + bmcfirmwareupdatesubstate: + type: string + enum: + - checkfwversion + - type: object + required: + - bmcfirmwareupdatesubstate + - firmware_type + - task_id + properties: + bmcfirmwareupdatesubstate: + type: string + enum: + - waitforupdatecompletion + firmware_type: + $ref: "#/components/schemas/FirmwareComponentType" + task_id: + type: string + - type: object + required: + - bmcfirmwareupdatesubstate + - count + properties: + bmcfirmwareupdatesubstate: + type: string + enum: + - reboot + count: + type: integer + format: uint32 + minimum: 0.0 + - type: object + required: + - bmcfirmwareupdatesubstate + properties: + bmcfirmwareupdatesubstate: + type: string + enum: + - waitforerotbackgroundcopytocomplete + - type: object + required: + - bmcfirmwareupdatesubstate + properties: + bmcfirmwareupdatesubstate: + type: string + enum: + - hostpowercycle + - type: object + required: + - bmcfirmwareupdatesubstate + - failure_details + properties: + bmcfirmwareupdatesubstate: + type: string + enum: + - failed + failure_details: + type: string + - type: object + required: + - bmcfirmwareupdatesubstate + properties: + bmcfirmwareupdatesubstate: + type: string + enum: + - fwupdatecompleted + BomValidating: + oneOf: + - type: object + required: + - MatchingSku + properties: + MatchingSku: + $ref: "#/components/schemas/BomValidatingContext" + additionalProperties: false + - type: object + required: + - UpdatingInventory + properties: + UpdatingInventory: + $ref: "#/components/schemas/BomValidatingContext" + additionalProperties: false + - type: object + required: + - VerifyingSku + properties: + VerifyingSku: + $ref: "#/components/schemas/BomValidatingContext" + additionalProperties: false + - type: object + required: + - SkuVerificationFailed + properties: + SkuVerificationFailed: + $ref: "#/components/schemas/BomValidatingContext" + additionalProperties: false + - type: object + required: + - WaitingForSkuAssignment + properties: + WaitingForSkuAssignment: + $ref: "#/components/schemas/BomValidatingContext" + additionalProperties: false + - type: object + required: + - SkuMissing + properties: + SkuMissing: + $ref: "#/components/schemas/BomValidatingContext" + additionalProperties: false + BomValidatingContext: + description: A context for passing information between states thoughout the BOM validation process. + type: object + properties: + machine_validation_context: + type: + - string + - "null" + reboot_retry_count: + type: + - integer + - "null" + format: int64 + CleanupState: + oneOf: + - type: object + required: + - state + properties: + state: + type: string + enum: + - init + - type: object + required: + - secure_erase_boss_context + - state + properties: + secure_erase_boss_context: + $ref: "#/components/schemas/SecureEraseBossContext" + state: + type: string + enum: + - secureeraseboss + - type: object + required: + - state + properties: + boss_controller_id: + type: + - string + - "null" + state: + type: string + enum: + - hostcleanup + - type: object + required: + - create_boss_volume_context + - state + properties: + create_boss_volume_context: + $ref: "#/components/schemas/CreateBossVolumeContext" + state: + type: string + enum: + - createbossvolume + - type: object + required: + - state + properties: + state: + type: string + enum: + - disablebiosbmclockdown + CreateBossVolumeContext: + type: object + required: + - boss_controller_id + - create_boss_volume_state + properties: + boss_controller_id: + type: string + create_boss_volume_jid: + type: + - string + - "null" + create_boss_volume_state: + $ref: "#/components/schemas/CreateBossVolumeState" + iteration: + type: + - integer + - "null" + format: uint32 + minimum: 0.0 + CreateBossVolumeState: + oneOf: + - type: object + required: + - state + properties: + state: + type: string + enum: + - createbossvolume + - type: object + required: + - state + properties: + state: + type: string + enum: + - waitforjobscheduled + - type: object + required: + - state + properties: + state: + type: string + enum: + - reboothost + - type: object + required: + - state + properties: + state: + type: string + enum: + - waitforjobcompletion + - type: object + required: + - failure + - power_state + - state + properties: + failure: + type: string + power_state: + type: string + state: + type: string + enum: + - handlejobfailure + - type: object + required: + - state + properties: + state: + type: string + enum: + - lockhost + DpuDiscoveringState: + oneOf: + - description: Dpu discovery via redfish states + type: object + required: + - dpudiscoverystate + properties: + dpudiscoverystate: + type: string + enum: + - initializing + - type: object + required: + - dpudiscoverystate + properties: + dpudiscoverystate: + type: string + enum: + - configuring + - type: object + required: + - dpudiscoverystate + properties: + dpudiscoverystate: + type: string + enum: + - rebootalldpus + - type: object + required: + - count + - dpudiscoverystate + - enable_secure_boot_state + properties: + count: + type: integer + format: uint32 + minimum: 0.0 + dpudiscoverystate: + type: string + enum: + - enablesecureboot + enable_secure_boot_state: + $ref: "#/components/schemas/SetSecureBootState" + - type: object + required: + - count + - dpudiscoverystate + properties: + count: + type: integer + format: uint32 + minimum: 0.0 + disable_secure_boot_state: + anyOf: + - $ref: "#/components/schemas/SetSecureBootState" + - type: "null" + dpudiscoverystate: + type: string + enum: + - disablesecureboot + - type: object + required: + - dpudiscoverystate + properties: + dpudiscoverystate: + type: string + enum: + - setuefihttpboot + - type: object + required: + - dpudiscoverystate + properties: + dpudiscoverystate: + type: string + enum: + - enablershim + DpuDiscoveringStates: + type: object + required: + - states + properties: + states: + type: object + additionalProperties: + $ref: "#/components/schemas/DpuDiscoveringState" + DpuInitState: + oneOf: + - type: object + required: + - dpustate + - substate + properties: + dpustate: + type: string + enum: + - installdpuos + substate: + $ref: "#/components/schemas/InstallDpuOsState" + - type: object + required: + - dpustate + properties: + dpustate: + type: string + enum: + - init + - type: object + required: + - dpustate + - substate + properties: + dpustate: + type: string + enum: + - waitingforplatformpowercycle + substate: + $ref: "#/components/schemas/PerformPowerOperation" + - type: object + required: + - dpustate + properties: + dpustate: + type: string + enum: + - waitingforplatformconfiguration + - type: object + required: + - dpustate + properties: + dpustate: + type: string + enum: + - pollingbiossetup + - type: object + required: + - dpustate + properties: + dpustate: + type: string + enum: + - waitingfornetworkconfig + - type: object + required: + - dpustate + properties: + dpustate: + type: string + enum: + - waitingfornetworkinstall + DpuInitStates: + type: object + required: + - states + properties: + states: + type: object + additionalProperties: + $ref: "#/components/schemas/DpuInitState" + DpuReprovisionStates: + type: object + required: + - states + properties: + states: + type: object + additionalProperties: + $ref: "#/components/schemas/ReprovisionState" + FailureCause: + oneOf: + - type: string + enum: + - noerror + - type: object + required: + - nvmecleanfailed + properties: + nvmecleanfailed: + type: object + required: + - err + properties: + err: + type: string + additionalProperties: false + - type: object + required: + - discovery + properties: + discovery: + type: object + required: + - err + properties: + err: + type: string + additionalProperties: false + - type: object + required: + - reprovisioning + properties: + reprovisioning: + type: object + required: + - err + properties: + err: + type: string + additionalProperties: false + - type: object + required: + - machinevalidation + properties: + machinevalidation: + type: object + required: + - err + properties: + err: + type: string + additionalProperties: false + - type: object + required: + - unhandledstate + properties: + unhandledstate: + type: object + required: + - err + properties: + err: + type: string + additionalProperties: false + - type: object + required: + - measurementsfailedsignaturecheck + properties: + measurementsfailedsignaturecheck: + type: object + required: + - err + properties: + err: + type: string + additionalProperties: false + - type: object + required: + - measurementsretired + properties: + measurementsretired: + type: object + required: + - err + properties: + err: + type: string + additionalProperties: false + - type: object + required: + - measurementsrevoked + properties: + measurementsrevoked: + type: object + required: + - err + properties: + err: + type: string + additionalProperties: false + - type: object + required: + - measurementscavalidationfailed + properties: + measurementscavalidationfailed: + type: object + required: + - err + properties: + err: + type: string + additionalProperties: false + FailureDetails: + type: object + required: + - cause + - failed_at + - source + properties: + cause: + $ref: "#/components/schemas/FailureCause" + failed_at: + type: string + format: date-time + source: + $ref: "#/components/schemas/FailureSource" + FailureSource: + oneOf: + - type: string + enum: + - noerror + - scout + - statemachine + - type: object + required: + - statemachinearea + properties: + statemachinearea: + $ref: "#/components/schemas/StateMachineArea" + additionalProperties: false + FirmwareComponentType: + type: string + enum: + - bmc + - cec + - uefi + - nic + - cpldmb + - cpldpdb + - hgxbmc + - combinedbmcuefi + - gpu + - unknown + HostPlatformConfigurationState: + oneOf: + - type: object + required: + - power_on + - state + properties: + power_on: + type: boolean + state: + type: string + enum: + - powercycle + - type: object + required: + - state + properties: + state: + type: string + enum: + - checkhostconfig + - type: object + required: + - state + properties: + state: + type: string + enum: + - unlockhost + - type: object + required: + - state + properties: + state: + type: string + enum: + - configurebios + - type: object + required: + - state + properties: + state: + type: string + enum: + - pollingbiossetup + - type: object + required: + - set_boot_order_info + - state + properties: + set_boot_order_info: + $ref: "#/components/schemas/SetBootOrderInfo" + state: + type: string + enum: + - setbootorder + - type: object + required: + - state + properties: + state: + type: string + enum: + - lockhost + HostReprovisionState: + oneOf: + - type: string + enum: + - checkingfirmware + - checkingfirmwarerepeat + - type: object + required: + - initialreset + properties: + initialreset: + type: object + required: + - last_time + - phase + properties: + last_time: + type: string + format: date-time + phase: + $ref: "#/components/schemas/InitialResetPhase" + additionalProperties: false + - type: object + required: + - waitingforscript + properties: + waitingforscript: + type: object + additionalProperties: false + - type: object + required: + - waitingforupload + properties: + waitingforupload: + type: object + required: + - final_version + - firmware_type + properties: + final_version: + type: string + firmware_number: + type: + - integer + - "null" + format: uint32 + minimum: 0.0 + firmware_type: + $ref: "#/components/schemas/FirmwareComponentType" + power_drains_needed: + type: + - integer + - "null" + format: uint32 + minimum: 0.0 + additionalProperties: false + - type: object + required: + - waitingforfirmwareupgrade + properties: + waitingforfirmwareupgrade: + type: object + required: + - final_version + - firmware_type + - task_id + properties: + final_version: + type: string + firmware_number: + type: + - integer + - "null" + format: uint32 + minimum: 0.0 + firmware_type: + $ref: "#/components/schemas/FirmwareComponentType" + power_drains_needed: + type: + - integer + - "null" + format: uint32 + minimum: 0.0 + started_waiting: + type: + - string + - "null" + format: date-time + task_id: + type: string + additionalProperties: false + - type: object + required: + - resetfornewfirmware + properties: + resetfornewfirmware: + type: object + required: + - final_version + - firmware_type + properties: + delay_until: + type: + - integer + - "null" + format: int64 + final_version: + type: string + firmware_type: + $ref: "#/components/schemas/FirmwareComponentType" + last_power_drain_operation: + anyOf: + - $ref: "#/components/schemas/PowerDrainState" + - type: "null" + power_drains_needed: + type: + - integer + - "null" + format: uint32 + minimum: 0.0 + additionalProperties: false + - type: object + required: + - newfirmwarereportedwait + properties: + newfirmwarereportedwait: + type: object + required: + - final_version + - firmware_type + properties: + final_version: + type: string + firmware_type: + $ref: "#/components/schemas/FirmwareComponentType" + previous_reset_time: + type: + - integer + - "null" + format: int64 + additionalProperties: false + - type: object + required: + - failedfirmwareupgrade + properties: + failedfirmwareupgrade: + type: object + required: + - firmware_type + properties: + firmware_type: + $ref: "#/components/schemas/FirmwareComponentType" + reason: + type: + - string + - "null" + report_time: + type: + - string + - "null" + format: date-time + additionalProperties: false + InitialResetPhase: + type: string + enum: + - start + - bmcwasreset + - waithostboot + InstallDpuOsState: + oneOf: + - type: object + required: + - installdpuosstate + properties: + installdpuosstate: + type: string + enum: + - installingbfb + - type: object + required: + - installdpuosstate + - progress + - task_id + properties: + installdpuosstate: + type: string + enum: + - waitforinstallcomplete + progress: + type: string + task_id: + type: string + - type: object + required: + - installdpuosstate + properties: + installdpuosstate: + type: string + enum: + - completed + - type: object + required: + - installdpuosstate + - msg + properties: + installdpuosstate: + type: string + enum: + - installationerror + msg: + type: string + InstanceState: + description: Possible Instance state-machine implementation, for when the machine host is assigned to a tenant + oneOf: + - type: object + required: + - state + properties: + state: + type: string + enum: + - init + - type: object + required: + - state + properties: + state: + type: string + enum: + - waitingfornetworksegmenttobeready + - type: object + required: + - state + properties: + state: + type: string + enum: + - waitingfornetworkconfig + - type: object + required: + - state + properties: + state: + type: string + enum: + - waitingforstorageconfig + - type: object + required: + - state + properties: + state: + type: string + enum: + - dpaprovisioning + - type: object + required: + - state + properties: + state: + type: string + enum: + - waitingfordpatobeready + - type: object + required: + - state + properties: + state: + type: string + enum: + - waitingforextensionservicesconfig + - type: object + required: + - state + properties: + state: + type: string + enum: + - waitingforreboottoready + - type: object + required: + - state + properties: + state: + type: string + enum: + - ready + - type: object + required: + - platform_config_state + - state + properties: + platform_config_state: + $ref: "#/components/schemas/HostPlatformConfigurationState" + state: + type: string + enum: + - hostplatformconfiguration + - type: object + required: + - state + properties: + state: + type: string + enum: + - waitingfordpustoup + - type: object + required: + - state + properties: + retry: + default: + count: 0 + allOf: + - $ref: "#/components/schemas/RetryInfo" + state: + type: string + enum: + - bootingwithdiscoveryimage + - type: object + required: + - state + properties: + state: + type: string + enum: + - switchtoadminnetwork + - type: object + required: + - state + properties: + state: + type: string + enum: + - waitingfornetworkreconfig + - type: object + required: + - dpu_states + - state + properties: + dpu_states: + $ref: "#/components/schemas/DpuReprovisionStates" + state: + type: string + enum: + - dpureprovision + - type: object + required: + - details + - machine_id + - state + properties: + details: + $ref: "#/components/schemas/FailureDetails" + machine_id: + $ref: "#/components/schemas/MachineId" + state: + type: string + enum: + - failed + - type: object + required: + - reprovision_state + - state + properties: + reprovision_state: + $ref: "#/components/schemas/HostReprovisionState" + state: + type: string + enum: + - hostreprovision + - type: object + required: + - network_config_update_state + - state + properties: + network_config_update_state: + $ref: "#/components/schemas/NetworkConfigUpdateState" + state: + type: string + enum: + - networkconfigupdate + LockdownInfo: + type: object + required: + - mode + - state + properties: + mode: + $ref: "#/components/schemas/LockdownMode" + state: + $ref: "#/components/schemas/LockdownState" + LockdownMode: + description: Whether lockdown should be enabled or disabled in an operation + type: string + enum: + - enable + - disable + LockdownState: + type: string + enum: + - setlockdown + - timewaitfordpudown + - waitfordpuup + - pollinglockdownstatus + MachineId: + type: string + MachineState: + oneOf: + - type: object + required: + - state + properties: + state: + type: string + enum: + - init + - type: object + required: + - state + properties: + state: + type: string + enum: + - enableipmioverlan + - type: object + required: + - state + properties: + state: + type: string + enum: + - waitingforplatformconfiguration + - type: object + required: + - state + properties: + state: + type: string + enum: + - pollingbiossetup + - type: object + required: + - state + properties: + set_boot_order_info: + anyOf: + - $ref: "#/components/schemas/SetBootOrderInfo" + - type: "null" + state: + type: string + enum: + - setbootorder + - type: object + required: + - state + - uefi_setup_info + properties: + state: + type: string + enum: + - uefisetup + uefi_setup_info: + $ref: "#/components/schemas/UefiSetupInfo" + - type: object + required: + - measuring_state + - state + properties: + measuring_state: + $ref: "#/components/schemas/MeasuringState" + state: + type: string + enum: + - measuring + - type: object + required: + - state + properties: + state: + type: string + enum: + - waitingfordiscovery + - type: object + required: + - state + properties: + skip_reboot_wait: + default: false + type: boolean + state: + type: string + enum: + - discovered + - description: Lockdown handling. + type: object + required: + - lockdown_info + - state + properties: + lockdown_info: + $ref: "#/components/schemas/LockdownInfo" + state: + type: string + enum: + - waitingforlockdown + MachineValidatingState: + oneOf: + - type: object + required: + - reboothost + properties: + reboothost: + type: object + required: + - validation_id + properties: + validation_id: + type: string + format: uuid + additionalProperties: false + - type: object + required: + - machinevalidating + properties: + machinevalidating: + type: object + required: + - completed + - context + - id + - total + properties: + completed: + type: integer + format: uint + minimum: 0.0 + context: + type: string + id: + type: string + format: uuid + is_enabled: + default: true + type: boolean + total: + type: integer + format: uint + minimum: 0.0 + additionalProperties: false + MeasuringState: + description: MeasuringState contains states used for host attestion (or measured boot). + oneOf: + - description: + WaitingForMeasurements is reported when the machine has reached a state where the API is now expecting measurements + from the machine, which Scout sends upon receiving an Action::Measure from the API. + type: string + enum: + - waitingformeasurements + - description: + PendingBundle is reported when the API has received measurements from the machine, but the measurements do + not match a known bundle. At this point, a matching bundle needs to be created, either via "promoting" a measurement + report from a machine (through manual interaction or trusted approval automation), or by manually creating a new bundle. + type: string + enum: + - pendingbundle + NetworkConfigUpdateState: + description: + Tenant has requested network config update for the existing instance. At this point, instance config, instance + network config version are already increased. + type: string + enum: + - waitingfornetworksegmenttobeready + - waitingforconfigsynced + - releaseoldresources + PerformPowerOperation: + oneOf: + - type: object + required: + - state + properties: + state: + type: string + enum: + - "off" + - type: object + required: + - state + properties: + state: + type: string + enum: + - "on" + PowerDrainState: + type: string + enum: + - "off" + - powercycle + - "on" + ReprovisionState: + oneOf: + - type: string + enum: + - firmwareupgrade + - waitingfornetworkinstall + - poweringoffhost + - powerdown + - buffertime + - verifyfirmareversions + - waitingfornetworkconfig + - reboothostbmc + - reboothost + - notunderreprovision + - type: object + required: + - bmcfirmwareupgrade + properties: + bmcfirmwareupgrade: + type: object + required: + - substate + properties: + substate: + $ref: "#/components/schemas/BmcFirmwareUpgradeSubstate" + additionalProperties: false + - type: object + required: + - installdpuos + properties: + installdpuos: + type: object + required: + - substate + properties: + substate: + $ref: "#/components/schemas/InstallDpuOsState" + additionalProperties: false + RetryInfo: + type: object + required: + - count + properties: + count: + type: integer + format: uint64 + minimum: 0.0 + SecureEraseBossContext: + type: object + required: + - boss_controller_id + - secure_erase_boss_state + properties: + boss_controller_id: + type: string + iteration: + type: + - integer + - "null" + format: uint32 + minimum: 0.0 + secure_erase_boss_state: + $ref: "#/components/schemas/SecureEraseBossState" + secure_erase_jid: + type: + - string + - "null" + SecureEraseBossState: + oneOf: + - type: object + required: + - state + properties: + state: + type: string + enum: + - unlockhost + - type: object + required: + - state + properties: + state: + type: string + enum: + - secureeraseboss + - type: object + required: + - state + properties: + state: + type: string + enum: + - waitforjobcompletion + - type: object + required: + - failure + - power_state + - state + properties: + failure: + type: string + power_state: + type: string + state: + type: string + enum: + - handlejobfailure + SetBootOrderInfo: + type: object + required: + - set_boot_order_state + properties: + set_boot_order_jid: + type: + - string + - "null" + set_boot_order_state: + $ref: "#/components/schemas/SetBootOrderState" + SetBootOrderState: + oneOf: + - type: object + required: + - state + properties: + state: + type: string + enum: + - setbootorder + - type: object + required: + - state + properties: + state: + type: string + enum: + - waitforsetbootorderjobscheduled + - type: object + required: + - state + properties: + state: + type: string + enum: + - reboothost + - type: object + required: + - state + properties: + state: + type: string + enum: + - waitforsetbootorderjobcompletion + SetSecureBootState: + oneOf: + - type: object + required: + - disablesecurebootstate + properties: + disablesecurebootstate: + type: string + enum: + - checksecurebootstatus + - type: object + required: + - disablesecurebootstate + properties: + disablesecurebootstate: + type: string + enum: + - disablesecureboot + - type: object + required: + - disablesecurebootstate + properties: + disablesecurebootstate: + type: string + enum: + - setsecureboot + - type: object + required: + - disablesecurebootstate + - reboot_count + properties: + disablesecurebootstate: + type: string + enum: + - rebootdpu + reboot_count: + type: integer + format: uint32 + minimum: 0.0 + - type: object + required: + - disablesecurebootstate + - task_id + properties: + disablesecurebootstate: + type: string + enum: + - waitcertificateupload + task_id: + type: string + StateMachineArea: + type: string + enum: + - default + - hostinit + - mainflow + - assignedinstance + UefiSetupInfo: + type: object + required: + - uefi_setup_state + properties: + uefi_password_jid: + type: + - string + - "null" + uefi_setup_state: + $ref: "#/components/schemas/UefiSetupState" + UefiSetupState: + description: Substates of enabling/disabling lockdown + oneOf: + - type: object + required: + - state + properties: + state: + type: string + enum: + - unlockhost + - type: object + required: + - state + properties: + state: + type: string + enum: + - setuefipassword + - type: object + required: + - state + properties: + state: + type: string + enum: + - waitforpasswordjobscheduled + - type: object + required: + - state + properties: + state: + type: string + enum: + - powercyclehost + - type: object + required: + - state + properties: + state: + type: string + enum: + - waitforpasswordjobcompletion + - type: object + required: + - state + properties: + state: + type: string + enum: + - lockdownhost + ValidationState: + oneOf: + - description: + "Host machine validation placeholder for DPU machine validation TODO: add DPU validation state SKU validatioon + can also be moved here, so that all validation done @ one place" + type: object + required: + - machine_validation + - validation_type + properties: + machine_validation: + $ref: "#/components/schemas/MachineValidatingState" + validation_type: + type: string + enum: + - machinevalidation diff --git a/mcp/dsx-exchange-mcp/schemas/asyncapi/nke/node-readiness.yaml b/mcp/dsx-exchange-mcp/schemas/asyncapi/nke/node-readiness.yaml new file mode 100644 index 0000000..4540794 --- /dev/null +++ b/mcp/dsx-exchange-mcp/schemas/asyncapi/nke/node-readiness.yaml @@ -0,0 +1,2 @@ +# Copyright 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 diff --git a/mcp/dsx-exchange-mcp/schemas/asyncapi/power-management/power-management.yaml b/mcp/dsx-exchange-mcp/schemas/asyncapi/power-management/power-management.yaml new file mode 100644 index 0000000..3080e8d --- /dev/null +++ b/mcp/dsx-exchange-mcp/schemas/asyncapi/power-management/power-management.yaml @@ -0,0 +1,825 @@ +# Copyright 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +asyncapi: 3.1.0 +info: + title: Power Management + version: 1.7.0 + description: | + Real-time power management and emergency control plane for data center grid events. + + ## Protocol Binding + + This API uses the MQTT [Protocol Binding for CloudEvents v1.0.2](https://github.com/cloudevents/spec/blob/v1.0.2/cloudevents/bindings/mqtt-protocol-binding.md). + + **MQTT 3.1.1 Structured Content Mode:** + + - Content mode is always "structured" (MQTT 3.1.1 lacks custom metadata support) + - The entire CloudEvents message is in the MQTT PUBLISH payload as JSON + - Content-Type `application/cloudevents+json` is implied (no header in MQTT 3.1.1) + - All CloudEvents attributes and data are in the JSON payload + + ## CloudEvents Message Format + + All messages conform to [CloudEvents 1.0.2 specification](https://github.com/cloudevents/spec/blob/v1.0.2/cloudevents/spec.md) with W3C [Distributed Tracing Extension](https://github.com/cloudevents/spec/blob/v1.0.2/cloudevents/extensions/distributed-tracing.md). + + Each MQTT PUBLISH payload contains a JSON object with: + + - **specversion**: "1.0.2" (REQUIRED) + - **id**: UUIDv4 unique event identifier (REQUIRED) + - **source**: URI format //grid/v1// (REQUIRED) + - **type**: Event type e.g., grid.loadtarget.set.v1 (REQUIRED) + - **time**: ISO 8601 timestamp (REQUIRED) + - **datacontenttype**: "application/json" (REQUIRED) + - **subject**: Resource subject e.g., loadtarget (REQUIRED) + - **traceparent**: W3C Trace Context for distributed tracing (REQUIRED) + - **tracestate**: Optional vendor-specific trace context + - **correlationid**: UUIDv4 correlation identifier (OPTIONAL, CloudEvents extension) + - **data**: JWS-signed payload (string - see Data Field Security below) + + ## Source Format + + The source attribute is a stable URI that identifies the publisher of an event. + Format: ///// + + - namespace: grid + - version: v1 + - role: isv | poweragent | infra + - identifier: deployment-unique name assigned during registration + + Examples: + + - ISV: //grid/v1/isv/acme-energy + - Power Agent: //grid/v1/poweragent/dps-prod + - Infra Agent: //grid/v1/infra/ima-prod + + ## Event Types + + - grid.loadtarget.set.v1 — ISV sets load target (fire-and-forget command) + - grid.powerstate.status.v1 — Power Agent publishes power state snapshots + - grid.powerbreach.alert.v1 — Power Agent publishes breach alerts + - grid.powerbreach.enforcement.v1 — Infra Agent publishes enforcement outcomes + + ## Security + + - Transport: MQTT 3.1.1 with TLS (mTLS or OAuth2 SSA) + - Data Field: MUST be signed (JWS RFC 7515) — broker never sees verified plaintext + + ## Data Field Security (JWS) + + The `data` field uses cryptographic protection per RFC 7515 (JWS): + + 1. **Sign (JWS)**: The JSON payload is signed using JWS (JSON Web Signature) + - Algorithm: ES256 (ECDSA using P-256 and SHA-256) or RS256 + - Provides: Data integrity and authenticity + - The `kid` (Key ID) in JWS header identifies the signing key + + 2. **Result**: The `data` field contains a JWS compact serialization string: + `..` + + **Processing Order:** + + - Sender: JSON payload → JWS sign → data field + - Receiver: data field → JWS verify → JSON payload + + ## Distributed Tracing + + All participants MUST propagate traceparent/tracestate for end-to-end visibility. + contact: + name: Grid Platform Team + email: grid-platform@nvidia.com + +defaultContentType: application/cloudevents+json + +servers: + production: + host: broker.example.com + protocol: mqtt + description: MQTT broker for power management grid events + +channels: + loadTargetSetChannel: + address: grid/v1/isv/{identifier}/loadtarget/set + parameters: + identifier: + $ref: '#/components/parameters/identifier' + messages: + loadTargetSet: + $ref: '#/components/messages/LoadTargetSetMessage' + description: | + ISV publishes load target commands. Fire-and-forget pattern — the ISV + does not wait for a response. Confirmation comes via grid.powerstate.status.v1. + bindings: + mqtt: + qos: 1 + + powerStateStatusChannel: + address: grid/v1/poweragent/{identifier}/powerstate/status + parameters: + identifier: + $ref: '#/components/parameters/identifier' + messages: + powerStateStatus: + $ref: '#/components/messages/PowerStateStatusMessage' + description: | + Power Management Agent publishes full snapshots of current power state + for all feeds. Includes compliance, ramp state, and active/scheduled targets. + Published on state transitions and as periodic heartbeats. + bindings: + mqtt: + qos: 1 + + powerBreachAlertChannel: + address: grid/v1/poweragent/{identifier}/powerbreach + parameters: + identifier: + $ref: '#/components/parameters/identifier' + messages: + powerBreachAlert: + $ref: '#/components/messages/PowerBreachAlertMessage' + description: | + Power Management Agent publishes breach alerts when measured power exceeds + the active load target. Breach lifecycle: active → escalated → resolved. + All events in a single breach share the same breach_id. + bindings: + mqtt: + qos: 2 + + powerBreachEnforcementChannel: + address: grid/v1/infra/{infraAgentId}/powerbreach/enforcement + parameters: + infraAgentId: + $ref: '#/components/parameters/infraAgentId' + messages: + powerBreachEnforcement: + $ref: '#/components/messages/PowerBreachEnforcementMessage' + description: | + Infrastructure Management Agent publishes enforcement outcomes after + applying or reverting shed hints at the hardware level via Redfish BMC. + bindings: + mqtt: + qos: 2 + +operations: + publishLoadTargetSet: + action: send + channel: + $ref: '#/channels/loadTargetSetChannel' + summary: ISV → Power Management Agent (set load target command) + messages: + - $ref: '#/channels/loadTargetSetChannel/messages/loadTargetSet' + + subscribeLoadTargetSet: + action: receive + channel: + $ref: '#/channels/loadTargetSetChannel' + summary: Power Management Agent subscribes to load target commands + messages: + - $ref: '#/channels/loadTargetSetChannel/messages/loadTargetSet' + + publishPowerStateStatus: + action: send + channel: + $ref: '#/channels/powerStateStatusChannel' + summary: Power Management Agent → ISVs (power state snapshot) + messages: + - $ref: '#/channels/powerStateStatusChannel/messages/powerStateStatus' + + subscribePowerStateStatus: + action: receive + channel: + $ref: '#/channels/powerStateStatusChannel' + summary: ISVs subscribe to power state status updates + messages: + - $ref: '#/channels/powerStateStatusChannel/messages/powerStateStatus' + + publishPowerBreachAlert: + action: send + channel: + $ref: '#/channels/powerBreachAlertChannel' + summary: Power Management Agent → Infra Agent + ISVs (breach alert) + messages: + - $ref: '#/channels/powerBreachAlertChannel/messages/powerBreachAlert' + + subscribePowerBreachAlert: + action: receive + channel: + $ref: '#/channels/powerBreachAlertChannel' + summary: Infra Agent and ISVs subscribe to breach alerts + messages: + - $ref: '#/channels/powerBreachAlertChannel/messages/powerBreachAlert' + + publishPowerBreachEnforcement: + action: send + channel: + $ref: '#/channels/powerBreachEnforcementChannel' + summary: Infra Agent → Power Management Agent + ISVs (enforcement outcome) + messages: + - $ref: '#/channels/powerBreachEnforcementChannel/messages/powerBreachEnforcement' + + subscribePowerBreachEnforcement: + action: receive + channel: + $ref: '#/channels/powerBreachEnforcementChannel' + summary: Power Management Agent and ISVs subscribe to enforcement outcomes + messages: + - $ref: '#/channels/powerBreachEnforcementChannel/messages/powerBreachEnforcement' + +components: + schemas: + # ========================================================================= + # Reusable Primitives + # ========================================================================= + + PowerUnit: + type: object + description: Power measurement with value and unit. + required: [value, unit] + properties: + value: + type: number + format: double + minimum: 0 + description: Power value (must be >= 0). + unit: + type: string + enum: [watt, kilowatt, megawatt] + description: Unit of measurement. + + Interval: + type: object + description: Time window for load target scheduling. + properties: + start_time: + type: string + format: date-time + description: ISO 8601 UTC start time. Defaults to now if omitted. + end_time: + type: string + format: date-time + description: ISO 8601 UTC end time. If omitted, target remains in effect until replaced. + + Strategy: + type: object + description: Load shedding strategy configuration. + properties: + best_effort: + type: boolean + default: false + description: | + If true, DPS sheds load non-disruptively by adjusting DPM-enabled jobs. + If the constraint cannot be satisfied, DPS sheds as much as possible and + leaves the feed in a non-compliant state until workloads terminate. + + LoadConstraint: + type: object + description: Power limit constraint. + required: [value, unit] + properties: + value: + type: number + format: double + minimum: 0 + description: Target power limit (must be > 0). + unit: + type: string + enum: [watt, kilowatt, megawatt] + description: Unit of measurement. + + FeedTags: + type: array + description: | + List of feed identifiers this target applies to (e.g., ["main-a"]). + If omitted or empty, the target applies to all feeds. + items: + type: string + examples: + - [main-a, main-b] + + # ========================================================================= + # Breach and Enforcement Schemas + # ========================================================================= + + ShedHint: + type: object + description: | + Resource-level load shedding recommendation. Shed hints are non-binding + advisory suggestions. The Infrastructure Management Agent MAY override + or augment based on topology and operational criteria. + required: [resource_id, resource_type, action, priority] + properties: + resource_id: + type: string + description: Identifier of the resource to throttle. + resource_type: + type: string + enum: [node, gpu, chassis] + description: Type of resource. + action: + type: string + enum: [power_cap, throttle, suspend] + description: Recommended action. + priority: + type: integer + minimum: 1 + description: Shedding priority (1 = shed first, higher = shed later). + target_power: + allOf: + - $ref: '#/components/schemas/PowerUnit' + description: Suggested power cap. Present when action is power_cap. + + EnforcementResult: + type: object + description: Per-resource enforcement outcome. + required: [resource_id, resource_type, requested_action, code] + properties: + resource_id: + type: string + description: The resource identifier from the shed hint. + resource_type: + type: string + enum: [node, gpu, chassis] + description: Type of resource. + requested_action: + type: string + enum: [power_cap, throttle, suspend] + description: The action that was requested. + code: + type: string + enum: [applied, failed, skipped] + description: Per-resource enforcement status. + message: + type: string + description: Diagnostic message (e.g., "BMC unreachable after 3 retries"). + applied_power: + allOf: + - $ref: '#/components/schemas/PowerUnit' + description: The actual power cap applied, if different from the requested target_power. + + # ========================================================================= + # Feed State Schemas (for PowerStateStatus) + # ========================================================================= + + FeedMetadata: + type: object + description: Static feed configuration. Values don't change during a curtailment event. + required: [power_minimum, power_maximum, default_constraint] + properties: + power_minimum: + allOf: + - $ref: '#/components/schemas/PowerUnit' + description: Minimum power threshold for this feed. + power_maximum: + allOf: + - $ref: '#/components/schemas/PowerUnit' + description: Maximum power capacity for this feed. + default_constraint: + allOf: + - $ref: '#/components/schemas/PowerUnit' + description: Default power constraint when no load target is active. + + FeedTarget: + type: object + description: A load target for a feed — active or scheduled. + required: [active, load_constraint, interval, strategy, correlation_id] + properties: + active: + type: boolean + description: true if this target is currently being enforced. + load_constraint: + $ref: '#/components/schemas/LoadConstraint' + interval: + $ref: '#/components/schemas/Interval' + strategy: + $ref: '#/components/schemas/Strategy' + correlation_id: + type: string + format: uuid + description: The correlationid from the grid.loadtarget.set.v1 that created this target. + + FeedState: + type: object + description: Current power state for a single feed. + required: [metadata, targets, calculated_load, in_flight, event, compliant] + properties: + metadata: + $ref: '#/components/schemas/FeedMetadata' + targets: + type: array + description: All load targets for this feed — active and scheduled. Empty array if no targets exist. + items: + $ref: '#/components/schemas/FeedTarget' + calculated_load: + allOf: + - $ref: '#/components/schemas/PowerUnit' + description: | + Agent-calculated effective load after shedding strategy is applied. + May differ from actual measured power during ramp transitions. + in_flight: + type: boolean + description: true if the agent is actively transitioning between power levels. + event: + type: string + enum: + - target_set + - start_ramp_down + - end_ramp_down + - start_ramp_up + - end_ramp_up + - periodic + description: | + The trigger that caused this status to be published: + - target_set — a grid.loadtarget.set.v1 was received and schedule updated + - start_ramp_down — constraint applied or tightened, redistribution begins + - end_ramp_down — redistribution complete, load converged to target + - start_ramp_up — constraint relaxed or removed, redistribution begins + - end_ramp_up — redistribution complete + - periodic — regular heartbeat, no state change + power_event_start_time: + type: string + format: date-time + description: ISO 8601 UTC timestamp when the current power event began. null if no event is active. + compliant: + type: boolean + description: true if the calculated load is within the active target constraint. true when no target is active. + + # ========================================================================= + # Breach Detail Schemas + # ========================================================================= + + BreachDetails: + type: object + description: Breach identification and timing. + required: [breach_id, detected_at] + properties: + breach_id: + type: string + format: uuid + description: | + Unique identifier for this breach instance (UUIDv4). Remains the same + across active → escalated → resolved for the same breach. + detected_at: + type: string + format: date-time + description: ISO 8601 UTC timestamp when the breach was first detected. + resolved_at: + type: string + format: date-time + description: ISO 8601 UTC timestamp when the breach was resolved. Present only when status is resolved. + + BreachTarget: + type: object + description: The load target being breached. + required: [correlation_id, load_constraint] + properties: + correlation_id: + type: string + format: uuid + description: The correlationid from the grid.loadtarget.set.v1 that established this target. + load_constraint: + $ref: '#/components/schemas/LoadConstraint' + + # ========================================================================= + # Event Data Payloads + # ========================================================================= + + SetLoadTargetData: + type: object + description: Payload for grid.loadtarget.set.v1. + required: [targets] + properties: + targets: + type: array + description: List of load target requests. + minItems: 1 + items: + type: object + required: [interval, load_constraint] + properties: + interval: + $ref: '#/components/schemas/Interval' + feed_tags: + $ref: '#/components/schemas/FeedTags' + load_constraint: + description: | + Power constraint to apply. If null, removes any active constraint + and reverts the feed to its unconstrained default load level. + oneOf: + - $ref: '#/components/schemas/LoadConstraint' + - type: 'null' + strategy: + $ref: '#/components/schemas/Strategy' + + PowerStateStatusData: + type: object + description: Payload for grid.powerstate.status.v1. + required: [snapshot_time, feeds] + properties: + snapshot_time: + type: string + format: date-time + description: ISO 8601 UTC timestamp of this snapshot. + feeds: + type: object + description: Map of feed_tag to power state. + additionalProperties: + $ref: '#/components/schemas/FeedState' + + PowerBreachAlertData: + type: object + description: Payload for grid.powerbreach.alert.v1. + required: [feed_tag, status, severity, breach, target, measured_load, infrastructure_actions] + properties: + feed_tag: + type: string + description: The feed identifier where the breach was detected. + status: + type: string + enum: [active, escalated, resolved] + description: Breach lifecycle state. + severity: + type: string + enum: [warning, critical] + description: | + Breach severity level: + - warning — excess between threshold and 20% over target + - critical — excess exceeds 20% over target + breach: + $ref: '#/components/schemas/BreachDetails' + target: + $ref: '#/components/schemas/BreachTarget' + measured_load: + allOf: + - $ref: '#/components/schemas/PowerUnit' + description: Actual measured power at time of event. + shed_hints: + type: array + description: | + Ordered list of load shedding recommendations. Present when status + is active or escalated. Absent when resolved. + items: + $ref: '#/components/schemas/ShedHint' + infrastructure_actions: + type: array + description: | + Ordered list of escalating infrastructure-level actions. Empty array + when status is resolved. Ordered from least to most disruptive. + items: + type: string + enum: [SHUTDOWN_NODES, SHUTDOWN_RACKS, CUT_DATA_FEEDS, TRIP_BREAKERS] + + PowerBreachEnforcementData: + type: object + description: Payload for grid.powerbreach.enforcement.v1. + required: [breach_id, feed_tag, action, code, results, timestamp] + properties: + breach_id: + type: string + format: uuid + description: The breach_id from the originating grid.powerbreach.alert.v1 event. + feed_tag: + type: string + description: The feed identifier this enforcement applies to. + action: + type: string + enum: [applied, reverted] + description: What phase of enforcement this represents. + code: + type: string + enum: [success, partial, error] + description: Overall enforcement outcome. + diag_msg: + type: string + description: Human-readable diagnostic message. + results: + type: array + description: Per-resource enforcement outcome. + items: + $ref: '#/components/schemas/EnforcementResult' + timestamp: + type: string + format: date-time + description: ISO 8601 UTC timestamp when enforcement completed. + + # ========================================================================= + # JWS Data Field Security + # ========================================================================= + + JwsCompactSerialization: + type: string + description: | + JWS Compact Serialization (RFC 7515) containing a signed payload. + This is the WIRE FORMAT for the "data" field in CloudEvents messages. + + Structure: BASE64URL(JWS Header).BASE64URL(Payload).BASE64URL(Signature) + + JWS Header (example): + { + "alg": "ES256", + "kid": "" + } + + The payload, when verified, contains the JSON event data + as defined by the message type schema. + pattern: ^[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+$ + + # ========================================================================= + # Base CloudEvents Schema + # ========================================================================= + + BaseCloudEvent: + type: object + description: | + Common CloudEvents 1.0.2 attributes shared by all events. + Each event type extends this with a specific "data" field. + + The "data" field is transmitted as a JWS compact serialization string. + The plaintext JSON payload is signed with JWS (RFC 7515). + required: + - specversion + - id + - source + - type + - time + - datacontenttype + - subject + - traceparent + properties: + specversion: + type: string + const: '1.0.2' + description: MUST be "1.0.2" — version of the CloudEvents specification. + id: + type: string + format: uuid + description: MUST be a UUIDv4 — globally unique identifier for this event instance. + source: + type: string + pattern: ^//grid/v1/(isv|poweragent|infra)/[^/]+$ + description: | + Stable URI identifying the publisher. Format: //grid/v1//. + Examples: + - //grid/v1/isv/acme-energy + - //grid/v1/poweragent/dps-prod + - //grid/v1/infra/ima-prod + type: + type: string + description: Event type following grid...v1 or grid..status.v1. + time: + type: string + format: date-time + description: MUST be an ISO 8601 timestamp — time the event was created. + datacontenttype: + type: string + const: application/json + description: MUST be "application/json" — media type of the data payload. + subject: + type: string + description: Resource subject identifying the domain object (e.g., loadtarget, powerstate, powerbreach). + correlationid: + type: string + format: uuid + description: | + CloudEvents extension attribute. UUIDv4 correlation identifier. + Generated by the ISV on grid.loadtarget.set.v1. Echoed by the + Power Management Agent in status events for correlation. + traceparent: + type: string + pattern: ^[0-9a-f]{2}-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$ + description: | + REQUIRED per W3C Trace Context and CloudEvents Distributed Tracing Extension. + Format: version-traceid-parentid-flags. + tracestate: + type: string + description: | + OPTIONAL per W3C Trace Context. Carries vendor-specific contextual + information across tracing systems. + + # ========================================================================= + # Event-Specific CloudEvents Envelope Schemas + # ========================================================================= + + LoadTargetSetEvent: + description: CloudEvents envelope for grid.loadtarget.set.v1. + allOf: + - $ref: '#/components/schemas/BaseCloudEvent' + - type: object + required: [data] + properties: + type: + const: grid.loadtarget.set.v1 + data: + description: | + WIRE FORMAT: JWS compact serialization string (type: string). + PLAINTEXT CONTENT: After JWS verification, the payload conforms + to SetLoadTargetData schema (type: object). + oneOf: + - $ref: '#/components/schemas/JwsCompactSerialization' + - $ref: '#/components/schemas/SetLoadTargetData' + + PowerStateStatusEvent: + description: CloudEvents envelope for grid.powerstate.status.v1. + allOf: + - $ref: '#/components/schemas/BaseCloudEvent' + - type: object + required: [data] + properties: + type: + const: grid.powerstate.status.v1 + data: + description: | + WIRE FORMAT: JWS compact serialization string (type: string). + PLAINTEXT CONTENT: After JWS verification, the payload conforms + to PowerStateStatusData schema (type: object). + oneOf: + - $ref: '#/components/schemas/JwsCompactSerialization' + - $ref: '#/components/schemas/PowerStateStatusData' + + PowerBreachAlertEvent: + description: CloudEvents envelope for grid.powerbreach.alert.v1. + allOf: + - $ref: '#/components/schemas/BaseCloudEvent' + - type: object + required: [data] + properties: + type: + const: grid.powerbreach.alert.v1 + data: + description: | + WIRE FORMAT: JWS compact serialization string (type: string). + PLAINTEXT CONTENT: After JWS verification, the payload conforms + to PowerBreachAlertData schema (type: object). + oneOf: + - $ref: '#/components/schemas/JwsCompactSerialization' + - $ref: '#/components/schemas/PowerBreachAlertData' + + PowerBreachEnforcementEvent: + description: CloudEvents envelope for grid.powerbreach.enforcement.v1. + allOf: + - $ref: '#/components/schemas/BaseCloudEvent' + - type: object + required: [data] + properties: + type: + const: grid.powerbreach.enforcement.v1 + data: + description: | + WIRE FORMAT: JWS compact serialization string (type: string). + PLAINTEXT CONTENT: After JWS verification, the payload conforms + to PowerBreachEnforcementData schema (type: object). + oneOf: + - $ref: '#/components/schemas/JwsCompactSerialization' + - $ref: '#/components/schemas/PowerBreachEnforcementData' + + messages: + LoadTargetSetMessage: + name: LoadTargetSetMessage + title: Set Load Target + summary: ISV publishes load target command to the event bus. + contentType: application/cloudevents+json + payload: + $ref: '#/components/schemas/LoadTargetSetEvent' + + PowerStateStatusMessage: + name: PowerStateStatusMessage + title: Power State Status + summary: Power Management Agent publishes full power state snapshot. + contentType: application/cloudevents+json + payload: + $ref: '#/components/schemas/PowerStateStatusEvent' + + PowerBreachAlertMessage: + name: PowerBreachAlertMessage + title: Power Breach Alert + summary: Power Management Agent publishes breach alert with shed hints. + contentType: application/cloudevents+json + payload: + $ref: '#/components/schemas/PowerBreachAlertEvent' + + PowerBreachEnforcementMessage: + name: PowerBreachEnforcementMessage + title: Power Breach Enforcement + summary: Infrastructure Management Agent publishes enforcement outcome. + contentType: application/cloudevents+json + payload: + $ref: '#/components/schemas/PowerBreachEnforcementEvent' + + parameters: + identifier: + description: Unique identifier of the ISV, power agent, or infrastructure agent. + infraAgentId: + description: Unique identifier of the Infrastructure Management Agent. + + securitySchemes: + oauth2Ssa: + type: oauth2 + description: OAuth2 SSA token in MQTT password field (username "oauthtoken"). + flows: + clientCredentials: + tokenUrl: https://auth.example.com/token + availableScopes: + mqtt: Access to MQTT topics + + mTLS: + type: X509 + description: Mutual TLS with client certificate signed by cluster CA. diff --git a/mcp/dsx-exchange-mcp/schemas/asyncapi/spiffe-exchange/pub-keysets.yaml b/mcp/dsx-exchange-mcp/schemas/asyncapi/spiffe-exchange/pub-keysets.yaml new file mode 100644 index 0000000..8803dcc --- /dev/null +++ b/mcp/dsx-exchange-mcp/schemas/asyncapi/spiffe-exchange/pub-keysets.yaml @@ -0,0 +1,117 @@ +# Copyright 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +asyncapi: 3.1.0 +info: + title: SPIRE SPIFFE Exchange - Public Keysets + version: 1.0.0 + description: | + AsyncAPI specification for publishing JWK (JSON Web Key) public keys on the + SPIFFE/SPIRE exchange topic. One JWK per message. Used to distribute public + keys for a given tenant and key identifier so consumers can verify JWS or + use keys for encryption. + + **Topic format:** `spiffe-exchange/v1/pub-keysets/tenant/{tenant_domain}/kid/{kid}` + + Payloads conform to RFC 7517 (JSON Web Key). Only public key material is + published on this channel. + +servers: + production: + host: broker.example.com + protocol: mqtt + description: MQTT broker for SPIFFE exchange public key distribution + +channels: + pubKeysets: + address: "spiffe-exchange/v1/pub-keysets/tenant/{tenant_domain}/kid/{kid}" + parameters: + tenant_domain: + description: Tenant domain identifier (e.g. tenant namespace or domain name). + kid: + description: Key ID (kid) for this key; aligns with JWS/JWE header kid. + messages: + jwk: + $ref: "#/components/messages/JwkMessage" + +operations: + publishPubKeyset: + action: send + channel: + $ref: "#/channels/pubKeysets" + messages: + - $ref: "#/channels/pubKeysets/messages/jwk" + description: > + Publish one JWK for the given tenant and kid. Publishers (e.g. SPIRE) + use this to advertise a public key for verification or encryption. + + subscribePubKeyset: + action: receive + channel: + $ref: "#/channels/pubKeysets" + messages: + - $ref: "#/channels/pubKeysets/messages/jwk" + description: > + Subscribe to public key updates for a tenant and kid. Each message + carries one JWK. Consumers use the key to verify signatures or encrypt. + +components: + messages: + JwkMessage: + name: JwkMessage + title: JWK (RFC 7517) + contentType: application/json + payload: + $ref: "#/components/schemas/Jwk" + + schemas: + Jwk: + type: object + required: + - kty + description: > + Single JSON Web Key per RFC 7517. Only public key parameters are included + on this channel. Key type (kty) determines which additional members are present. + properties: + kty: + type: string + description: Key type (e.g. RSA, EC, OKP). + enum: + - RSA + - EC + - OKP + use: + type: string + description: Public key use (sig, enc, or omitted). + enum: + - sig + - enc + key_ops: + type: array + items: + type: string + description: Key operations (e.g. verify, encrypt). + alg: + type: string + description: Algorithm (e.g. ES256, RS256, EdDSA). + kid: + type: string + description: Key ID; should match the topic kid when present. + # RSA public key parameters (when kty is RSA) + n: + type: string + description: RSA modulus (Base64url). + e: + type: string + description: RSA public exponent (Base64url). + # EC public key parameters (when kty is EC) + crv: + type: string + description: Elliptic curve (e.g. P-256, P-384). + x: + type: string + description: EC x coordinate (Base64url). + y: + type: string + description: EC y coordinate (Base64url). + # OKP (e.g. Ed25519): public key is in 'x'. Private key (d) MUST NOT be published here. diff --git a/mcp/dsx-exchange-mcp/schemas/asyncapi/tenant/scheduler-events.yaml b/mcp/dsx-exchange-mcp/schemas/asyncapi/tenant/scheduler-events.yaml new file mode 100644 index 0000000..4540794 --- /dev/null +++ b/mcp/dsx-exchange-mcp/schemas/asyncapi/tenant/scheduler-events.yaml @@ -0,0 +1,2 @@ +# Copyright 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 diff --git a/mcp/dsx-exchange-mcp/schemas/cloud-events-example.yaml b/mcp/dsx-exchange-mcp/schemas/cloud-events-example.yaml new file mode 100644 index 0000000..1d1b337 --- /dev/null +++ b/mcp/dsx-exchange-mcp/schemas/cloud-events-example.yaml @@ -0,0 +1,107 @@ +# Copyright 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +asyncapi: 3.1.0 +info: + title: Example CloudEvent AsyncAPI + version: 1.0.0 + description: > + AsyncAPI 3.0 document describing publication of a single CloudEvent + (`com.example.someevent`) on the `mytopic` channel. +defaultContentType: application/cloudevents+json +servers: + mqttBroker: + host: broker.example.com:1883 + protocol: mqtt + protocolVersion: 3.1.1 + description: Example MQTT broker transporting CloudEvents. +channels: + mytopic: + address: mytopic + description: CloudEvents describing `com.example.someevent`. + messages: + cloudEventMessage: + $ref: '#/components/messages/cloudEventMessage' +operations: + publishSomeEvent: + action: send + channel: + $ref: '#/channels/mytopic' + summary: Publish CloudEvents of type `com.example.someevent`. + messages: + - $ref: '#/channels/mytopic/messages/cloudEventMessage' +components: + schemas: + BaseCloudEvent: + type: object + description: Common CloudEvent attributes shared by all events. + required: + - specversion + - type + - source + - id + - time + - datacontenttype + properties: + specversion: + type: string + enum: ['1.0'] + description: Version of the CloudEvents specification. + type: + type: string + description: Application-defined event type. + source: + type: string + description: Identifies the context in which an event happened. + subject: + type: string + description: Optional subject within the source. + id: + type: string + description: Unique identifier for the event. + time: + type: string + format: date-time + description: Timestamp for when the event occurred. + datacontenttype: + type: string + description: Content type of the `data` attribute. + dataschema: + type: string + format: uri + description: URI identifying the schema of the `data` attribute. + ExampleEvent: + allOf: + - $ref: '#/components/schemas/BaseCloudEvent' + - type: object + required: + - data + properties: + data: + $ref: '#/components/schemas/ExampleEventData' + ExampleEventData: + type: object + description: Application-specific payload for `com.example.someevent`. + additionalProperties: true + properties: + exampleField: + type: string + description: Placeholder attribute for domain-specific content. + messages: + cloudEventMessage: + name: CloudEventMessage + title: CloudEvent Wrapper + summary: CloudEvent published to `mytopic`. + contentType: application/cloudevents+json; charset=utf-8 + payload: + $ref: '#/components/schemas/ExampleEvent' + examples: + - payload: + specversion: '1.0' + type: com.example.someevent + time: '2018-04-05T03:56:24Z' + id: '1234-1234-1234' + source: /mycontext/subcontext + datacontenttype: application/json; charset=utf-8 + data: + exampleField: sample value diff --git a/mcp/dsx-exchange-mcp/schemas/embed.go b/mcp/dsx-exchange-mcp/schemas/embed.go new file mode 100644 index 0000000..26fb5fd --- /dev/null +++ b/mcp/dsx-exchange-mcp/schemas/embed.go @@ -0,0 +1,12 @@ +// Copyright 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +// Package schemas embeds the DSX Exchange public schema tree. +package schemas + +import "embed" + +// FS contains the schema files copied from the monorepo root schemas directory. +// +//go:embed README.md cloud-events-example.yaml asyncapi/*/*.yaml +var FS embed.FS diff --git a/mcp/dsx-exchange-mcp/vendor/github.com/eclipse/paho.mqtt.golang/.gitignore b/mcp/dsx-exchange-mcp/vendor/github.com/eclipse/paho.mqtt.golang/.gitignore new file mode 100644 index 0000000..47bb0de --- /dev/null +++ b/mcp/dsx-exchange-mcp/vendor/github.com/eclipse/paho.mqtt.golang/.gitignore @@ -0,0 +1,36 @@ +# Compiled Object files, Static and Dynamic libs (Shared Objects) +*.o +*.a +*.so + +# Folders +_obj +_test + +# Architecture specific extensions/prefixes +*.[568vq] +[568vq].out + +*.cgo1.go +*.cgo2.c +_cgo_defun.c +_cgo_gotypes.go +_cgo_export.* + +_testmain.go + +*.exe + +*.msg +*.lok + +samples/trivial +samples/trivial2 +samples/sample +samples/reconnect +samples/ssl +samples/custom_store +samples/simple +samples/stdinpub +samples/stdoutsub +samples/routing \ No newline at end of file diff --git a/mcp/dsx-exchange-mcp/vendor/github.com/eclipse/paho.mqtt.golang/CODE_OF_CONDUCT.md b/mcp/dsx-exchange-mcp/vendor/github.com/eclipse/paho.mqtt.golang/CODE_OF_CONDUCT.md new file mode 100644 index 0000000..faa735b --- /dev/null +++ b/mcp/dsx-exchange-mcp/vendor/github.com/eclipse/paho.mqtt.golang/CODE_OF_CONDUCT.md @@ -0,0 +1,93 @@ +# Community Code of Conduct + +**Version 2.0 +January 1, 2023** + +## Our Pledge + +In the interest of fostering an open and welcoming environment, we as community members, contributors, Committers[^1], and Project Leads (collectively "Contributors") pledge to make participation in our projects and our community a harassment-free and inclusive experience for everyone. + +This Community Code of Conduct ("Code") outlines our behavior expectations as members of our community in all Eclipse Foundation activities, both offline and online. It is not intended to govern scenarios or behaviors outside of the scope of Eclipse Foundation activities. Nor is it intended to replace or supersede the protections offered to all our community members under the law. Please follow both the spirit and letter of this Code and encourage other Contributors to follow these principles into our work. Failure to read or acknowledge this Code does not excuse a Contributor from compliance with the Code. + +## Our Standards + +Examples of behavior that contribute to creating a positive and professional environment include: + +- Using welcoming and inclusive language; +- Actively encouraging all voices; +- Helping others bring their perspectives and listening actively. If you find yourself dominating a discussion, it is especially important to encourage other voices to join in; +- Being respectful of differing viewpoints and experiences; +- Gracefully accepting constructive criticism; +- Focusing on what is best for the community; +- Showing empathy towards other community members; +- Being direct but professional; and +- Leading by example by holding yourself and others accountable + +Examples of unacceptable behavior by Contributors include: + +- The use of sexualized language or imagery; +- Unwelcome sexual attention or advances; +- Trolling, insulting/derogatory comments, and personal or political attacks; +- Public or private harassment, repeated harassment; +- Publishing others' private information, such as a physical or electronic address, without explicit permission; +- Violent threats or language directed against another person; +- Sexist, racist, or otherwise discriminatory jokes and language; +- Posting sexually explicit or violent material; +- Sharing private content, such as emails sent privately or non-publicly, or unlogged forums such as IRC channel history; +- Personal insults, especially those using racist or sexist terms; +- Excessive or unnecessary profanity; +- Advocating for, or encouraging, any of the above behavior; and +- Other conduct which could reasonably be considered inappropriate in a professional setting + +## Our Responsibilities + +With the support of the Eclipse Foundation employees, consultants, officers, and directors (collectively, the "Staff"), Committers, and Project Leads, the Eclipse Foundation Conduct Committee (the "Conduct Committee") is responsible for clarifying the standards of acceptable behavior. The Conduct Committee takes appropriate and fair corrective action in response to any instances of unacceptable behavior. + +## Scope + +This Code applies within all Project, Working Group, and Interest Group spaces and communication channels of the Eclipse Foundation (collectively, "Eclipse spaces"), within any Eclipse-organized event or meeting, and in public spaces when an individual is representing an Eclipse Foundation Project, Working Group, Interest Group, or their communities. Examples of representing a Project or community include posting via an official social media account, personal accounts, or acting as an appointed representative at an online or offline event. Representation of Projects, Working Groups, and Interest Groups may be further defined and clarified by Committers, Project Leads, or the Staff. + +## Enforcement + +Instances of abusive, harassing, or otherwise unacceptable behavior may be reported by contacting the Conduct Committee via conduct@eclipse-foundation.org. All complaints will be reviewed and investigated and will result in a response that is deemed necessary and appropriate to the circumstances. Without the explicit consent of the reporter, the Conduct Committee is obligated to maintain confidentiality with regard to the reporter of an incident. The Conduct Committee is further obligated to ensure that the respondent is provided with sufficient information about the complaint to reply. If such details cannot be provided while maintaining confidentiality, the Conduct Committee will take the respondent‘s inability to provide a defense into account in its deliberations and decisions. Further details of enforcement guidelines may be posted separately. + +Staff, Committers and Project Leads have the right to report, remove, edit, or reject comments, commits, code, wiki edits, issues, and other contributions that are not aligned to this Code, or to block temporarily or permanently any Contributor for other behaviors that they deem inappropriate, threatening, offensive, or harmful. Any such actions will be reported to the Conduct Committee for transparency and record keeping. + +Any Staff (including officers and directors of the Eclipse Foundation), Committers, Project Leads, or Conduct Committee members who are the subject of a complaint to the Conduct Committee will be recused from the process of resolving any such complaint. + +## Responsibility + +The responsibility for administering this Code rests with the Conduct Committee, with oversight by the Executive Director and the Board of Directors. For additional information on the Conduct Committee and its process, please write to . + +## Investigation of Potential Code Violations + +All conflict is not bad as a healthy debate may sometimes be necessary to push us to do our best. It is, however, unacceptable to be disrespectful or offensive, or violate this Code. If you see someone engaging in objectionable behavior violating this Code, we encourage you to address the behavior directly with those involved. If for some reason, you are unable to resolve the matter or feel uncomfortable doing so, or if the behavior is threatening or harassing, please report it following the procedure laid out below. + +Reports should be directed to . It is the Conduct Committee’s role to receive and address reported violations of this Code and to ensure a fair and speedy resolution. + +The Eclipse Foundation takes all reports of potential Code violations seriously and is committed to confidentiality and a full investigation of all allegations. The identity of the reporter will be omitted from the details of the report supplied to the accused. Contributors who are being investigated for a potential Code violation will have an opportunity to be heard prior to any final determination. Those found to have violated the Code can seek reconsideration of the violation and disciplinary action decisions. Every effort will be made to have all matters disposed of within 60 days of the receipt of the complaint. + +## Actions +Contributors who do not follow this Code in good faith may face temporary or permanent repercussions as determined by the Conduct Committee. + +This Code does not address all conduct. It works in conjunction with our [Communication Channel Guidelines](https://www.eclipse.org/org/documents/communication-channel-guidelines/), [Social Media Guidelines](https://www.eclipse.org/org/documents/social_media_guidelines.php), [Bylaws](https://www.eclipse.org/org/documents/eclipse-foundation-be-bylaws-en.pdf), and [Internal Rules](https://www.eclipse.org/org/documents/ef-be-internal-rules.pdf) which set out additional protections for, and obligations of, all contributors. The Foundation has additional policies that provide further guidance on other matters. + +It’s impossible to spell out every possible scenario that might be deemed a violation of this Code. Instead, we rely on one another’s good judgment to uphold a high standard of integrity within all Eclipse Spaces. Sometimes, identifying the right thing to do isn’t an easy call. In such a scenario, raise the issue as early as possible. + +## No Retaliation + +The Eclipse community relies upon and values the help of Contributors who identify potential problems that may need to be addressed within an Eclipse Space. Any retaliation against a Contributor who raises an issue honestly is a violation of this Code. That a Contributor has raised a concern honestly or participated in an investigation, cannot be the basis for any adverse action, including threats, harassment, or discrimination. If you work with someone who has raised a concern or provided information in an investigation, you should continue to treat the person with courtesy and respect. If you believe someone has retaliated against you, report the matter as described by this Code. Honest reporting does not mean that you have to be right when you raise a concern; you just have to believe that the information you are providing is accurate. + +False reporting, especially when intended to retaliate or exclude, is itself a violation of this Code and will not be accepted or tolerated. + +Everyone is encouraged to ask questions about this Code. Your feedback is welcome, and you will get a response within three business days. Write to . + +## Amendments + +The Eclipse Foundation Board of Directors may amend this Code from time to time and may vary the procedures it sets out where appropriate in a particular case. + +### Attribution + +This Code was inspired by the [Contributor Covenant](https://www.contributor-covenant.org/), version 1.4, available [here](https://www.contributor-covenant.org/version/1/4/code-of-conduct/). + +[^1]: Capitalized terms used herein without definition shall have the meanings assigned to them in the Bylaws. \ No newline at end of file diff --git a/mcp/dsx-exchange-mcp/vendor/github.com/eclipse/paho.mqtt.golang/CONTRIBUTING.md b/mcp/dsx-exchange-mcp/vendor/github.com/eclipse/paho.mqtt.golang/CONTRIBUTING.md new file mode 100644 index 0000000..9791dc6 --- /dev/null +++ b/mcp/dsx-exchange-mcp/vendor/github.com/eclipse/paho.mqtt.golang/CONTRIBUTING.md @@ -0,0 +1,56 @@ +Contributing to Paho +==================== + +Thanks for your interest in this project. + +Project description: +-------------------- + +The Paho project has been created to provide scalable open-source implementations of open and standard messaging protocols aimed at new, existing, and emerging applications for Machine-to-Machine (M2M) and Internet of Things (IoT). +Paho reflects the inherent physical and cost constraints of device connectivity. Its objectives include effective levels of decoupling between devices and applications, designed to keep markets open and encourage the rapid growth of scalable Web and Enterprise middleware and applications. Paho is being kicked off with MQTT publish/subscribe client implementations for use on embedded platforms, along with corresponding server support as determined by the community. + +- https://projects.eclipse.org/projects/technology.paho + +Developer resources: +-------------------- + +Information regarding source code management, builds, coding standards, and more. + +- https://projects.eclipse.org/projects/technology.paho/developer + +Contributor License Agreement: +------------------------------ + +Before your contribution can be accepted by the project, you need to create and electronically sign the Eclipse Foundation Contributor License Agreement (CLA). + +- http://www.eclipse.org/legal/CLA.php + +Contributing Code: +------------------ + +The Go client is developed in Github, see their documentation on the process of forking and pull requests; https://help.github.com/categories/collaborating-on-projects-using-pull-requests/ + +Git commit messages should follow the style described here; + +http://tbaggery.com/2008/04/19/a-note-about-git-commit-messages.html + +Contact: +-------- + +Contact the project developers via the project's "dev" list. + +- https://dev.eclipse.org/mailman/listinfo/paho-dev + +Search for bugs: +---------------- + +This project uses Github issues to track ongoing development and issues. + +- https://github.com/eclipse/paho.mqtt.golang/issues + +Create a new bug: +----------------- + +Be sure to search for existing bugs before you create another one. Remember that contributions are always welcome! + +- https://github.com/eclipse/paho.mqtt.golang/issues diff --git a/mcp/dsx-exchange-mcp/vendor/github.com/eclipse/paho.mqtt.golang/LICENSE b/mcp/dsx-exchange-mcp/vendor/github.com/eclipse/paho.mqtt.golang/LICENSE new file mode 100644 index 0000000..f55c395 --- /dev/null +++ b/mcp/dsx-exchange-mcp/vendor/github.com/eclipse/paho.mqtt.golang/LICENSE @@ -0,0 +1,294 @@ +Eclipse Public License - v 2.0 (EPL-2.0) + +This program and the accompanying materials +are made available under the terms of the Eclipse Public License v2.0 +and Eclipse Distribution License v1.0 which accompany this distribution. + +The Eclipse Public License is available at + https://www.eclipse.org/legal/epl-2.0/ +and the Eclipse Distribution License is available at + http://www.eclipse.org/org/documents/edl-v10.php. + +For an explanation of what dual-licensing means to you, see: +https://www.eclipse.org/legal/eplfaq.php#DUALLIC + +**** +The epl-2.0 is copied below in order to pass the pkg.go.dev license check (https://pkg.go.dev/license-policy). +**** +Eclipse Public License - v 2.0 + + THE ACCOMPANYING PROGRAM IS PROVIDED UNDER THE TERMS OF THIS ECLIPSE + PUBLIC LICENSE ("AGREEMENT"). ANY USE, REPRODUCTION OR DISTRIBUTION + OF THE PROGRAM CONSTITUTES RECIPIENT'S ACCEPTANCE OF THIS AGREEMENT. + +1. DEFINITIONS + +"Contribution" means: + + a) in the case of the initial Contributor, the initial content + Distributed under this Agreement, and + + b) in the case of each subsequent Contributor: + i) changes to the Program, and + ii) additions to the Program; + where such changes and/or additions to the Program originate from + and are Distributed by that particular Contributor. A Contribution + "originates" from a Contributor if it was added to the Program by + such Contributor itself or anyone acting on such Contributor's behalf. + Contributions do not include changes or additions to the Program that + are not Modified Works. + +"Contributor" means any person or entity that Distributes the Program. + +"Licensed Patents" mean patent claims licensable by a Contributor which +are necessarily infringed by the use or sale of its Contribution alone +or when combined with the Program. + +"Program" means the Contributions Distributed in accordance with this +Agreement. + +"Recipient" means anyone who receives the Program under this Agreement +or any Secondary License (as applicable), including Contributors. + +"Derivative Works" shall mean any work, whether in Source Code or other +form, that is based on (or derived from) the Program and for which the +editorial revisions, annotations, elaborations, or other modifications +represent, as a whole, an original work of authorship. + +"Modified Works" shall mean any work in Source Code or other form that +results from an addition to, deletion from, or modification of the +contents of the Program, including, for purposes of clarity any new file +in Source Code form that contains any contents of the Program. Modified +Works shall not include works that contain only declarations, +interfaces, types, classes, structures, or files of the Program solely +in each case in order to link to, bind by name, or subclass the Program +or Modified Works thereof. + +"Distribute" means the acts of a) distributing or b) making available +in any manner that enables the transfer of a copy. + +"Source Code" means the form of a Program preferred for making +modifications, including but not limited to software source code, +documentation source, and configuration files. + +"Secondary License" means either the GNU General Public License, +Version 2.0, or any later versions of that license, including any +exceptions or additional permissions as identified by the initial +Contributor. + +2. GRANT OF RIGHTS + + a) Subject to the terms of this Agreement, each Contributor hereby + grants Recipient a non-exclusive, worldwide, royalty-free copyright + license to reproduce, prepare Derivative Works of, publicly display, + publicly perform, Distribute and sublicense the Contribution of such + Contributor, if any, and such Derivative Works. + + b) Subject to the terms of this Agreement, each Contributor hereby + grants Recipient a non-exclusive, worldwide, royalty-free patent + license under Licensed Patents to make, use, sell, offer to sell, + import and otherwise transfer the Contribution of such Contributor, + if any, in Source Code or other form. This patent license shall + apply to the combination of the Contribution and the Program if, at + the time the Contribution is added by the Contributor, such addition + of the Contribution causes such combination to be covered by the + Licensed Patents. The patent license shall not apply to any other + combinations which include the Contribution. No hardware per se is + licensed hereunder. + + c) Recipient understands that although each Contributor grants the + licenses to its Contributions set forth herein, no assurances are + provided by any Contributor that the Program does not infringe the + patent or other intellectual property rights of any other entity. + Each Contributor disclaims any liability to Recipient for claims + brought by any other entity based on infringement of intellectual + property rights or otherwise. As a condition to exercising the + rights and licenses granted hereunder, each Recipient hereby + assumes sole responsibility to secure any other intellectual + property rights needed, if any. For example, if a third party + patent license is required to allow Recipient to Distribute the + Program, it is Recipient's responsibility to acquire that license + before distributing the Program. + + d) Each Contributor represents that to its knowledge it has + sufficient copyright rights in its Contribution, if any, to grant + the copyright license set forth in this Agreement. + + e) Notwithstanding the terms of any Secondary License, no + Contributor makes additional grants to any Recipient (other than + those set forth in this Agreement) as a result of such Recipient's + receipt of the Program under the terms of a Secondary License + (if permitted under the terms of Section 3). + +3. REQUIREMENTS + +3.1 If a Contributor Distributes the Program in any form, then: + + a) the Program must also be made available as Source Code, in + accordance with section 3.2, and the Contributor must accompany + the Program with a statement that the Source Code for the Program + is available under this Agreement, and informs Recipients how to + obtain it in a reasonable manner on or through a medium customarily + used for software exchange; and + + b) the Contributor may Distribute the Program under a license + different than this Agreement, provided that such license: + i) effectively disclaims on behalf of all other Contributors all + warranties and conditions, express and implied, including + warranties or conditions of title and non-infringement, and + implied warranties or conditions of merchantability and fitness + for a particular purpose; + + ii) effectively excludes on behalf of all other Contributors all + liability for damages, including direct, indirect, special, + incidental and consequential damages, such as lost profits; + + iii) does not attempt to limit or alter the recipients' rights + in the Source Code under section 3.2; and + + iv) requires any subsequent distribution of the Program by any + party to be under a license that satisfies the requirements + of this section 3. + +3.2 When the Program is Distributed as Source Code: + + a) it must be made available under this Agreement, or if the + Program (i) is combined with other material in a separate file or + files made available under a Secondary License, and (ii) the initial + Contributor attached to the Source Code the notice described in + Exhibit A of this Agreement, then the Program may be made available + under the terms of such Secondary Licenses, and + + b) a copy of this Agreement must be included with each copy of + the Program. + +3.3 Contributors may not remove or alter any copyright, patent, +trademark, attribution notices, disclaimers of warranty, or limitations +of liability ("notices") contained within the Program from any copy of +the Program which they Distribute, provided that Contributors may add +their own appropriate notices. + +4. COMMERCIAL DISTRIBUTION + +Commercial distributors of software may accept certain responsibilities +with respect to end users, business partners and the like. While this +license is intended to facilitate the commercial use of the Program, +the Contributor who includes the Program in a commercial product +offering should do so in a manner which does not create potential +liability for other Contributors. Therefore, if a Contributor includes +the Program in a commercial product offering, such Contributor +("Commercial Contributor") hereby agrees to defend and indemnify every +other Contributor ("Indemnified Contributor") against any losses, +damages and costs (collectively "Losses") arising from claims, lawsuits +and other legal actions brought by a third party against the Indemnified +Contributor to the extent caused by the acts or omissions of such +Commercial Contributor in connection with its distribution of the Program +in a commercial product offering. The obligations in this section do not +apply to any claims or Losses relating to any actual or alleged +intellectual property infringement. In order to qualify, an Indemnified +Contributor must: a) promptly notify the Commercial Contributor in +writing of such claim, and b) allow the Commercial Contributor to control, +and cooperate with the Commercial Contributor in, the defense and any +related settlement negotiations. The Indemnified Contributor may +participate in any such claim at its own expense. + +For example, a Contributor might include the Program in a commercial +product offering, Product X. That Contributor is then a Commercial +Contributor. If that Commercial Contributor then makes performance +claims, or offers warranties related to Product X, those performance +claims and warranties are such Commercial Contributor's responsibility +alone. Under this section, the Commercial Contributor would have to +defend claims against the other Contributors related to those performance +claims and warranties, and if a court requires any other Contributor to +pay any damages as a result, the Commercial Contributor must pay +those damages. + +5. NO WARRANTY + +EXCEPT AS EXPRESSLY SET FORTH IN THIS AGREEMENT, AND TO THE EXTENT +PERMITTED BY APPLICABLE LAW, THE PROGRAM IS PROVIDED ON AN "AS IS" +BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, EITHER EXPRESS OR +IMPLIED INCLUDING, WITHOUT LIMITATION, ANY WARRANTIES OR CONDITIONS OF +TITLE, NON-INFRINGEMENT, MERCHANTABILITY OR FITNESS FOR A PARTICULAR +PURPOSE. Each Recipient is solely responsible for determining the +appropriateness of using and distributing the Program and assumes all +risks associated with its exercise of rights under this Agreement, +including but not limited to the risks and costs of program errors, +compliance with applicable laws, damage to or loss of data, programs +or equipment, and unavailability or interruption of operations. + +6. DISCLAIMER OF LIABILITY + +EXCEPT AS EXPRESSLY SET FORTH IN THIS AGREEMENT, AND TO THE EXTENT +PERMITTED BY APPLICABLE LAW, NEITHER RECIPIENT NOR ANY CONTRIBUTORS +SHALL HAVE ANY LIABILITY FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, +EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING WITHOUT LIMITATION LOST +PROFITS), HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN +CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) +ARISING IN ANY WAY OUT OF THE USE OR DISTRIBUTION OF THE PROGRAM OR THE +EXERCISE OF ANY RIGHTS GRANTED HEREUNDER, EVEN IF ADVISED OF THE +POSSIBILITY OF SUCH DAMAGES. + +7. GENERAL + +If any provision of this Agreement is invalid or unenforceable under +applicable law, it shall not affect the validity or enforceability of +the remainder of the terms of this Agreement, and without further +action by the parties hereto, such provision shall be reformed to the +minimum extent necessary to make such provision valid and enforceable. + +If Recipient institutes patent litigation against any entity +(including a cross-claim or counterclaim in a lawsuit) alleging that the +Program itself (excluding combinations of the Program with other software +or hardware) infringes such Recipient's patent(s), then such Recipient's +rights granted under Section 2(b) shall terminate as of the date such +litigation is filed. + +All Recipient's rights under this Agreement shall terminate if it +fails to comply with any of the material terms or conditions of this +Agreement and does not cure such failure in a reasonable period of +time after becoming aware of such noncompliance. If all Recipient's +rights under this Agreement terminate, Recipient agrees to cease use +and distribution of the Program as soon as reasonably practicable. +However, Recipient's obligations under this Agreement and any licenses +granted by Recipient relating to the Program shall continue and survive. + +Everyone is permitted to copy and distribute copies of this Agreement, +but in order to avoid inconsistency the Agreement is copyrighted and +may only be modified in the following manner. The Agreement Steward +reserves the right to publish new versions (including revisions) of +this Agreement from time to time. No one other than the Agreement +Steward has the right to modify this Agreement. The Eclipse Foundation +is the initial Agreement Steward. The Eclipse Foundation may assign the +responsibility to serve as the Agreement Steward to a suitable separate +entity. Each new version of the Agreement will be given a distinguishing +version number. The Program (including Contributions) may always be +Distributed subject to the version of the Agreement under which it was +received. In addition, after a new version of the Agreement is published, +Contributor may elect to Distribute the Program (including its +Contributions) under the new version. + +Except as expressly stated in Sections 2(a) and 2(b) above, Recipient +receives no rights or licenses to the intellectual property of any +Contributor under this Agreement, whether expressly, by implication, +estoppel or otherwise. All rights in the Program not expressly granted +under this Agreement are reserved. Nothing in this Agreement is intended +to be enforceable by any entity that is not a Contributor or Recipient. +No third-party beneficiary rights are created under this Agreement. + +Exhibit A - Form of Secondary Licenses Notice + +"This Source Code may also be made available under the following +Secondary Licenses when the conditions for such availability set forth +in the Eclipse Public License, v. 2.0 are satisfied: {name license(s), +version(s), and exceptions or additional permissions here}." + + Simply including a copy of this Agreement, including this Exhibit A + is not sufficient to license the Source Code under Secondary Licenses. + + If it is not possible or desirable to put the notice in a particular + file, then You may include the notice in a location (such as a LICENSE + file in a relevant directory) where a recipient would be likely to + look for such a notice. + + You may add additional accurate notices of copyright ownership. diff --git a/mcp/dsx-exchange-mcp/vendor/github.com/eclipse/paho.mqtt.golang/NOTICE.md b/mcp/dsx-exchange-mcp/vendor/github.com/eclipse/paho.mqtt.golang/NOTICE.md new file mode 100644 index 0000000..10c4a1c --- /dev/null +++ b/mcp/dsx-exchange-mcp/vendor/github.com/eclipse/paho.mqtt.golang/NOTICE.md @@ -0,0 +1,77 @@ +# Notices for paho.mqtt.golang + +This content is produced and maintained by the Eclipse Paho project. + + * Project home: https://www.eclipse.org/paho/ + +Note that a [separate mqtt v5 client](https://github.com/eclipse/paho.golang) also exists (this is a full rewrite +and deliberately incompatible with this library). + +## Trademarks + +Eclipse Mosquitto trademarks of the Eclipse Foundation. Eclipse, and the +Eclipse Logo are registered trademarks of the Eclipse Foundation. + +Paho is a trademark of the Eclipse Foundation. Eclipse, and the Eclipse Logo are +registered trademarks of the Eclipse Foundation. + +## Copyright + +All content is the property of the respective authors or their employers. +For more information regarding authorship of content, please consult the +listed source code repository logs. + +## Declared Project Licenses + +This program and the accompanying materials are made available under the terms of the +Eclipse Public License v2.0 and Eclipse Distribution License v1.0 which accompany this +distribution. + +The Eclipse Public License is available at +https://www.eclipse.org/legal/epl-2.0/ +and the Eclipse Distribution License is available at +http://www.eclipse.org/org/documents/edl-v10.php. + +For an explanation of what dual-licensing means to you, see: +https://www.eclipse.org/legal/eplfaq.php#DUALLIC + +SPDX-License-Identifier: EPL-2.0 or BSD-3-Clause + +## Source Code + +The project maintains the following source code repositories: + + * https://github.com/eclipse/paho.mqtt.golang + +## Third-party Content + +This project makes use of the follow third party projects. + +Go Programming Language and Standard Library + +* License: BSD-style license (https://golang.org/LICENSE) +* Project: https://golang.org/ + +Go Networking + +* License: BSD 3-Clause style license and patent grant. +* Project: https://cs.opensource.google/go/x/net + +Go Sync + +* License: BSD 3-Clause style license and patent grant. +* Project: https://cs.opensource.google/go/x/sync/ + +Gorilla Websockets v1.4.2 + +* License: BSD 2-Clause "Simplified" License +* Project: https://github.com/gorilla/websocket + +## Cryptography + +Content may contain encryption software. The country in which you are currently +may have restrictions on the import, possession, and use, and/or re-export to +another country, of encryption software. BEFORE using any encryption software, +please check the country's laws, regulations and policies concerning the import, +possession, or use, and re-export of encryption software, to see if this is +permitted. \ No newline at end of file diff --git a/mcp/dsx-exchange-mcp/vendor/github.com/eclipse/paho.mqtt.golang/README.md b/mcp/dsx-exchange-mcp/vendor/github.com/eclipse/paho.mqtt.golang/README.md new file mode 100644 index 0000000..21ed96f --- /dev/null +++ b/mcp/dsx-exchange-mcp/vendor/github.com/eclipse/paho.mqtt.golang/README.md @@ -0,0 +1,198 @@ + +[![PkgGoDev](https://pkg.go.dev/badge/github.com/eclipse/paho.mqtt.golang)](https://pkg.go.dev/github.com/eclipse/paho.mqtt.golang) +[![Go Report Card](https://goreportcard.com/badge/github.com/eclipse/paho.mqtt.golang)](https://goreportcard.com/report/github.com/eclipse/paho.mqtt.golang) + +Eclipse Paho MQTT Go client +=========================== + + +This repository contains the source code for the [Eclipse Paho](https://eclipse.org/paho) MQTT 3.1/3.11 Go client library. + +This code builds a library which enable applications to connect to an [MQTT](https://mqtt.org) broker to publish +messages, and to subscribe to topics and receive published messages. + +This library supports a fully asynchronous mode of operation. + +A client supporting MQTT V5 is [also available](https://github.com/eclipse/paho.golang). + +Installation and Build +---------------------- + +The process depends upon whether you are using [modules](https://golang.org/ref/mod) (recommended) or `GOPATH`. + +#### Modules + +If you are using [modules](https://blog.golang.org/using-go-modules) then `import "github.com/eclipse/paho.mqtt.golang"` +and start using it. The necessary packages will be download automatically when you run `go build`. + +Note that the latest release will be downloaded and changes may have been made since the release. If you have +encountered an issue, or wish to try the latest code for another reason, then run +`go get github.com/eclipse/paho.mqtt.golang@master` to get the latest commit. + +#### GOPATH + +Installation is as easy as: + +``` +go get github.com/eclipse/paho.mqtt.golang +``` + +The client depends on Google's [proxy](https://godoc.org/golang.org/x/net/proxy) package and the +[websockets](https://godoc.org/github.com/gorilla/websocket) package, also easily installed with the commands: + +``` +go get github.com/gorilla/websocket +go get golang.org/x/net/proxy +``` + + +Usage and API +------------- + +Detailed API documentation is available by using to godoc tool, or can be browsed online +using the [pkg.go.dev](https://pkg.go.dev/github.com/eclipse/paho.mqtt.golang) service. + +Samples are available in the `cmd` directory for reference. + +Note: + +The library also supports using MQTT over websockets by using the `ws://` (unsecure) or `wss://` (secure) prefix in the +URI. If the client is running behind a corporate http/https proxy then the following environment variables `HTTP_PROXY`, +`HTTPS_PROXY` and `NO_PROXY` are taken into account when establishing the connection. + +Troubleshooting +--------------- + +If you are new to MQTT and your application is not working as expected reviewing the +[MQTT specification](https://docs.oasis-open.org/mqtt/mqtt/v3.1.1/os/mqtt-v3.1.1-os.html), which this library implements, +is a good first step. [MQTT.org](https://mqtt.org) has some [good resources](https://mqtt.org/getting-started/) that answer many +common questions. + +### Error Handling + +The asynchronous nature of this library makes it easy to forget to check for errors. Consider using a go routine to +log these: + +```go +t := client.Publish("topic", qos, retained, msg) +go func() { + _ = t.Wait() // Can also use '<-t.Done()' in releases > 1.2.0 + if t.Error() != nil { + log.Error(t.Error()) // Use your preferred logging technique (or just fmt.Printf) + } +}() +``` + +### Logging + +If you are encountering issues then enabling logging, both within this library and on your broker, is a good way to +begin troubleshooting. This library can produce various levels of log by assigning the logging endpoints, ERROR, +CRITICAL, WARN and DEBUG. For example: + +```go +func main() { + mqtt.ERROR = log.New(os.Stdout, "[ERROR] ", 0) + mqtt.CRITICAL = log.New(os.Stdout, "[CRIT] ", 0) + mqtt.WARN = log.New(os.Stdout, "[WARN] ", 0) + mqtt.DEBUG = log.New(os.Stdout, "[DEBUG] ", 0) + + // Connect, Subscribe, Publish etc.. +} +``` + +### Common Problems + +* Seemingly random disconnections may be caused by another client connecting to the broker with the same client +identifier; this is as per the [spec](https://docs.oasis-open.org/mqtt/mqtt/v3.1.1/os/mqtt-v3.1.1-os.html#_Toc384800405). +* Unless ordered delivery of messages is essential (and you have configured your broker to support this e.g. + `max_inflight_messages=1` in mosquitto) then set `ClientOptions.SetOrderMatters(false)`. Doing so will avoid the + below issue (deadlocks due to blocking message handlers). +* A `MessageHandler` (called when a new message is received) must not block (unless + `ClientOptions.SetOrderMatters(false)` set). If you wish to perform a long-running task, or publish a message, then + please use a go routine (blocking in the handler is a common cause of unexpected `pingresp +not received, disconnecting` errors). +* When QOS1+ subscriptions have been created previously and you connect with `CleanSession` set to false it is possible +that the broker will deliver retained messages before `Subscribe` can be called. To process these messages either +configure a handler with `AddRoute` or set a `DefaultPublishHandler`. If there is no handler (or `DefaultPublishHandler`) +then inbound messages will not be acknowledged. Adding a handler (even if it's `opts.SetDefaultPublishHandler(func(mqtt.Client, mqtt.Message) {})`) +is highly recommended to avoid inadvertently hitting inflight message limits. +* Loss of network connectivity may not be detected immediately. If this is an issue then consider setting +`ClientOptions.KeepAlive` (sends regular messages to check the link is active). +* Reusing a `Client` is not completely safe. After calling `Disconnect` please create a new Client (`NewClient()`) rather +than attempting to reuse the existing one (note that features such as `SetAutoReconnect` mean this is rarely necessary). +* Brokers offer many configuration options; some settings may lead to unexpected results. +* Publish tokens will complete if the connection is lost and re-established using the default +options.SetAutoReconnect(true) functionality (token.Error() will return nil). Attempts will be made to re-deliver the +message but there is currently no easy way know when such messages are delivered. + +If using Mosquitto then there are a range of fairly common issues: +* `listener` - By default [Mosquitto v2+](https://mosquitto.org/documentation/migrating-to-2-0/) listens on loopback +interfaces only (meaning it will only accept connections made from the computer its running on). +* `max_inflight_messages` - Unless this is set to 1 mosquitto does not guarantee ordered delivery of messages. +* `max_queued_messages` / `max_queued_bytes` - These impose limits on the number/size of queued messages. The defaults +may lead to messages being silently dropped. +* `persistence` - Defaults to false (messages will not survive a broker restart) +* `max_keepalive` - defaults to 65535 and, from version 2.0.12, `SetKeepAlive(0)` will result in a rejected connection +by default. + +Reporting bugs +-------------- + +Please report bugs by raising issues for this project in github https://github.com/eclipse/paho.mqtt.golang/issues + +A limited number of contributors monitor the issues section so if you have a general question please see the +resources in the [more information](#more-information) section for help. + +We welcome bug reports, but it is important they are actionable. A significant percentage of issues reported are not +resolved due to a lack of information. If we cannot replicate the problem then it is unlikely we will be able to fix it. +The information required will vary from issue to issue but almost all bug reports would be expected to include: + +* Which version of the package you are using (tag or commit - this should be in your `go.mod` file) +* A full, clear, description of the problem (detail what you are expecting vs what actually happens). +* Configuration information (code showing how you connect, please include all references to `ClientOption`) +* Broker details (name and version). + +If at all possible please also include: +* Details of your attempts to resolve the issue (what have you tried, what worked, what did not). +* A [Minimal, Reproducible Example](https://stackoverflow.com/help/minimal-reproducible-example). Providing an example +is the best way to demonstrate the issue you are facing; it is important this includes all relevant information +(including broker configuration). Docker (see `cmd/docker`) makes it relatively simple to provide a working end-to-end +example. +* Broker logs covering the period the issue occurred. +* [Application Logs](#logging) covering the period the issue occurred. Unless you have isolated the root cause of the +issue please include a link to a full log (including data from well before the problem arose). + +It is important to remember that this library does not stand alone; it communicates with a broker and any issues you are +seeing may be due to: + +* Bugs in your code. +* Bugs in this library. +* The broker configuration. +* Bugs in the broker. +* Issues with whatever you are communicating with. + +When submitting an issue, please ensure that you provide sufficient details to enable us to eliminate causes outside of +this library. + +Contributing +------------ + +We welcome pull requests but before your contribution can be accepted by the project, you need to create and +electronically sign the Eclipse Contributor Agreement (ECA) and sign off on the Eclipse Foundation Certificate of Origin. + +More information is available in the +[Eclipse Development Resources](http://wiki.eclipse.org/Development_Resources/Contributing_via_Git); please take special +note of the requirement that the commit record contain a "Signed-off-by" entry. + +More information +---------------- + +[Stack Overflow](https://stackoverflow.com/questions/tagged/mqtt+go) has a range questions/answers covering a range of +common issues (both relating to use of this library and MQTT in general). This is the best place to ask general questions +(including those relating to the use of this library). + +Discussion of the Paho clients takes place on the [Eclipse paho-dev mailing list](https://dev.eclipse.org/mailman/listinfo/paho-dev). + +General questions about the MQTT protocol are discussed in the [MQTT Google Group](https://groups.google.com/forum/?hl=en-US&fromgroups#!forum/mqtt). + +There is much more information available via the [MQTT community site](http://mqtt.org). diff --git a/mcp/dsx-exchange-mcp/vendor/github.com/eclipse/paho.mqtt.golang/SECURITY.md b/mcp/dsx-exchange-mcp/vendor/github.com/eclipse/paho.mqtt.golang/SECURITY.md new file mode 100644 index 0000000..1520876 --- /dev/null +++ b/mcp/dsx-exchange-mcp/vendor/github.com/eclipse/paho.mqtt.golang/SECURITY.md @@ -0,0 +1,13 @@ +# Security Policy + +This project implements the Eclipse Foundation Security Policy + +* https://www.eclipse.org/security + +## Supported Versions + +Only the most recent release of the client will be supported with security updates. + +## Reporting a Vulnerability + +Please report vulnerabilities to the Eclipse Foundation Security Team at security@eclipse.org \ No newline at end of file diff --git a/mcp/dsx-exchange-mcp/vendor/github.com/eclipse/paho.mqtt.golang/backoff.go b/mcp/dsx-exchange-mcp/vendor/github.com/eclipse/paho.mqtt.golang/backoff.go new file mode 100644 index 0000000..8ee06f6 --- /dev/null +++ b/mcp/dsx-exchange-mcp/vendor/github.com/eclipse/paho.mqtt.golang/backoff.go @@ -0,0 +1,104 @@ +/* + * Copyright (c) 2021 IBM Corp and others. + * + * All rights reserved. This program and the accompanying materials + * are made available under the terms of the Eclipse Public License v2.0 + * and Eclipse Distribution License v1.0 which accompany this distribution. + * + * The Eclipse Public License is available at + * https://www.eclipse.org/legal/epl-2.0/ + * and the Eclipse Distribution License is available at + * http://www.eclipse.org/org/documents/edl-v10.php. + * + * Contributors: + * Matt Brittan + * Daichi Tomaru + */ + +package mqtt + +import ( + "sync" + "time" +) + +// Controller for sleep with backoff when the client attempts reconnection +// It has statuses for each situations cause reconnection. +type backoffController struct { + sync.RWMutex + statusMap map[string]*backoffStatus +} + +type backoffStatus struct { + lastSleepPeriod time.Duration + lastErrorTime time.Time +} + +func newBackoffController() *backoffController { + return &backoffController{ + statusMap: map[string]*backoffStatus{}, + } +} + +// Calculate next sleep period from the specified parameters. +// Returned values are next sleep period and whether the error situation is continual. +// If connection errors continuouslly occurs, its sleep period is exponentially increased. +// Also if there is a lot of time between last and this error, sleep period is initialized. +func (b *backoffController) getBackoffSleepTime( + situation string, initSleepPeriod time.Duration, maxSleepPeriod time.Duration, processTime time.Duration, skipFirst bool, +) (time.Duration, bool) { + // Decide first sleep time if the situation is not continual. + var firstProcess = func(status *backoffStatus, init time.Duration, skip bool) (time.Duration, bool) { + if skip { + status.lastSleepPeriod = 0 + return 0, false + } + status.lastSleepPeriod = init + return init, false + } + + // Prioritize maxSleep. + if initSleepPeriod > maxSleepPeriod { + initSleepPeriod = maxSleepPeriod + } + b.Lock() + defer b.Unlock() + + status, exist := b.statusMap[situation] + if !exist { + b.statusMap[situation] = &backoffStatus{initSleepPeriod, time.Now()} + return firstProcess(b.statusMap[situation], initSleepPeriod, skipFirst) + } + + oldTime := status.lastErrorTime + status.lastErrorTime = time.Now() + + // When there is a lot of time between last and this error, sleep period is initialized. + if status.lastErrorTime.Sub(oldTime) > (processTime * 2 + status.lastSleepPeriod) { + return firstProcess(status, initSleepPeriod, skipFirst) + } + + if status.lastSleepPeriod == 0 { + status.lastSleepPeriod = initSleepPeriod + return initSleepPeriod, true + } + + if nextSleepPeriod := status.lastSleepPeriod * 2; nextSleepPeriod <= maxSleepPeriod { + status.lastSleepPeriod = nextSleepPeriod + } else { + status.lastSleepPeriod = maxSleepPeriod + } + + return status.lastSleepPeriod, true +} + +// Execute sleep the time returned from getBackoffSleepTime. +func (b *backoffController) sleepWithBackoff( + situation string, initSleepPeriod time.Duration, maxSleepPeriod time.Duration, processTime time.Duration, skipFirst bool, +) (time.Duration, bool) { + sleep, isFirst := b.getBackoffSleepTime(situation, initSleepPeriod, maxSleepPeriod, processTime, skipFirst) + if sleep != 0 { + time.Sleep(sleep) + } + return sleep, isFirst +} diff --git a/mcp/dsx-exchange-mcp/vendor/github.com/eclipse/paho.mqtt.golang/client.go b/mcp/dsx-exchange-mcp/vendor/github.com/eclipse/paho.mqtt.golang/client.go new file mode 100644 index 0000000..0b502f6 --- /dev/null +++ b/mcp/dsx-exchange-mcp/vendor/github.com/eclipse/paho.mqtt.golang/client.go @@ -0,0 +1,1265 @@ +/* + * Copyright (c) 2021 IBM Corp and others. + * + * All rights reserved. This program and the accompanying materials + * are made available under the terms of the Eclipse Public License v2.0 + * and Eclipse Distribution License v1.0 which accompany this distribution. + * + * The Eclipse Public License is available at + * https://www.eclipse.org/legal/epl-2.0/ + * and the Eclipse Distribution License is available at + * http://www.eclipse.org/org/documents/edl-v10.php. + * + * Contributors: + * Seth Hoenig + * Allan Stockdill-Mander + * Mike Robertson + * Matt Brittan + */ + +// Portions copyright © 2018 TIBCO Software Inc. + +// Package mqtt provides an MQTT v3.1.1 client library. +package mqtt + +import ( + "bytes" + "context" + "errors" + "fmt" + "net" + "strings" + "sync" + "sync/atomic" + "time" + + "golang.org/x/sync/semaphore" + + "github.com/eclipse/paho.mqtt.golang/packets" +) + +// Client is the interface definition for a Client as used by this +// library, the interface is primarily to allow mocking tests. +// +// It is an MQTT v3.1.1 client for communicating +// with an MQTT server using non-blocking methods that allow work +// to be done in the background. +// An application may connect to an MQTT server using: +// +// A plain TCP socket (e.g. mqtt://test.mosquitto.org:1833) +// A secure SSL/TLS socket (e.g. tls://test.mosquitto.org:8883) +// A websocket (e.g ws://test.mosquitto.org:8080 or wss://test.mosquitto.org:8081) +// Something else (using `options.CustomOpenConnectionFn`) +// +// To enable ensured message delivery at Quality of Service (QoS) levels +// described in the MQTT spec, a message persistence mechanism must be +// used. This is done by providing a type which implements the Store +// interface. For convenience, FileStore and MemoryStore are provided +// implementations that should be sufficient for most use cases. More +// information can be found in their respective documentation. +// Numerous connection options may be specified by configuring a +// and then supplying a ClientOptions type. +// Implementations of Client must be safe for concurrent use by multiple +// goroutines +type Client interface { + // IsConnected returns a bool signifying whether + // the client is connected or not. + IsConnected() bool + // IsConnectionOpen return a bool signifying whether the client has an active + // connection to mqtt broker, i.e not in disconnected or reconnect mode + IsConnectionOpen() bool + // Connect will create a connection to the message broker, by default + // it will attempt to connect at v3.1.1 and auto retry at v3.1 if that + // fails + Connect() Token + // Disconnect will end the connection with the server, but not before waiting + // the specified number of milliseconds to wait for existing work to be + // completed. + Disconnect(quiesce uint) + // Publish will publish a message with the specified QoS and content + // to the specified topic. + // Returns a token to track delivery of the message to the broker + Publish(topic string, qos byte, retained bool, payload interface{}) Token + // Subscribe starts a new subscription. Provide a MessageHandler to be executed when + // a message is published on the topic provided, or nil for the default handler. + // + // If options.OrderMatters is true (the default) then callback must not block or + // call functions within this package that may block (e.g. Publish) other than in + // a new go routine. + // callback must be safe for concurrent use by multiple goroutines. + Subscribe(topic string, qos byte, callback MessageHandler) Token + // SubscribeMultiple starts a new subscription for multiple topics. Provide a MessageHandler to + // be executed when a message is published on one of the topics provided, or nil for the + // default handler. + // + // If options.OrderMatters is true (the default) then callback must not block or + // call functions within this package that may block (e.g. Publish) other than in + // a new go routine. + // callback must be safe for concurrent use by multiple goroutines. + SubscribeMultiple(filters map[string]byte, callback MessageHandler) Token + // Unsubscribe will end the subscription from each of the topics provided. + // Messages published to those topics from other clients will no longer be + // received. + Unsubscribe(topics ...string) Token + // AddRoute allows you to add a handler for messages on a specific topic + // without making a subscription. For example having a different handler + // for parts of a wildcard subscription or for receiving retained messages + // upon connection (before Sub scribe can be processed). + // + // If options.OrderMatters is true (the default) then callback must not block or + // call functions within this package that may block (e.g. Publish) other than in + // a new go routine. + // callback must be safe for concurrent use by multiple goroutines. + AddRoute(topic string, callback MessageHandler) + // OptionsReader returns a ClientOptionsReader which is a copy of the clientoptions + // in use by the client. + OptionsReader() ClientOptionsReader +} + +// client implements the Client interface +// clients are safe for concurrent use by multiple +// goroutines +type client struct { + lastSent atomic.Value // time.Time - the last time a packet was successfully sent to network + lastReceived atomic.Value // time.Time - the last time a packet was successfully received from network + pingOutstanding int32 // set to 1 if a ping has been sent but response not ret received + + status connectionStatus // see constants in status.go for values + + messageIds // effectively a map from message id to token completor + + obound chan *PacketAndToken // outgoing publish packet + oboundP chan *PacketAndToken // outgoing 'priority' packet (anything other than publish) + msgRouter *router // routes topics to handlers + persist Store + options ClientOptions + optionsMu sync.Mutex // Protects the options in a few limited cases where needed for testing + + conn net.Conn // the network connection, must only be set with connMu locked (only used when starting/stopping workers) + connMu sync.Mutex // mutex for the connection (again only used in two functions) + + stop chan struct{} // Closed to request that workers stop + workers sync.WaitGroup // used to wait for workers to complete (ping, keepalive, errwatch, resume) + commsStopped chan struct{} // closed when the comms routines have stopped (kept running until after workers have closed to avoid deadlocks) + + backoff *backoffController +} + +// NewClient will create an MQTT v3.1.1 client with all of the options specified +// in the provided ClientOptions. The client must have the Connect method called +// on it before it may be used. This is to make sure resources (such as a net +// connection) are created before the application is actually ready. +func NewClient(o *ClientOptions) Client { + c := &client{} + c.options = *o + + if c.options.Store == nil { + c.options.Store = NewMemoryStore() + } + switch c.options.ProtocolVersion { + case 3, 4: + c.options.protocolVersionExplicit = true + case 0x83, 0x84: + c.options.protocolVersionExplicit = true + default: + c.options.ProtocolVersion = 4 + c.options.protocolVersionExplicit = false + } + c.persist = c.options.Store + c.messageIds = messageIds{index: make(map[uint16]tokenCompletor)} + c.msgRouter = newRouter() + c.msgRouter.setDefaultHandler(c.options.DefaultPublishHandler) + c.obound = make(chan *PacketAndToken) + c.oboundP = make(chan *PacketAndToken) + c.backoff = newBackoffController() + return c +} + +// AddRoute allows you to add a handler for messages on a specific topic +// without making a subscription. For example having a different handler +// for parts of a wildcard subscription +// +// If options.OrderMatters is true (the default) then callback must not block or +// call functions within this package that may block (e.g. Publish) other than in +// a new go routine. +// callback must be safe for concurrent use by multiple goroutines. +func (c *client) AddRoute(topic string, callback MessageHandler) { + if callback != nil { + c.msgRouter.addRoute(topic, callback) + } +} + +// IsConnected returns a bool signifying whether +// the client is connected or not. +// connected means that the connection is up now OR it will +// be established/reestablished automatically when possible +// Warning: The connection status may change at any time so use this with care! +func (c *client) IsConnected() bool { + // This will need to change if additional statuses are added + s, r := c.status.ConnectionStatusRetry() + switch { + case s == connected: + return true + case c.options.ConnectRetry && s == connecting: + return true + case c.options.AutoReconnect: + return s == reconnecting || (s == disconnecting && r) // r indicates we will reconnect + default: + return false + } +} + +// IsConnectionOpen return a bool signifying whether the client has an active +// connection to mqtt broker, i.e. not in disconnected or reconnect mode +// Warning: The connection status may change at any time so use this with care! +func (c *client) IsConnectionOpen() bool { + return c.status.ConnectionStatus() == connected +} + +// ErrNotConnected is the error returned from function calls that are +// made when the client is not connected to a broker +var ErrNotConnected = errors.New("not Connected") + +// Connect will create a connection to the message broker, by default +// it will attempt to connect at v3.1.1 and auto retry at v3.1 if that +// fails +// Note: If using QOS1+ and CleanSession=false it is advisable to add +// routes (or a DefaultPublishHandler) prior to calling Connect() +// because queued messages may be delivered immediately post connection +func (c *client) Connect() Token { + t := newToken(packets.Connect).(*ConnectToken) + DEBUG.Println(CLI, "Connect()") + + connectionUp, err := c.status.Connecting() + if err != nil { + if err == errAlreadyConnectedOrReconnecting && c.options.AutoReconnect { + // When reconnection is active we don't consider calls tro Connect to ba an error (mainly for compatability) + WARN.Println(CLI, "Connect() called but not disconnected") + t.returnCode = packets.Accepted + t.flowComplete() + return t + } + ERROR.Println(CLI, err) // CONNECT should never be called unless we are disconnected + t.setError(err) + return t + } + + c.persist.Open() + if c.options.ConnectRetry { + c.reserveStoredPublishIDs() // Reserve IDs to allow publishing before connect complete + } + + go func() { + if len(c.options.Servers) == 0 { + t.setError(fmt.Errorf("no servers defined to connect to")) + if err := connectionUp(false); err != nil { + ERROR.Println(CLI, err.Error()) + } + return + } + + var attemptCount int + + RETRYCONN: + var conn net.Conn + var rc byte + var err error + conn, rc, t.sessionPresent, err = c.attemptConnection(false, attemptCount) + if err != nil { + attemptCount++ + if c.options.ConnectRetry { + DEBUG.Println(CLI, "Connect failed, sleeping for", int(c.options.ConnectRetryInterval.Seconds()), "seconds and will then retry, error:", err.Error()) + time.Sleep(c.options.ConnectRetryInterval) + + if c.status.ConnectionStatus() == connecting { // Possible connection aborted elsewhere + goto RETRYCONN + } + } + ERROR.Println(CLI, "Failed to connect to a broker") + c.persist.Close() + t.returnCode = rc + t.setError(err) + if err := connectionUp(false); err != nil { + ERROR.Println(CLI, err.Error()) + } + return + } + inboundFromStore := make(chan packets.ControlPacket) // there may be some inbound comms packets in the store that are awaiting processing + if c.startCommsWorkers(conn, connectionUp, inboundFromStore) { // note that this takes care of updating the status (to connected or disconnected) + // Take care of any messages in the store + if !c.options.CleanSession { + c.resume(c.options.ResumeSubs, inboundFromStore) + } else { + c.persist.Reset() + } + } else { // Note: With the new status subsystem this should only happen if Disconnect called simultaneously with the above + WARN.Println(CLI, "Connect() called but connection established in another goroutine") + } + + close(inboundFromStore) + t.flowComplete() + DEBUG.Println(CLI, "exit startClient") + }() + return t +} + +// internal function used to reconnect the client when it loses its connection +// The connection status MUST be reconnecting prior to calling this function (via call to status.connectionLost) +func (c *client) reconnect(connectionUp connCompletedFn) { + DEBUG.Println(CLI, "enter reconnect") + var ( + initSleep = 1 * time.Second + conn net.Conn + ) + + // If the reason of connection lost is same as the before one, sleep timer is set before attempting connection is started. + // Sleep time is exponentially increased as the same situation continues + if slp, isContinual := c.backoff.sleepWithBackoff("connectionLost", initSleep, c.options.MaxReconnectInterval, 3*time.Second, true); isContinual { + DEBUG.Println(CLI, "Detect continual connection lost after reconnect, slept for", int(slp.Seconds()), "seconds") + } + + var attemptCount int + for { + if nil != c.options.OnReconnecting { + c.options.OnReconnecting(c, &c.options) + } + var err error + conn, _, _, err = c.attemptConnection(true, attemptCount) + if err == nil { + break + } + attemptCount++ + sleep, _ := c.backoff.sleepWithBackoff("attemptReconnection", initSleep, c.options.MaxReconnectInterval, c.options.ConnectTimeout, false) + DEBUG.Println(CLI, "Reconnect failed, slept for", int(sleep.Seconds()), "seconds:", err) + + if c.status.ConnectionStatus() != reconnecting { // Disconnect may have been called + if err := connectionUp(false); err != nil { // Should always return an error + ERROR.Println(CLI, err.Error()) + } + DEBUG.Println(CLI, "Client moved to disconnected state while reconnecting, abandoning reconnect") + return + } + } + + inboundFromStore := make(chan packets.ControlPacket) // there may be some inbound comms packets in the store that are awaiting processing + if c.startCommsWorkers(conn, connectionUp, inboundFromStore) { // note that this takes care of updating the status (to connected or disconnected) + c.resume(c.options.ResumeSubs, inboundFromStore) + } + close(inboundFromStore) +} + +// attemptConnection makes a single attempt to connect to each of the brokers +// the protocol version to use is passed in (as c.options.ProtocolVersion) +// Note: Does not set c.conn in order to minimise race conditions +// Returns: +// net.Conn - Connected network connection +// byte - Return code (packets.Accepted indicates a successful connection). +// bool - SessionPresent flag from the connect ack (only valid if packets.Accepted) +// err - Error (err != nil guarantees that conn has been set to active connection). +func (c *client) attemptConnection(isReconnect bool, attempt int) (net.Conn, byte, bool, error) { + protocolVersion := c.options.ProtocolVersion + var ( + sessionPresent bool + conn net.Conn + err error + rc byte + ) + + if c.options.OnConnectionNotification != nil { + c.options.OnConnectionNotification(c, ConnectionNotificationConnecting{isReconnect, attempt}) + } + + c.optionsMu.Lock() // Protect c.options.Servers so that servers can be added in test cases + brokers := c.options.Servers + c.optionsMu.Unlock() + for _, broker := range brokers { + cm := newConnectMsgFromOptions(&c.options, broker) + DEBUG.Println(CLI, "about to write new connect msg") + CONN: + tlsCfg := c.options.TLSConfig + if c.options.OnConnectAttempt != nil { + DEBUG.Println(CLI, "using custom onConnectAttempt handler...") + tlsCfg = c.options.OnConnectAttempt(broker, c.options.TLSConfig) + } + if c.options.OnConnectionNotification != nil { + c.options.OnConnectionNotification(c, ConnectionNotificationBroker{broker}) + } + connDeadline := time.Now().Add(c.options.ConnectTimeout) // Time by which connection must be established + dialer := c.options.Dialer + if dialer == nil { // + WARN.Println(CLI, "dialer was nil, using default") + dialer = &net.Dialer{Timeout: 30 * time.Second} + } + // Start by opening the network connection (tcp, tls, ws) etc + if c.options.CustomOpenConnectionFn != nil { + conn, err = c.options.CustomOpenConnectionFn(broker, c.options) + } else { + conn, err = openConnection(broker, tlsCfg, c.options.ConnectTimeout, c.options.HTTPHeaders, c.options.WebsocketOptions, dialer) + } + if err != nil { + ERROR.Println(CLI, err.Error()) + WARN.Println(CLI, "failed to connect to broker, trying next") + rc = packets.ErrNetworkError + if c.options.OnConnectionNotification != nil { + c.options.OnConnectionNotification(c, ConnectionNotificationBrokerFailed{broker, err}) + } + continue + } + DEBUG.Println(CLI, "socket connected to broker") + + // Now we perform the MQTT connection handshake ensuring that it does not exceed the timeout + if err := conn.SetDeadline(connDeadline); err != nil { + ERROR.Println(CLI, "set deadline for handshake ", err) + } + + // Now we perform the MQTT connection handshake + rc, sessionPresent, err = connectMQTT(conn, cm, protocolVersion) + if rc == packets.Accepted { + if err := conn.SetDeadline(time.Time{}); err != nil { + ERROR.Println(CLI, "reset deadline following handshake ", err) + } + break // successfully connected + } + + // We may have to attempt the connection with MQTT 3.1 + _ = conn.Close() + + if !c.options.protocolVersionExplicit && protocolVersion == 4 { // try falling back to 3.1? + DEBUG.Println(CLI, "Trying reconnect using MQTT 3.1 protocol") + protocolVersion = 3 + goto CONN + } + if c.options.protocolVersionExplicit { // to maintain logging from previous version + ERROR.Println(CLI, "Connecting to", broker, "CONNACK was not CONN_ACCEPTED, but rather", packets.ConnackReturnCodes[rc]) + } + } + // If the connection was successful we set member variable and lock in the protocol version for future connection attempts (and users) + if rc == packets.Accepted { + c.options.ProtocolVersion = protocolVersion + c.options.protocolVersionExplicit = true + } else { + // Maintain same error format as used previously + if rc != packets.ErrNetworkError { // mqtt error + err = packets.ConnErrors[rc] + } else { // network error (if this occurred in ConnectMQTT then err will be nil) + err = fmt.Errorf("%w : %w", packets.ConnErrors[rc], err) + } + } + if err != nil && c.options.OnConnectionNotification != nil { + c.options.OnConnectionNotification(c, ConnectionNotificationFailed{err}) + } + return conn, rc, sessionPresent, err +} + +// Disconnect will end the connection with the server, but not before waiting +// the specified number of milliseconds to wait for existing work to be +// completed. +// WARNING: `Disconnect` may return before all activities (goroutines) have completed. This means that +// reusing the `client` may lead to panics. If you want to reconnect when the connection drops then use +// `SetAutoReconnect` and/or `SetConnectRetry`options instead of implementing this yourself. +func (c *client) Disconnect(quiesce uint) { + done := make(chan struct{}) // Simplest way to ensure quiesce is always honoured + go func() { + defer close(done) + disDone, err := c.status.Disconnecting() + if err != nil { + // Status has been set to disconnecting, but we had to wait for something else to complete + WARN.Println(CLI, err.Error()) + return + } + defer func() { + c.disconnect() // Force disconnection + disDone() // Update status + }() + DEBUG.Println(CLI, "disconnecting") + dm := packets.NewControlPacket(packets.Disconnect).(*packets.DisconnectPacket) + dt := newToken(packets.Disconnect) + select { + case c.oboundP <- &PacketAndToken{p: dm, t: dt}: + // wait for work to finish, or quiesce time consumed + DEBUG.Println(CLI, "calling WaitTimeout") + dt.WaitTimeout(time.Duration(quiesce) * time.Millisecond) + DEBUG.Println(CLI, "WaitTimeout done") + // Below code causes a potential data race. Following status refactor it should no longer be required + // but leaving in as need to check code further. + // case <-c.commsStopped: + // WARN.Println("Disconnect packet could not be sent because comms stopped") + case <-time.After(time.Duration(quiesce) * time.Millisecond): + WARN.Println("Disconnect packet not sent due to timeout") + } + }() + + // Return when done or after timeout expires (would like to change but this maintains compatibility) + delay := time.NewTimer(time.Duration(quiesce) * time.Millisecond) + select { + case <-done: + if !delay.Stop() { + <-delay.C + } + case <-delay.C: + } +} + +// forceDisconnect will end the connection with the mqtt broker immediately (used for tests only) +func (c *client) forceDisconnect() { + disDone, err := c.status.Disconnecting() + if err != nil { + // Possible that we are not actually connected + WARN.Println(CLI, err.Error()) + return + } + DEBUG.Println(CLI, "forcefully disconnecting") + c.disconnect() + disDone() +} + +// disconnect cleans up after a final disconnection (user requested so no auto reconnection) +func (c *client) disconnect() { + done := c.stopCommsWorkers() + if done != nil { + <-done // Wait until the disconnect is complete (to limit chance that another connection will be started) + DEBUG.Println(CLI, "forcefully disconnecting") + c.messageIds.cleanUp() + DEBUG.Println(CLI, "disconnected") + c.persist.Close() + } +} + +// internalConnLost cleanup when connection is lost or an error occurs +// Note: This function will not block +func (c *client) internalConnLost(whyConnLost error) { + // It is possible that internalConnLost will be called multiple times simultaneously + // (including after sending a DisconnectPacket) as such we only do cleanup etc if the + // routines were actually running and are not being disconnected at users request + DEBUG.Println(CLI, "internalConnLost called") + disDone, err := c.status.ConnectionLost(c.options.AutoReconnect && c.status.ConnectionStatus() > connecting) + if err != nil { + if err == errConnLossWhileDisconnecting || err == errAlreadyHandlingConnectionLoss { + return // Loss of connection is expected or already being handled + } + ERROR.Println(CLI, fmt.Sprintf("internalConnLost unexpected status: %s", err.Error())) + return + } + + // c.stopCommsWorker returns a channel that is closed when the operation completes. This was required prior + // to the implementation of proper status management but has been left in place, for now, to minimise change + stopDone := c.stopCommsWorkers() + // stopDone was required in previous versions because there was no connectionLost status (and there were + // issues with status handling). This code has been left in place for the time being just in case the new + // status handling contains bugs (refactoring required at some point). + if stopDone == nil { // stopDone will be nil if workers already in the process of stopping or stopped + ERROR.Println(CLI, "internalConnLost stopDone unexpectedly nil - BUG BUG") + // Cannot really do anything other than leave things disconnected + if _, err = disDone(false); err != nil { // Safest option - cannot leave status as connectionLost + ERROR.Println(CLI, fmt.Sprintf("internalConnLost failed to set status to disconnected (stopDone): %s", err.Error())) + } + return + } + + // It may take a while for the disconnection to complete whatever called us needs to exit cleanly so finnish in goRoutine + go func() { + DEBUG.Println(CLI, "internalConnLost waiting on workers") + <-stopDone + DEBUG.Println(CLI, "internalConnLost workers stopped") + + reConnDone, err := disDone(true) + if err != nil { + ERROR.Println(CLI, "failure whilst reporting completion of disconnect", err) + } else if reConnDone == nil { // Should never happen + ERROR.Println(CLI, "BUG BUG BUG reconnection function is nil", err) + } + + reconnect := err == nil && reConnDone != nil + + if c.options.CleanSession && !reconnect { + c.messageIds.cleanUp() // completes PUB/SUB/UNSUB tokens + } else if !c.options.ResumeSubs { + c.messageIds.cleanUpSubscribe() // completes SUB/UNSUB tokens + } + if reconnect { + go c.reconnect(reConnDone) // Will set connection status to reconnecting + } + if c.options.OnConnectionLost != nil { + go c.options.OnConnectionLost(c, whyConnLost) + } + if c.options.OnConnectionNotification != nil { + go c.options.OnConnectionNotification(c, ConnectionNotificationLost{whyConnLost}) + } + DEBUG.Println(CLI, "internalConnLost complete") + }() +} + +// startCommsWorkers is called when the connection is up. +// It starts off the routines needed to process incoming and outgoing messages. +// Returns true if the comms workers were started (i.e. successful connection) +// connectionUp(true) will be called once everything is up; connectionUp(false) will be called on failure +func (c *client) startCommsWorkers(conn net.Conn, connectionUp connCompletedFn, inboundFromStore <-chan packets.ControlPacket) bool { + DEBUG.Println(CLI, "startCommsWorkers called") + c.connMu.Lock() + defer c.connMu.Unlock() + if c.conn != nil { // Should never happen due to new status handling; leaving in for safety for the time being + WARN.Println(CLI, "startCommsWorkers called when commsworkers already running BUG BUG") + _ = conn.Close() // No use for the new network connection + if err := connectionUp(false); err != nil { + ERROR.Println(CLI, err.Error()) + } + return false + } + c.conn = conn // Store the connection + + c.stop = make(chan struct{}) + if c.options.KeepAlive != 0 { + atomic.StoreInt32(&c.pingOutstanding, 0) + c.lastReceived.Store(time.Now()) + c.lastSent.Store(time.Now()) + c.workers.Add(1) + go keepalive(c, conn) + } + + // matchAndDispatch will process messages received from the network. It may generate acknowledgements + // It will complete when incomingPubChan is closed and will close ackOut prior to exiting + incomingPubChan := make(chan *packets.PublishPacket) + c.workers.Add(1) // Done will be called when ackOut is closed + ackOut := c.msgRouter.matchAndDispatch(incomingPubChan, c.options.Order, c) + + // The connection is now ready for use (we spin up a few go routines below). + // It is possible that Disconnect has been called in the interim... + // issue 675:we will allow the connection to complete before the Disconnect is allowed to proceed + // as if a Disconnect event occurred immediately after connectionUp(true) completed. + if err := connectionUp(true); err != nil { + ERROR.Println(CLI, err) + } + + DEBUG.Println(CLI, "client is connected/reconnected") + if c.options.OnConnect != nil { + go c.options.OnConnect(c) + } + if c.options.OnConnectionNotification != nil { + go c.options.OnConnectionNotification(c, ConnectionNotificationConnected{}) + } + + // c.oboundP and c.obound need to stay active for the life of the client because, depending upon the options, + // messages may be published while the client is disconnected (they will block unless in a goroutine). However + // to keep the comms routines clean we want to shutdown the input messages it uses so create out own channels + // and copy data across. + commsobound := make(chan *PacketAndToken) // outgoing publish packets + commsoboundP := make(chan *PacketAndToken) // outgoing 'priority' packet + c.workers.Add(1) + go func() { + defer c.workers.Done() + for { + select { + case msg := <-c.oboundP: + commsoboundP <- msg + case msg := <-c.obound: + commsobound <- msg + case msg, ok := <-ackOut: + if !ok { + ackOut = nil // ignore channel going forward + c.workers.Done() // matchAndDispatch has completed + continue // await next message + } + commsoboundP <- msg + case <-c.stop: + // Attempt to transmit any outstanding acknowledgements (this may well fail but should work if this is a clean disconnect) + if ackOut != nil { + for msg := range ackOut { + commsoboundP <- msg + } + c.workers.Done() // matchAndDispatch has completed + } + close(commsoboundP) // Nothing sending to these channels anymore so close them and allow comms routines to exit + close(commsobound) + DEBUG.Println(CLI, "startCommsWorkers output redirector finished") + return + } + } + }() + + commsIncomingPub, commsErrors := startComms(c.conn, c, inboundFromStore, commsoboundP, commsobound) + c.commsStopped = make(chan struct{}) + go func() { + for { + if commsIncomingPub == nil && commsErrors == nil { + break + } + select { + case pub, ok := <-commsIncomingPub: + if !ok { + // Incoming comms has shutdown + close(incomingPubChan) // stop the router + commsIncomingPub = nil + continue + } + // Care is needed here because an error elsewhere could trigger a deadlock + sendPubLoop: + for { + select { + case incomingPubChan <- pub: + break sendPubLoop + case err, ok := <-commsErrors: + if !ok { // commsErrors has been closed so we can ignore it + commsErrors = nil + continue + } + ERROR.Println(CLI, "Connect comms goroutine - error triggered during send Pub", err) + c.internalConnLost(err) // no harm in calling this if the connection is already down (or shutdown is in progress) + continue + } + } + case err, ok := <-commsErrors: + if !ok { + commsErrors = nil + continue + } + ERROR.Println(CLI, "Connect comms goroutine - error triggered", err) + c.internalConnLost(err) // no harm in calling this if the connection is already down (or shutdown is in progress) + continue + } + } + DEBUG.Println(CLI, "incoming comms goroutine done") + close(c.commsStopped) + }() + DEBUG.Println(CLI, "startCommsWorkers done") + return true +} + +// stopWorkersAndComms - Cleanly shuts down worker go routines (including the comms routines) and waits until everything has stopped +// Returns nil if workers did not need to be stopped; otherwise returns a channel which will be closed when the stop is complete +// Note: This may block so run as a go routine if calling from any of the comms routines +// Note2: It should be possible to simplify this now that the new status management code is in place. +func (c *client) stopCommsWorkers() chan struct{} { + DEBUG.Println(CLI, "stopCommsWorkers called") + // It is possible that this function will be called multiple times simultaneously due to the way things get shutdown + c.connMu.Lock() + if c.conn == nil { + DEBUG.Println(CLI, "stopCommsWorkers done (not running)") + c.connMu.Unlock() + return nil + } + + // It is important that everything is stopped in the correct order to avoid deadlocks. The main issue here is + // the router because it both receives incoming publish messages and also sends outgoing acknowledgements. To + // avoid issues we signal the workers to stop and close the connection (it is probably already closed but + // there is no harm in being sure). We can then wait for the workers to finnish before closing outbound comms + // channels which will allow the comms routines to exit. + + // We stop all non-comms related workers first (ping, keepalive, errwatch, resume etc) so they don't get blocked waiting on comms + close(c.stop) // Signal for workers to stop + c.conn.Close() // Possible that this is already closed but no harm in closing again + c.conn = nil // Important that this is the only place that this is set to nil + c.connMu.Unlock() // As the connection is now nil we can unlock the mu (allowing subsequent calls to exit immediately) + + doneChan := make(chan struct{}) + + go func() { + DEBUG.Println(CLI, "stopCommsWorkers waiting for workers") + c.workers.Wait() + + // Stopping the workers will allow the comms routines to exit; we wait for these to complete + DEBUG.Println(CLI, "stopCommsWorkers waiting for comms") + <-c.commsStopped // wait for comms routine to stop + + DEBUG.Println(CLI, "stopCommsWorkers done") + close(doneChan) + }() + return doneChan +} + +// Publish will publish a message with the specified QoS and content +// to the specified topic. +// Returns a token to track delivery of the message to the broker +func (c *client) Publish(topic string, qos byte, retained bool, payload interface{}) Token { + token := newToken(packets.Publish).(*PublishToken) + DEBUG.Println(CLI, "enter Publish") + switch { + case !c.IsConnected(): + token.setError(ErrNotConnected) + return token + case c.status.ConnectionStatus() == reconnecting && qos == 0: + // message written to store and will be sent when connection comes up + token.flowComplete() + return token + } + pub := packets.NewControlPacket(packets.Publish).(*packets.PublishPacket) + pub.Qos = qos + pub.TopicName = topic + pub.Retain = retained + switch p := payload.(type) { + case string: + pub.Payload = []byte(p) + case []byte: + pub.Payload = p + case bytes.Buffer: + pub.Payload = p.Bytes() + default: + token.setError(fmt.Errorf("unknown payload type")) + return token + } + + if pub.Qos != 0 && pub.MessageID == 0 { + mID := c.getID(token) + if mID == 0 { + token.setError(fmt.Errorf("no message IDs available")) + return token + } + pub.MessageID = mID + token.messageID = mID + } + persistOutbound(c.persist, pub) + switch c.status.ConnectionStatus() { + case connecting: + DEBUG.Println(CLI, "storing publish message (connecting), topic:", topic) + case reconnecting: + DEBUG.Println(CLI, "storing publish message (reconnecting), topic:", topic) + case disconnecting: + DEBUG.Println(CLI, "storing publish message (disconnecting), topic:", topic) + default: + DEBUG.Println(CLI, "sending publish message, topic:", topic) + publishWaitTimeout := c.options.WriteTimeout + if publishWaitTimeout == 0 { + publishWaitTimeout = time.Second * 30 + } + + t := time.NewTimer(publishWaitTimeout) + defer t.Stop() + + select { + case c.obound <- &PacketAndToken{p: pub, t: token}: + case <-t.C: + token.setError(errors.New("publish was broken by timeout")) + } + } + return token +} + +// Subscribe starts a new subscription. Provide a MessageHandler to be executed when +// a message is published on the topic provided. +// +// If options.OrderMatters is true (the default) then callback must not block or +// call functions within this package that may block (e.g. Publish) other than in +// a new go routine. +// callback must be safe for concurrent use by multiple goroutines. +func (c *client) Subscribe(topic string, qos byte, callback MessageHandler) Token { + token := newToken(packets.Subscribe).(*SubscribeToken) + DEBUG.Println(CLI, "enter Subscribe") + if !c.IsConnected() { + token.setError(ErrNotConnected) + return token + } + if !c.IsConnectionOpen() { + switch { + case !c.options.ResumeSubs: + // if not connected and resumeSubs not set this sub will be thrown away + token.setError(fmt.Errorf("not currently connected and ResumeSubs not set")) + return token + case c.options.CleanSession && c.status.ConnectionStatus() == reconnecting: + // if reconnecting and cleanSession is true this sub will be thrown away + token.setError(fmt.Errorf("reconnecting state and cleansession is true")) + return token + } + } + sub := packets.NewControlPacket(packets.Subscribe).(*packets.SubscribePacket) + if err := validateTopicAndQos(topic, qos); err != nil { + token.setError(err) + return token + } + sub.Topics = append(sub.Topics, topic) + sub.Qoss = append(sub.Qoss, qos) + + if strings.HasPrefix(topic, "$share/") { + topic = strings.Join(strings.Split(topic, "/")[2:], "/") + } + + if strings.HasPrefix(topic, "$queue/") { + topic = strings.TrimPrefix(topic, "$queue/") + } + + if callback != nil { + c.msgRouter.addRoute(topic, callback) + } + + token.subs = append(token.subs, topic) + + if sub.MessageID == 0 { + mID := c.getID(token) + if mID == 0 { + token.setError(fmt.Errorf("no message IDs available")) + return token + } + sub.MessageID = mID + token.messageID = mID + } + DEBUG.Println(CLI, sub.String()) + + if c.options.ResumeSubs { // Only persist if we need this to resume subs after a disconnection + persistOutbound(c.persist, sub) + } + switch c.status.ConnectionStatus() { + case connecting: + DEBUG.Println(CLI, "storing subscribe message (connecting), topic:", topic) + case reconnecting: + DEBUG.Println(CLI, "storing subscribe message (reconnecting), topic:", topic) + case disconnecting: + DEBUG.Println(CLI, "storing subscribe message (disconnecting), topic:", topic) + default: + DEBUG.Println(CLI, "sending subscribe message, topic:", topic) + subscribeWaitTimeout := c.options.WriteTimeout + if subscribeWaitTimeout == 0 { + subscribeWaitTimeout = time.Second * 30 + } + select { + case c.oboundP <- &PacketAndToken{p: sub, t: token}: + case <-time.After(subscribeWaitTimeout): + token.setError(errors.New("subscribe was broken by timeout")) + } + } + DEBUG.Println(CLI, "exit Subscribe") + return token +} + +// SubscribeMultiple starts a new subscription for multiple topics. Provide a MessageHandler to +// be executed when a message is published on one of the topics provided. +// +// If options.OrderMatters is true (the default) then callback must not block or +// call functions within this package that may block (e.g. Publish) other than in +// a new go routine. +// callback must be safe for concurrent use by multiple goroutines. +func (c *client) SubscribeMultiple(filters map[string]byte, callback MessageHandler) Token { + var err error + token := newToken(packets.Subscribe).(*SubscribeToken) + DEBUG.Println(CLI, "enter SubscribeMultiple") + if !c.IsConnected() { + token.setError(ErrNotConnected) + return token + } + if !c.IsConnectionOpen() { + switch { + case !c.options.ResumeSubs: + // if not connected and resumesubs not set this sub will be thrown away + token.setError(fmt.Errorf("not currently connected and ResumeSubs not set")) + return token + case c.options.CleanSession && c.status.ConnectionStatus() == reconnecting: + // if reconnecting and cleanSession is true this sub will be thrown away + token.setError(fmt.Errorf("reconnecting state and cleansession is true")) + return token + } + } + sub := packets.NewControlPacket(packets.Subscribe).(*packets.SubscribePacket) + if sub.Topics, sub.Qoss, err = validateSubscribeMap(filters); err != nil { + token.setError(err) + return token + } + + if callback != nil { + for topic := range filters { + c.msgRouter.addRoute(topic, callback) + } + } + token.subs = make([]string, len(sub.Topics)) + copy(token.subs, sub.Topics) + + if sub.MessageID == 0 { + mID := c.getID(token) + if mID == 0 { + token.setError(fmt.Errorf("no message IDs available")) + return token + } + sub.MessageID = mID + token.messageID = mID + } + if c.options.ResumeSubs { // Only persist if we need this to resume subs after a disconnection + persistOutbound(c.persist, sub) + } + switch c.status.ConnectionStatus() { + case connecting: + DEBUG.Println(CLI, "storing subscribe message (connecting), topics:", sub.Topics) + case reconnecting: + DEBUG.Println(CLI, "storing subscribe message (reconnecting), topics:", sub.Topics) + case disconnecting: + DEBUG.Println(CLI, "storing subscribe message (disconnecting), topics:", sub.Topics) + default: + DEBUG.Println(CLI, "sending subscribe message, topics:", sub.Topics) + subscribeWaitTimeout := c.options.WriteTimeout + if subscribeWaitTimeout == 0 { + subscribeWaitTimeout = time.Second * 30 + } + select { + case c.oboundP <- &PacketAndToken{p: sub, t: token}: + case <-time.After(subscribeWaitTimeout): + token.setError(errors.New("subscribe was broken by timeout")) + } + } + DEBUG.Println(CLI, "exit SubscribeMultiple") + return token +} + +// reserveStoredPublishIDs reserves the ids for publish packets in the persistent store to ensure these are not duplicated +func (c *client) reserveStoredPublishIDs() { + // The resume function sets the stored id for publish packets only (some other packets + // will get new ids in net code). This means that the only keys we need to ensure are + // unique are the publish ones (and these will completed/replaced in resume() ) + if !c.options.CleanSession { + storedKeys := c.persist.All() + for _, key := range storedKeys { + packet := c.persist.Get(key) + if packet == nil { + continue + } + switch packet.(type) { + case *packets.PublishPacket: + details := packet.Details() + token := &PlaceHolderToken{id: details.MessageID} + c.claimID(token, details.MessageID) + } + } + } +} + +// Load all stored messages and resend them +// Call this to ensure QOS > 1,2 even after an application crash +// Note: This function will exit if c.stop is closed (this allows the shutdown to proceed avoiding a potential deadlock) +// other than that it does not return until all messages in the store have been sent (connect() does not complete its +// token before this completes) +func (c *client) resume(subscription bool, ibound chan packets.ControlPacket) { + DEBUG.Println(STR, "enter Resume") + + // Prior to sending a message getSemaphore will be called and once sent releaseSemaphore will be called + // with the token (so semaphore can be released when ACK received if applicable). + // Using a weighted semaphore rather than channels because this retains ordering + getSemaphore := func() {} // Default = do nothing + releaseSemaphore := func(_ *PublishToken) {} // Default = do nothing + var sem *semaphore.Weighted + if c.options.MaxResumePubInFlight > 0 { + sem = semaphore.NewWeighted(int64(c.options.MaxResumePubInFlight)) + ctx, cancel := context.WithCancel(context.Background()) // Context needed for semaphore + defer cancel() // ensure context gets cancelled + + go func() { + select { + case <-c.stop: // Request to stop (due to comm error etc) + cancel() + case <-ctx.Done(): // resume completed normally + } + }() + + getSemaphore = func() { sem.Acquire(ctx, 1) } + releaseSemaphore = func(token *PublishToken) { // Note: If token never completes then resume() may stall (will still exit on ctx.Done()) + go func() { + select { + case <-token.Done(): + case <-ctx.Done(): + } + sem.Release(1) + }() + } + } + + storedKeys := c.persist.All() + for _, key := range storedKeys { + packet := c.persist.Get(key) + if packet == nil { + DEBUG.Println(STR, fmt.Sprintf("resume found NIL packet (%s)", key)) + continue + } + details := packet.Details() + if isKeyOutbound(key) { + switch p := packet.(type) { + case *packets.SubscribePacket: + if subscription { + DEBUG.Println(STR, fmt.Sprintf("loaded pending subscribe (%d)", details.MessageID)) + subPacket := packet.(*packets.SubscribePacket) + token := newToken(packets.Subscribe).(*SubscribeToken) + token.messageID = details.MessageID + token.subs = append(token.subs, subPacket.Topics...) + c.claimID(token, details.MessageID) + select { + case c.oboundP <- &PacketAndToken{p: packet, t: token}: + case <-c.stop: + DEBUG.Println(STR, "resume exiting due to stop") + return + } + } else { + c.persist.Del(key) // Unsubscribe packets should not be retained following a reconnect + } + case *packets.UnsubscribePacket: + if subscription { + DEBUG.Println(STR, fmt.Sprintf("loaded pending unsubscribe (%d)", details.MessageID)) + token := newToken(packets.Unsubscribe).(*UnsubscribeToken) + select { + case c.oboundP <- &PacketAndToken{p: packet, t: token}: + case <-c.stop: + DEBUG.Println(STR, "resume exiting due to stop") + return + } + } else { + c.persist.Del(key) // Unsubscribe packets should not be retained following a reconnect + } + case *packets.PubrelPacket: + DEBUG.Println(STR, fmt.Sprintf("loaded pending pubrel (%d)", details.MessageID)) + select { + case c.oboundP <- &PacketAndToken{p: packet, t: nil}: + case <-c.stop: + DEBUG.Println(STR, "resume exiting due to stop") + return + } + case *packets.PublishPacket: + // spec: If the DUP flag is set to 0, it indicates that this is the first occasion that the Client or + // Server has attempted to send this MQTT PUBLISH Packet. If the DUP flag is set to 1, it indicates that + // this might be re-delivery of an earlier attempt to send the Packet. + // + // If the message is in the store than an attempt at delivery has been made (note that the message may + // never have made it onto the wire but tracking that would be complicated!). + if p.Qos != 0 { // spec: The DUP flag MUST be set to 0 for all QoS 0 messages + p.Dup = true + } + token := newToken(packets.Publish).(*PublishToken) + token.messageID = details.MessageID + c.claimID(token, details.MessageID) + DEBUG.Println(STR, fmt.Sprintf("loaded pending publish (%d)", details.MessageID)) + DEBUG.Println(STR, details) + getSemaphore() + select { + case c.obound <- &PacketAndToken{p: p, t: token}: + case <-c.stop: + DEBUG.Println(STR, "resume exiting due to stop") + return + } + releaseSemaphore(token) // If limiting simultaneous messages then we need to know when message is acknowledged + default: + ERROR.Println(STR, fmt.Sprintf("invalid message type (inbound - %T) in store (discarded)", packet)) + c.persist.Del(key) + } + } else { + switch packet.(type) { + case *packets.PubrelPacket: + DEBUG.Println(STR, fmt.Sprintf("loaded pending incomming (%d)", details.MessageID)) + select { + case ibound <- packet: + case <-c.stop: + DEBUG.Println(STR, "resume exiting due to stop (ibound <- packet)") + return + } + default: + ERROR.Println(STR, fmt.Sprintf("invalid message type (%T) in store (discarded)", packet)) + c.persist.Del(key) + } + } + } + DEBUG.Println(STR, "exit resume") +} + +// Unsubscribe will end the subscription from each of the topics provided. +// Messages published to those topics from other clients will no longer be +// received. +func (c *client) Unsubscribe(topics ...string) Token { + token := newToken(packets.Unsubscribe).(*UnsubscribeToken) + DEBUG.Println(CLI, "enter Unsubscribe") + if !c.IsConnected() { + token.setError(ErrNotConnected) + return token + } + if !c.IsConnectionOpen() { + switch { + case !c.options.ResumeSubs: + // if not connected and resumeSubs not set this unsub will be thrown away + token.setError(fmt.Errorf("not currently connected and ResumeSubs not set")) + return token + case c.options.CleanSession && c.status.ConnectionStatus() == reconnecting: + // if reconnecting and cleanSession is true this unsub will be thrown away + token.setError(fmt.Errorf("reconnecting state and cleansession is true")) + return token + } + } + unsub := packets.NewControlPacket(packets.Unsubscribe).(*packets.UnsubscribePacket) + unsub.Topics = make([]string, len(topics)) + copy(unsub.Topics, topics) + + if unsub.MessageID == 0 { + mID := c.getID(token) + if mID == 0 { + token.setError(fmt.Errorf("no message IDs available")) + return token + } + unsub.MessageID = mID + token.messageID = mID + } + + if c.options.ResumeSubs { // Only persist if we need this to resume subs after a disconnection + persistOutbound(c.persist, unsub) + } + + switch c.status.ConnectionStatus() { + case connecting: + DEBUG.Println(CLI, "storing unsubscribe message (connecting), topics:", topics) + case reconnecting: + DEBUG.Println(CLI, "storing unsubscribe message (reconnecting), topics:", topics) + case disconnecting: + DEBUG.Println(CLI, "storing unsubscribe message (reconnecting), topics:", topics) + default: + DEBUG.Println(CLI, "sending unsubscribe message, topics:", topics) + subscribeWaitTimeout := c.options.WriteTimeout + if subscribeWaitTimeout == 0 { + subscribeWaitTimeout = time.Second * 30 + } + select { + case c.oboundP <- &PacketAndToken{p: unsub, t: token}: + for _, topic := range topics { + c.msgRouter.deleteRoute(topic) + } + case <-time.After(subscribeWaitTimeout): + token.setError(errors.New("unsubscribe was broken by timeout")) + } + } + + DEBUG.Println(CLI, "exit Unsubscribe") + return token +} + +// OptionsReader returns a ClientOptionsReader which is a copy of the clientoptions +// in use by the client. +func (c *client) OptionsReader() ClientOptionsReader { + r := ClientOptionsReader{options: &c.options} + return r +} + +// DefaultConnectionLostHandler is a definition of a function that simply +// reports to the DEBUG log the reason for the client losing a connection. +func DefaultConnectionLostHandler(client Client, reason error) { + DEBUG.Println("Connection lost:", reason.Error()) +} + +// UpdateLastReceived - Will be called whenever a packet is received off the network +// This is used by the keepalive routine to +func (c *client) UpdateLastReceived() { + if c.options.KeepAlive != 0 { + c.lastReceived.Store(time.Now()) + } +} + +// UpdateLastReceived - Will be called whenever a packet is successfully transmitted to the network +func (c *client) UpdateLastSent() { + if c.options.KeepAlive != 0 { + c.lastSent.Store(time.Now()) + } +} + +// getWriteTimeOut returns the writetimeout (duration to wait when writing to the connection) or 0 if none +func (c *client) getWriteTimeOut() time.Duration { + return c.options.WriteTimeout +} + +// persistOutbound adds the packet to the outbound store +func (c *client) persistOutbound(m packets.ControlPacket) { + persistOutbound(c.persist, m) +} + +// persistInbound adds the packet to the inbound store +func (c *client) persistInbound(m packets.ControlPacket) { + persistInbound(c.persist, m) +} + +// pingRespReceived will be called by the network routines when a ping response is received +func (c *client) pingRespReceived() { + atomic.StoreInt32(&c.pingOutstanding, 0) +} diff --git a/mcp/dsx-exchange-mcp/vendor/github.com/eclipse/paho.mqtt.golang/components.go b/mcp/dsx-exchange-mcp/vendor/github.com/eclipse/paho.mqtt.golang/components.go new file mode 100644 index 0000000..524db03 --- /dev/null +++ b/mcp/dsx-exchange-mcp/vendor/github.com/eclipse/paho.mqtt.golang/components.go @@ -0,0 +1,36 @@ +/* + * Copyright (c) 2021 IBM Corp and others. + * + * All rights reserved. This program and the accompanying materials + * are made available under the terms of the Eclipse Public License v2.0 + * and Eclipse Distribution License v1.0 which accompany this distribution. + * + * The Eclipse Public License is available at + * https://www.eclipse.org/legal/epl-2.0/ + * and the Eclipse Distribution License is available at + * http://www.eclipse.org/org/documents/edl-v10.php. + * + * Contributors: + * Seth Hoenig + * Allan Stockdill-Mander + * Mike Robertson + */ + +package mqtt + +type component string + +// Component names for debug output +const ( + NET component = "[net] " + PNG component = "[pinger] " + CLI component = "[client] " + DEC component = "[decode] " + MES component = "[message] " + STR component = "[store] " + MID component = "[msgids] " + TST component = "[test] " + STA component = "[state] " + ERR component = "[error] " + ROU component = "[router] " +) diff --git a/mcp/dsx-exchange-mcp/vendor/github.com/eclipse/paho.mqtt.golang/connnotf.go b/mcp/dsx-exchange-mcp/vendor/github.com/eclipse/paho.mqtt.golang/connnotf.go new file mode 100644 index 0000000..3f50fca --- /dev/null +++ b/mcp/dsx-exchange-mcp/vendor/github.com/eclipse/paho.mqtt.golang/connnotf.go @@ -0,0 +1,79 @@ +package mqtt + +import "net/url" + +type ConnectionNotificationType int64 + +const ( + ConnectionNotificationTypeConnected ConnectionNotificationType = iota + ConnectionNotificationTypeConnecting + ConnectionNotificationTypeFailed + ConnectionNotificationTypeLost + ConnectionNotificationTypeBroker + ConnectionNotificationTypeBrokerFailed +) + +type ConnectionNotification interface { + Type() ConnectionNotificationType +} + +// Connected + +type ConnectionNotificationConnected struct { +} + +func (n ConnectionNotificationConnected) Type() ConnectionNotificationType { + return ConnectionNotificationTypeConnected +} + +// Connecting + +type ConnectionNotificationConnecting struct { + IsReconnect bool + Attempt int +} + +func (n ConnectionNotificationConnecting) Type() ConnectionNotificationType { + return ConnectionNotificationTypeConnecting +} + +// Connection Failed + +type ConnectionNotificationFailed struct { + Reason error +} + +func (n ConnectionNotificationFailed) Type() ConnectionNotificationType { + return ConnectionNotificationTypeFailed +} + +// Connection Lost + +type ConnectionNotificationLost struct { + Reason error // may be nil +} + +func (n ConnectionNotificationLost) Type() ConnectionNotificationType { + return ConnectionNotificationTypeLost +} + +// Broker Connection + +type ConnectionNotificationBroker struct { + Broker *url.URL +} + +func (n ConnectionNotificationBroker) Type() ConnectionNotificationType { + return ConnectionNotificationTypeBroker +} + +// Broker Connection Failed + +type ConnectionNotificationBrokerFailed struct { + Broker *url.URL + Reason error +} + +func (n ConnectionNotificationBrokerFailed) Type() ConnectionNotificationType { + return ConnectionNotificationTypeBrokerFailed +} diff --git a/mcp/dsx-exchange-mcp/vendor/github.com/eclipse/paho.mqtt.golang/edl-v10 b/mcp/dsx-exchange-mcp/vendor/github.com/eclipse/paho.mqtt.golang/edl-v10 new file mode 100644 index 0000000..cf989f1 --- /dev/null +++ b/mcp/dsx-exchange-mcp/vendor/github.com/eclipse/paho.mqtt.golang/edl-v10 @@ -0,0 +1,15 @@ + +Eclipse Distribution License - v 1.0 + +Copyright (c) 2007, Eclipse Foundation, Inc. and its licensors. + +All rights reserved. + +Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met: + + Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer. + Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution. + Neither the name of the Eclipse Foundation, Inc. nor the names of its contributors may be used to endorse or promote products derived from this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + diff --git a/mcp/dsx-exchange-mcp/vendor/github.com/eclipse/paho.mqtt.golang/epl-v20 b/mcp/dsx-exchange-mcp/vendor/github.com/eclipse/paho.mqtt.golang/epl-v20 new file mode 100644 index 0000000..e55f344 --- /dev/null +++ b/mcp/dsx-exchange-mcp/vendor/github.com/eclipse/paho.mqtt.golang/epl-v20 @@ -0,0 +1,277 @@ +Eclipse Public License - v 2.0 + + THE ACCOMPANYING PROGRAM IS PROVIDED UNDER THE TERMS OF THIS ECLIPSE + PUBLIC LICENSE ("AGREEMENT"). ANY USE, REPRODUCTION OR DISTRIBUTION + OF THE PROGRAM CONSTITUTES RECIPIENT'S ACCEPTANCE OF THIS AGREEMENT. + +1. DEFINITIONS + +"Contribution" means: + + a) in the case of the initial Contributor, the initial content + Distributed under this Agreement, and + + b) in the case of each subsequent Contributor: + i) changes to the Program, and + ii) additions to the Program; + where such changes and/or additions to the Program originate from + and are Distributed by that particular Contributor. A Contribution + "originates" from a Contributor if it was added to the Program by + such Contributor itself or anyone acting on such Contributor's behalf. + Contributions do not include changes or additions to the Program that + are not Modified Works. + +"Contributor" means any person or entity that Distributes the Program. + +"Licensed Patents" mean patent claims licensable by a Contributor which +are necessarily infringed by the use or sale of its Contribution alone +or when combined with the Program. + +"Program" means the Contributions Distributed in accordance with this +Agreement. + +"Recipient" means anyone who receives the Program under this Agreement +or any Secondary License (as applicable), including Contributors. + +"Derivative Works" shall mean any work, whether in Source Code or other +form, that is based on (or derived from) the Program and for which the +editorial revisions, annotations, elaborations, or other modifications +represent, as a whole, an original work of authorship. + +"Modified Works" shall mean any work in Source Code or other form that +results from an addition to, deletion from, or modification of the +contents of the Program, including, for purposes of clarity any new file +in Source Code form that contains any contents of the Program. Modified +Works shall not include works that contain only declarations, +interfaces, types, classes, structures, or files of the Program solely +in each case in order to link to, bind by name, or subclass the Program +or Modified Works thereof. + +"Distribute" means the acts of a) distributing or b) making available +in any manner that enables the transfer of a copy. + +"Source Code" means the form of a Program preferred for making +modifications, including but not limited to software source code, +documentation source, and configuration files. + +"Secondary License" means either the GNU General Public License, +Version 2.0, or any later versions of that license, including any +exceptions or additional permissions as identified by the initial +Contributor. + +2. GRANT OF RIGHTS + + a) Subject to the terms of this Agreement, each Contributor hereby + grants Recipient a non-exclusive, worldwide, royalty-free copyright + license to reproduce, prepare Derivative Works of, publicly display, + publicly perform, Distribute and sublicense the Contribution of such + Contributor, if any, and such Derivative Works. + + b) Subject to the terms of this Agreement, each Contributor hereby + grants Recipient a non-exclusive, worldwide, royalty-free patent + license under Licensed Patents to make, use, sell, offer to sell, + import and otherwise transfer the Contribution of such Contributor, + if any, in Source Code or other form. This patent license shall + apply to the combination of the Contribution and the Program if, at + the time the Contribution is added by the Contributor, such addition + of the Contribution causes such combination to be covered by the + Licensed Patents. The patent license shall not apply to any other + combinations which include the Contribution. No hardware per se is + licensed hereunder. + + c) Recipient understands that although each Contributor grants the + licenses to its Contributions set forth herein, no assurances are + provided by any Contributor that the Program does not infringe the + patent or other intellectual property rights of any other entity. + Each Contributor disclaims any liability to Recipient for claims + brought by any other entity based on infringement of intellectual + property rights or otherwise. As a condition to exercising the + rights and licenses granted hereunder, each Recipient hereby + assumes sole responsibility to secure any other intellectual + property rights needed, if any. For example, if a third party + patent license is required to allow Recipient to Distribute the + Program, it is Recipient's responsibility to acquire that license + before distributing the Program. + + d) Each Contributor represents that to its knowledge it has + sufficient copyright rights in its Contribution, if any, to grant + the copyright license set forth in this Agreement. + + e) Notwithstanding the terms of any Secondary License, no + Contributor makes additional grants to any Recipient (other than + those set forth in this Agreement) as a result of such Recipient's + receipt of the Program under the terms of a Secondary License + (if permitted under the terms of Section 3). + +3. REQUIREMENTS + +3.1 If a Contributor Distributes the Program in any form, then: + + a) the Program must also be made available as Source Code, in + accordance with section 3.2, and the Contributor must accompany + the Program with a statement that the Source Code for the Program + is available under this Agreement, and informs Recipients how to + obtain it in a reasonable manner on or through a medium customarily + used for software exchange; and + + b) the Contributor may Distribute the Program under a license + different than this Agreement, provided that such license: + i) effectively disclaims on behalf of all other Contributors all + warranties and conditions, express and implied, including + warranties or conditions of title and non-infringement, and + implied warranties or conditions of merchantability and fitness + for a particular purpose; + + ii) effectively excludes on behalf of all other Contributors all + liability for damages, including direct, indirect, special, + incidental and consequential damages, such as lost profits; + + iii) does not attempt to limit or alter the recipients' rights + in the Source Code under section 3.2; and + + iv) requires any subsequent distribution of the Program by any + party to be under a license that satisfies the requirements + of this section 3. + +3.2 When the Program is Distributed as Source Code: + + a) it must be made available under this Agreement, or if the + Program (i) is combined with other material in a separate file or + files made available under a Secondary License, and (ii) the initial + Contributor attached to the Source Code the notice described in + Exhibit A of this Agreement, then the Program may be made available + under the terms of such Secondary Licenses, and + + b) a copy of this Agreement must be included with each copy of + the Program. + +3.3 Contributors may not remove or alter any copyright, patent, +trademark, attribution notices, disclaimers of warranty, or limitations +of liability ("notices") contained within the Program from any copy of +the Program which they Distribute, provided that Contributors may add +their own appropriate notices. + +4. COMMERCIAL DISTRIBUTION + +Commercial distributors of software may accept certain responsibilities +with respect to end users, business partners and the like. While this +license is intended to facilitate the commercial use of the Program, +the Contributor who includes the Program in a commercial product +offering should do so in a manner which does not create potential +liability for other Contributors. Therefore, if a Contributor includes +the Program in a commercial product offering, such Contributor +("Commercial Contributor") hereby agrees to defend and indemnify every +other Contributor ("Indemnified Contributor") against any losses, +damages and costs (collectively "Losses") arising from claims, lawsuits +and other legal actions brought by a third party against the Indemnified +Contributor to the extent caused by the acts or omissions of such +Commercial Contributor in connection with its distribution of the Program +in a commercial product offering. The obligations in this section do not +apply to any claims or Losses relating to any actual or alleged +intellectual property infringement. In order to qualify, an Indemnified +Contributor must: a) promptly notify the Commercial Contributor in +writing of such claim, and b) allow the Commercial Contributor to control, +and cooperate with the Commercial Contributor in, the defense and any +related settlement negotiations. The Indemnified Contributor may +participate in any such claim at its own expense. + +For example, a Contributor might include the Program in a commercial +product offering, Product X. That Contributor is then a Commercial +Contributor. If that Commercial Contributor then makes performance +claims, or offers warranties related to Product X, those performance +claims and warranties are such Commercial Contributor's responsibility +alone. Under this section, the Commercial Contributor would have to +defend claims against the other Contributors related to those performance +claims and warranties, and if a court requires any other Contributor to +pay any damages as a result, the Commercial Contributor must pay +those damages. + +5. NO WARRANTY + +EXCEPT AS EXPRESSLY SET FORTH IN THIS AGREEMENT, AND TO THE EXTENT +PERMITTED BY APPLICABLE LAW, THE PROGRAM IS PROVIDED ON AN "AS IS" +BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, EITHER EXPRESS OR +IMPLIED INCLUDING, WITHOUT LIMITATION, ANY WARRANTIES OR CONDITIONS OF +TITLE, NON-INFRINGEMENT, MERCHANTABILITY OR FITNESS FOR A PARTICULAR +PURPOSE. Each Recipient is solely responsible for determining the +appropriateness of using and distributing the Program and assumes all +risks associated with its exercise of rights under this Agreement, +including but not limited to the risks and costs of program errors, +compliance with applicable laws, damage to or loss of data, programs +or equipment, and unavailability or interruption of operations. + +6. DISCLAIMER OF LIABILITY + +EXCEPT AS EXPRESSLY SET FORTH IN THIS AGREEMENT, AND TO THE EXTENT +PERMITTED BY APPLICABLE LAW, NEITHER RECIPIENT NOR ANY CONTRIBUTORS +SHALL HAVE ANY LIABILITY FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, +EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING WITHOUT LIMITATION LOST +PROFITS), HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN +CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) +ARISING IN ANY WAY OUT OF THE USE OR DISTRIBUTION OF THE PROGRAM OR THE +EXERCISE OF ANY RIGHTS GRANTED HEREUNDER, EVEN IF ADVISED OF THE +POSSIBILITY OF SUCH DAMAGES. + +7. GENERAL + +If any provision of this Agreement is invalid or unenforceable under +applicable law, it shall not affect the validity or enforceability of +the remainder of the terms of this Agreement, and without further +action by the parties hereto, such provision shall be reformed to the +minimum extent necessary to make such provision valid and enforceable. + +If Recipient institutes patent litigation against any entity +(including a cross-claim or counterclaim in a lawsuit) alleging that the +Program itself (excluding combinations of the Program with other software +or hardware) infringes such Recipient's patent(s), then such Recipient's +rights granted under Section 2(b) shall terminate as of the date such +litigation is filed. + +All Recipient's rights under this Agreement shall terminate if it +fails to comply with any of the material terms or conditions of this +Agreement and does not cure such failure in a reasonable period of +time after becoming aware of such noncompliance. If all Recipient's +rights under this Agreement terminate, Recipient agrees to cease use +and distribution of the Program as soon as reasonably practicable. +However, Recipient's obligations under this Agreement and any licenses +granted by Recipient relating to the Program shall continue and survive. + +Everyone is permitted to copy and distribute copies of this Agreement, +but in order to avoid inconsistency the Agreement is copyrighted and +may only be modified in the following manner. The Agreement Steward +reserves the right to publish new versions (including revisions) of +this Agreement from time to time. No one other than the Agreement +Steward has the right to modify this Agreement. The Eclipse Foundation +is the initial Agreement Steward. The Eclipse Foundation may assign the +responsibility to serve as the Agreement Steward to a suitable separate +entity. Each new version of the Agreement will be given a distinguishing +version number. The Program (including Contributions) may always be +Distributed subject to the version of the Agreement under which it was +received. In addition, after a new version of the Agreement is published, +Contributor may elect to Distribute the Program (including its +Contributions) under the new version. + +Except as expressly stated in Sections 2(a) and 2(b) above, Recipient +receives no rights or licenses to the intellectual property of any +Contributor under this Agreement, whether expressly, by implication, +estoppel or otherwise. All rights in the Program not expressly granted +under this Agreement are reserved. Nothing in this Agreement is intended +to be enforceable by any entity that is not a Contributor or Recipient. +No third-party beneficiary rights are created under this Agreement. + +Exhibit A - Form of Secondary Licenses Notice + +"This Source Code may also be made available under the following +Secondary Licenses when the conditions for such availability set forth +in the Eclipse Public License, v. 2.0 are satisfied: {name license(s), +version(s), and exceptions or additional permissions here}." + + Simply including a copy of this Agreement, including this Exhibit A + is not sufficient to license the Source Code under Secondary Licenses. + + If it is not possible or desirable to put the notice in a particular + file, then You may include the notice in a location (such as a LICENSE + file in a relevant directory) where a recipient would be likely to + look for such a notice. + + You may add additional accurate notices of copyright ownership. \ No newline at end of file diff --git a/mcp/dsx-exchange-mcp/vendor/github.com/eclipse/paho.mqtt.golang/filestore.go b/mcp/dsx-exchange-mcp/vendor/github.com/eclipse/paho.mqtt.golang/filestore.go new file mode 100644 index 0000000..20f246a --- /dev/null +++ b/mcp/dsx-exchange-mcp/vendor/github.com/eclipse/paho.mqtt.golang/filestore.go @@ -0,0 +1,266 @@ +/* + * Copyright (c) 2021 IBM Corp and others. + * + * All rights reserved. This program and the accompanying materials + * are made available under the terms of the Eclipse Public License v2.0 + * and Eclipse Distribution License v1.0 which accompany this distribution. + * + * The Eclipse Public License is available at + * https://www.eclipse.org/legal/epl-2.0/ + * and the Eclipse Distribution License is available at + * http://www.eclipse.org/org/documents/edl-v10.php. + * + * Contributors: + * Seth Hoenig + * Allan Stockdill-Mander + * Mike Robertson + */ + +package mqtt + +import ( + "io/fs" + "os" + "path" + "sort" + "sync" + + "github.com/eclipse/paho.mqtt.golang/packets" +) + +const ( + msgExt = ".msg" + tmpExt = ".tmp" + corruptExt = ".CORRUPT" +) + +// FileStore implements the store interface using the filesystem to provide +// true persistence, even across client failure. This is designed to use a +// single directory per running client. If you are running multiple clients +// on the same filesystem, you will need to be careful to specify unique +// store directories for each. +type FileStore struct { + sync.RWMutex + directory string + opened bool +} + +// NewFileStore will create a new FileStore which stores its messages in the +// directory provided. +func NewFileStore(directory string) *FileStore { + store := &FileStore{ + directory: directory, + opened: false, + } + return store +} + +// Open will allow the FileStore to be used. +func (store *FileStore) Open() { + store.Lock() + defer store.Unlock() + // if no store directory was specified in ClientOpts, by default use the + // current working directory + if store.directory == "" { + store.directory, _ = os.Getwd() + } + + // if store dir exists, great, otherwise, create it + if !exists(store.directory) { + perms := os.FileMode(0770) + merr := os.MkdirAll(store.directory, perms) + chkerr(merr) + } + store.opened = true + DEBUG.Println(STR, "store is opened at", store.directory) +} + +// Close will disallow the FileStore from being used. +func (store *FileStore) Close() { + store.Lock() + defer store.Unlock() + store.opened = false + DEBUG.Println(STR, "store is closed") +} + +// Put will put a message into the store, associated with the provided +// key value. +func (store *FileStore) Put(key string, m packets.ControlPacket) { + store.Lock() + defer store.Unlock() + if !store.opened { + ERROR.Println(STR, "Trying to use file store, but not open") + return + } + full := fullpath(store.directory, key) + write(store.directory, key, m) + if !exists(full) { + ERROR.Println(STR, "file not created:", full) + } +} + +// Get will retrieve a message from the store, the one associated with +// the provided key value. +func (store *FileStore) Get(key string) packets.ControlPacket { + store.RLock() + defer store.RUnlock() + if !store.opened { + ERROR.Println(STR, "trying to use file store, but not open") + return nil + } + filepath := fullpath(store.directory, key) + if !exists(filepath) { + return nil + } + mfile, oerr := os.Open(filepath) + chkerr(oerr) + msg, rerr := packets.ReadPacket(mfile) + chkerr(mfile.Close()) + + // Message was unreadable, return nil + if rerr != nil { + newpath := corruptpath(store.directory, key) + WARN.Println(STR, "corrupted file detected:", rerr.Error(), "archived at:", newpath) + if err := os.Rename(filepath, newpath); err != nil { + ERROR.Println(STR, err) + } + return nil + } + return msg +} + +// All will provide a list of all of the keys associated with messages +// currently residing in the FileStore. +func (store *FileStore) All() []string { + store.RLock() + defer store.RUnlock() + return store.all() +} + +// Del will remove the persisted message associated with the provided +// key from the FileStore. +func (store *FileStore) Del(key string) { + store.Lock() + defer store.Unlock() + store.del(key) +} + +// Reset will remove all persisted messages from the FileStore. +func (store *FileStore) Reset() { + store.Lock() + defer store.Unlock() + WARN.Println(STR, "FileStore Reset") + for _, key := range store.all() { + store.del(key) + } +} + +// lockless +func (store *FileStore) all() []string { + var err error + var keys []string + + if !store.opened { + ERROR.Println(STR, "trying to use file store, but not open") + return nil + } + + entries, err := os.ReadDir(store.directory) + chkerr(err) + files := make(fileInfos, 0, len(entries)) + for _, entry := range entries { + info, err := entry.Info() + chkerr(err) + files = append(files, info) + } + sort.Sort(files) + for _, f := range files { + DEBUG.Println(STR, "file in All():", f.Name()) + name := f.Name() + if len(name) < len(msgExt) || name[len(name)-len(msgExt):] != msgExt { + DEBUG.Println(STR, "skipping file, doesn't have right extension: ", name) + continue + } + key := name[0 : len(name)-4] // remove file extension + keys = append(keys, key) + } + return keys +} + +// lockless +func (store *FileStore) del(key string) { + if !store.opened { + ERROR.Println(STR, "trying to use file store, but not open") + return + } + DEBUG.Println(STR, "store del filepath:", store.directory) + DEBUG.Println(STR, "store delete key:", key) + filepath := fullpath(store.directory, key) + DEBUG.Println(STR, "path of deletion:", filepath) + if !exists(filepath) { + WARN.Println(STR, "store could not delete key:", key) + return + } + rerr := os.Remove(filepath) + chkerr(rerr) + DEBUG.Println(STR, "del msg:", key) + if exists(filepath) { + ERROR.Println(STR, "file not deleted:", filepath) + } +} + +func fullpath(store string, key string) string { + p := path.Join(store, key+msgExt) + return p +} + +func tmppath(store string, key string) string { + p := path.Join(store, key+tmpExt) + return p +} + +func corruptpath(store string, key string) string { + p := path.Join(store, key+corruptExt) + return p +} + +// create file called "X.[messageid].tmp" located in the store +// the contents of the file is the bytes of the message, then +// rename it to "X.[messageid].msg", overwriting any existing +// message with the same id +// X will be 'i' for inbound messages, and O for outbound messages +func write(store, key string, m packets.ControlPacket) { + temppath := tmppath(store, key) + f, err := os.Create(temppath) + chkerr(err) + werr := m.Write(f) + chkerr(werr) + cerr := f.Close() + chkerr(cerr) + rerr := os.Rename(temppath, fullpath(store, key)) + chkerr(rerr) +} + +func exists(file string) bool { + if _, err := os.Stat(file); err != nil { + if os.IsNotExist(err) { + return false + } + chkerr(err) + } + return true +} + +type fileInfos []fs.FileInfo + +func (f fileInfos) Len() int { + return len(f) +} + +func (f fileInfos) Swap(i, j int) { + f[i], f[j] = f[j], f[i] +} + +func (f fileInfos) Less(i, j int) bool { + return f[i].ModTime().Before(f[j].ModTime()) +} diff --git a/mcp/dsx-exchange-mcp/vendor/github.com/eclipse/paho.mqtt.golang/memstore.go b/mcp/dsx-exchange-mcp/vendor/github.com/eclipse/paho.mqtt.golang/memstore.go new file mode 100644 index 0000000..e9f8088 --- /dev/null +++ b/mcp/dsx-exchange-mcp/vendor/github.com/eclipse/paho.mqtt.golang/memstore.go @@ -0,0 +1,142 @@ +/* + * Copyright (c) 2021 IBM Corp and others. + * + * All rights reserved. This program and the accompanying materials + * are made available under the terms of the Eclipse Public License v2.0 + * and Eclipse Distribution License v1.0 which accompany this distribution. + * + * The Eclipse Public License is available at + * https://www.eclipse.org/legal/epl-2.0/ + * and the Eclipse Distribution License is available at + * http://www.eclipse.org/org/documents/edl-v10.php. + * + * Contributors: + * Seth Hoenig + * Allan Stockdill-Mander + * Mike Robertson + */ + +package mqtt + +import ( + "sync" + + "github.com/eclipse/paho.mqtt.golang/packets" +) + +// MemoryStore implements the store interface to provide a "persistence" +// mechanism wholly stored in memory. This is only useful for +// as long as the client instance exists. +type MemoryStore struct { + sync.RWMutex + messages map[string]packets.ControlPacket + opened bool +} + +// NewMemoryStore returns a pointer to a new instance of +// MemoryStore, the instance is not initialized and ready to +// use until Open() has been called on it. +func NewMemoryStore() *MemoryStore { + store := &MemoryStore{ + messages: make(map[string]packets.ControlPacket), + opened: false, + } + return store +} + +// Open initializes a MemoryStore instance. +func (store *MemoryStore) Open() { + store.Lock() + defer store.Unlock() + store.opened = true + DEBUG.Println(STR, "memorystore initialized") +} + +// Put takes a key and a pointer to a Message and stores the +// message. +func (store *MemoryStore) Put(key string, message packets.ControlPacket) { + store.Lock() + defer store.Unlock() + if !store.opened { + ERROR.Println(STR, "Trying to use memory store, but not open") + return + } + store.messages[key] = message +} + +// Get takes a key and looks in the store for a matching Message +// returning either the Message pointer or nil. +func (store *MemoryStore) Get(key string) packets.ControlPacket { + store.RLock() + defer store.RUnlock() + if !store.opened { + ERROR.Println(STR, "Trying to use memory store, but not open") + return nil + } + mid := mIDFromKey(key) + m := store.messages[key] + if m == nil { + CRITICAL.Println(STR, "memorystore get: message", mid, "not found") + } else { + DEBUG.Println(STR, "memorystore get: message", mid, "found") + } + return m +} + +// All returns a slice of strings containing all the keys currently +// in the MemoryStore. +func (store *MemoryStore) All() []string { + store.RLock() + defer store.RUnlock() + if !store.opened { + ERROR.Println(STR, "Trying to use memory store, but not open") + return nil + } + var keys []string + for k := range store.messages { + keys = append(keys, k) + } + return keys +} + +// Del takes a key, searches the MemoryStore and if the key is found +// deletes the Message pointer associated with it. +func (store *MemoryStore) Del(key string) { + store.Lock() + defer store.Unlock() + if !store.opened { + ERROR.Println(STR, "Trying to use memory store, but not open") + return + } + mid := mIDFromKey(key) + m := store.messages[key] + if m == nil { + WARN.Println(STR, "memorystore del: message", mid, "not found") + } else { + delete(store.messages, key) + DEBUG.Println(STR, "memorystore del: message", mid, "was deleted") + } +} + +// Close will disallow modifications to the state of the store. +func (store *MemoryStore) Close() { + store.Lock() + defer store.Unlock() + if !store.opened { + ERROR.Println(STR, "Trying to close memory store, but not open") + return + } + store.opened = false + DEBUG.Println(STR, "memorystore closed") +} + +// Reset eliminates all persisted message data in the store. +func (store *MemoryStore) Reset() { + store.Lock() + defer store.Unlock() + if !store.opened { + ERROR.Println(STR, "Trying to reset memory store, but not open") + } + store.messages = make(map[string]packets.ControlPacket) + WARN.Println(STR, "memorystore wiped") +} diff --git a/mcp/dsx-exchange-mcp/vendor/github.com/eclipse/paho.mqtt.golang/memstore_ordered.go b/mcp/dsx-exchange-mcp/vendor/github.com/eclipse/paho.mqtt.golang/memstore_ordered.go new file mode 100644 index 0000000..498b82b --- /dev/null +++ b/mcp/dsx-exchange-mcp/vendor/github.com/eclipse/paho.mqtt.golang/memstore_ordered.go @@ -0,0 +1,166 @@ +/* + * Copyright (c) 2021 IBM Corp and others. + * + * All rights reserved. This program and the accompanying materials + * are made available under the terms of the Eclipse Public License v2.0 + * and Eclipse Distribution License v1.0 which accompany this distribution. + * + * The Eclipse Public License is available at + * https://www.eclipse.org/legal/epl-2.0/ + * and the Eclipse Distribution License is available at + * http://www.eclipse.org/org/documents/edl-v10.php. + * + * Contributors: + * Seth Hoenig + * Allan Stockdill-Mander + * Mike Robertson + * Matt Brittan + */ + +package mqtt + +import ( + "sort" + "sync" + "time" + + "github.com/eclipse/paho.mqtt.golang/packets" +) + +// OrderedMemoryStore uses a map internally so the order in which All() returns packets is +// undefined. OrderedMemoryStore resolves this by storing the time the message is added +// and sorting based upon this. + +// storedMessage encapsulates a message and the time it was initially stored +type storedMessage struct { + ts time.Time + msg packets.ControlPacket +} + +// OrderedMemoryStore implements the store interface to provide a "persistence" +// mechanism wholly stored in memory. This is only useful for +// as long as the client instance exists. +type OrderedMemoryStore struct { + sync.RWMutex + messages map[string]storedMessage + opened bool +} + +// NewOrderedMemoryStore returns a pointer to a new instance of +// OrderedMemoryStore, the instance is not initialized and ready to +// use until Open() has been called on it. +func NewOrderedMemoryStore() *OrderedMemoryStore { + store := &OrderedMemoryStore{ + messages: make(map[string]storedMessage), + opened: false, + } + return store +} + +// Open initializes a OrderedMemoryStore instance. +func (store *OrderedMemoryStore) Open() { + store.Lock() + defer store.Unlock() + store.opened = true + DEBUG.Println(STR, "OrderedMemoryStore initialized") +} + +// Put takes a key and a pointer to a Message and stores the +// message. +func (store *OrderedMemoryStore) Put(key string, message packets.ControlPacket) { + store.Lock() + defer store.Unlock() + if !store.opened { + ERROR.Println(STR, "Trying to use memory store, but not open") + return + } + store.messages[key] = storedMessage{ts: time.Now(), msg: message} +} + +// Get takes a key and looks in the store for a matching Message +// returning either the Message pointer or nil. +func (store *OrderedMemoryStore) Get(key string) packets.ControlPacket { + store.RLock() + defer store.RUnlock() + if !store.opened { + ERROR.Println(STR, "Trying to use memory store, but not open") + return nil + } + mid := mIDFromKey(key) + m, ok := store.messages[key] + if !ok || m.msg == nil { + CRITICAL.Println(STR, "OrderedMemoryStore get: message", mid, "not found") + } else { + DEBUG.Println(STR, "OrderedMemoryStore get: message", mid, "found") + } + return m.msg +} + +// All returns a slice of strings containing all the keys currently +// in the OrderedMemoryStore. +func (store *OrderedMemoryStore) All() []string { + store.RLock() + defer store.RUnlock() + if !store.opened { + ERROR.Println(STR, "Trying to use memory store, but not open") + return nil + } + type tsAndKey struct { + ts time.Time + key string + } + + tsKeys := make([]tsAndKey, 0, len(store.messages)) + for k, v := range store.messages { + tsKeys = append(tsKeys, tsAndKey{ts: v.ts, key: k}) + } + sort.Slice(tsKeys, func(a int, b int) bool { return tsKeys[a].ts.Before(tsKeys[b].ts) }) + + keys := make([]string, len(tsKeys)) + for i := range tsKeys { + keys[i] = tsKeys[i].key + } + return keys +} + +// Del takes a key, searches the OrderedMemoryStore and if the key is found +// deletes the Message pointer associated with it. +func (store *OrderedMemoryStore) Del(key string) { + store.Lock() + defer store.Unlock() + if !store.opened { + ERROR.Println(STR, "Trying to use memory store, but not open") + return + } + mid := mIDFromKey(key) + _, ok := store.messages[key] + if !ok { + WARN.Println(STR, "OrderedMemoryStore del: message", mid, "not found") + } else { + delete(store.messages, key) + DEBUG.Println(STR, "OrderedMemoryStore del: message", mid, "was deleted") + } +} + +// Close will disallow modifications to the state of the store. +func (store *OrderedMemoryStore) Close() { + store.Lock() + defer store.Unlock() + if !store.opened { + ERROR.Println(STR, "Trying to close memory store, but not open") + return + } + store.opened = false + DEBUG.Println(STR, "OrderedMemoryStore closed") +} + +// Reset eliminates all persisted message data in the store. +func (store *OrderedMemoryStore) Reset() { + store.Lock() + defer store.Unlock() + if !store.opened { + ERROR.Println(STR, "Trying to reset memory store, but not open") + } + store.messages = make(map[string]storedMessage) + WARN.Println(STR, "OrderedMemoryStore wiped") +} diff --git a/mcp/dsx-exchange-mcp/vendor/github.com/eclipse/paho.mqtt.golang/message.go b/mcp/dsx-exchange-mcp/vendor/github.com/eclipse/paho.mqtt.golang/message.go new file mode 100644 index 0000000..35b463f --- /dev/null +++ b/mcp/dsx-exchange-mcp/vendor/github.com/eclipse/paho.mqtt.golang/message.go @@ -0,0 +1,131 @@ +/* + * Copyright (c) 2021 IBM Corp and others. + * + * All rights reserved. This program and the accompanying materials + * are made available under the terms of the Eclipse Public License v2.0 + * and Eclipse Distribution License v1.0 which accompany this distribution. + * + * The Eclipse Public License is available at + * https://www.eclipse.org/legal/epl-2.0/ + * and the Eclipse Distribution License is available at + * http://www.eclipse.org/org/documents/edl-v10.php. + * + * Contributors: + * Seth Hoenig + * Allan Stockdill-Mander + * Mike Robertson + */ + +package mqtt + +import ( + "net/url" + "sync" + + "github.com/eclipse/paho.mqtt.golang/packets" +) + +// Message defines the externals that a message implementation must support +// these are received messages that are passed to the callbacks, not internal +// messages +type Message interface { + Duplicate() bool + Qos() byte + Retained() bool + Topic() string + MessageID() uint16 + Payload() []byte + Ack() +} + +type message struct { + duplicate bool + qos byte + retained bool + topic string + messageID uint16 + payload []byte + once sync.Once + ack func() +} + +func (m *message) Duplicate() bool { + return m.duplicate +} + +func (m *message) Qos() byte { + return m.qos +} + +func (m *message) Retained() bool { + return m.retained +} + +func (m *message) Topic() string { + return m.topic +} + +func (m *message) MessageID() uint16 { + return m.messageID +} + +func (m *message) Payload() []byte { + return m.payload +} + +func (m *message) Ack() { + m.once.Do(m.ack) +} + +func messageFromPublish(p *packets.PublishPacket, ack func()) Message { + return &message{ + duplicate: p.Dup, + qos: p.Qos, + retained: p.Retain, + topic: p.TopicName, + messageID: p.MessageID, + payload: p.Payload, + ack: ack, + } +} + +func newConnectMsgFromOptions(options *ClientOptions, broker *url.URL) *packets.ConnectPacket { + m := packets.NewControlPacket(packets.Connect).(*packets.ConnectPacket) + + m.CleanSession = options.CleanSession + m.WillFlag = options.WillEnabled + m.WillRetain = options.WillRetained + m.ClientIdentifier = options.ClientID + + if options.WillEnabled { + m.WillQos = options.WillQos + m.WillTopic = options.WillTopic + m.WillMessage = options.WillPayload + } + + username := options.Username + password := options.Password + if broker.User != nil { + username = broker.User.Username() + if pwd, ok := broker.User.Password(); ok { + password = pwd + } + } + if options.CredentialsProvider != nil { + username, password = options.CredentialsProvider() + } + + if username != "" { + m.UsernameFlag = true + m.Username = username + // mustn't have password without user as well + if password != "" { + m.PasswordFlag = true + m.Password = []byte(password) + } + } + + m.Keepalive = uint16(options.KeepAlive) + + return m +} diff --git a/mcp/dsx-exchange-mcp/vendor/github.com/eclipse/paho.mqtt.golang/messageids.go b/mcp/dsx-exchange-mcp/vendor/github.com/eclipse/paho.mqtt.golang/messageids.go new file mode 100644 index 0000000..04c94bd --- /dev/null +++ b/mcp/dsx-exchange-mcp/vendor/github.com/eclipse/paho.mqtt.golang/messageids.go @@ -0,0 +1,200 @@ +/* + * Copyright (c) 2013 IBM Corp and others. + * + * All rights reserved. This program and the accompanying materials + * are made available under the terms of the Eclipse Public License v2.0 + * and Eclipse Distribution License v1.0 which accompany this distribution. + * + * The Eclipse Public License is available at + * https://www.eclipse.org/legal/epl-2.0/ + * and the Eclipse Distribution License is available at + * http://www.eclipse.org/org/documents/edl-v10.php. + * + * Contributors: + * Seth Hoenig + * Allan Stockdill-Mander + * Mike Robertson + * Matt Brittan + */ + +package mqtt + +import ( + "fmt" + "sync" + "time" +) + +// MId is 16 bit message id as specified by the MQTT spec. +// In general, these values should not be depended upon by +// the client application. +type MId uint16 + +type messageIds struct { + mu sync.RWMutex // Named to prevent Mu from being accessible directly via client + index map[uint16]tokenCompletor + + lastIssuedID uint16 // The most recently issued ID. Used so we cycle through ids rather than immediately reusing them (can make debugging easier) +} + +const ( + midMin uint16 = 1 + midMax uint16 = 65535 +) + +// cleanup clears the message ID map; completes all token types and sets error on PUB, SUB and UNSUB tokens. +func (mids *messageIds) cleanUp() { + mids.mu.Lock() + for _, token := range mids.index { + switch token.(type) { + case *PublishToken: + token.setError(fmt.Errorf("connection lost before Publish completed")) + case *SubscribeToken: + token.setError(fmt.Errorf("connection lost before Subscribe completed")) + case *UnsubscribeToken: + token.setError(fmt.Errorf("connection lost before Unsubscribe completed")) + case nil: // should not be any nil entries + continue + } + token.flowComplete() + } + mids.index = make(map[uint16]tokenCompletor) + mids.mu.Unlock() + DEBUG.Println(MID, "cleaned up") +} + +// cleanUpSubscribe removes all SUBSCRIBE and UNSUBSCRIBE tokens (setting error) +// This may be called when the connection is lost, and we will not be resending SUB/UNSUB packets +func (mids *messageIds) cleanUpSubscribe() { + mids.mu.Lock() + for mid, token := range mids.index { + switch token.(type) { + case *SubscribeToken: + token.setError(fmt.Errorf("connection lost before Subscribe completed")) + delete(mids.index, mid) + case *UnsubscribeToken: + token.setError(fmt.Errorf("connection lost before Unsubscribe completed")) + delete(mids.index, mid) + } + } + mids.mu.Unlock() + DEBUG.Println(MID, "cleaned up subs") +} + +func (mids *messageIds) freeID(id uint16) { + mids.mu.Lock() + delete(mids.index, id) + mids.mu.Unlock() +} + +func (mids *messageIds) claimID(token tokenCompletor, id uint16) { + mids.mu.Lock() + defer mids.mu.Unlock() + if _, ok := mids.index[id]; !ok { + mids.index[id] = token + } else { + old := mids.index[id] + old.flowComplete() + mids.index[id] = token + } + if id > mids.lastIssuedID { + mids.lastIssuedID = id + } +} + +// getID will return an available id or 0 if none available +// The id will generally be the previous id + 1 (because this makes tracing messages a bit simpler) +func (mids *messageIds) getID(t tokenCompletor) uint16 { + mids.mu.Lock() + defer mids.mu.Unlock() + i := mids.lastIssuedID // note: the only situation where lastIssuedID is 0 the map will be empty + looped := false // uint16 will loop from 65535->0 + for { + i++ + if i == 0 { // skip 0 because its not a valid id (Control Packets MUST contain a non-zero 16-bit Packet Identifier [MQTT-2.3.1-1]) + i++ + looped = true + } + if _, ok := mids.index[i]; !ok { + mids.index[i] = t + mids.lastIssuedID = i + return i + } + if (looped && i == mids.lastIssuedID) || (mids.lastIssuedID == 0 && i == midMax) { // lastIssuedID will be 0 at startup + return 0 // no free ids + } + } +} + +func (mids *messageIds) getToken(id uint16) tokenCompletor { + mids.mu.RLock() + defer mids.mu.RUnlock() + if token, ok := mids.index[id]; ok { + return token + } + return &DummyToken{id: id} +} + +type DummyToken struct { + id uint16 +} + +// Wait implements the Token Wait method. +func (d *DummyToken) Wait() bool { + return true +} + +// WaitTimeout implements the Token WaitTimeout method. +func (d *DummyToken) WaitTimeout(t time.Duration) bool { + return true +} + +// Done implements the Token Done method. +func (d *DummyToken) Done() <-chan struct{} { + ch := make(chan struct{}) + close(ch) + return ch +} + +func (d *DummyToken) flowComplete() { + ERROR.Printf("A lookup for token %d returned nil\n", d.id) +} + +func (d *DummyToken) Error() error { + return nil +} + +func (d *DummyToken) setError(e error) {} + +// PlaceHolderToken does nothing and was implemented to allow a messageid to be reserved +// it differs from DummyToken in that calling flowComplete does not generate an error (it +// is expected that flowComplete will be called when the token is overwritten with a real token) +type PlaceHolderToken struct { + id uint16 +} + +// Wait implements the Token Wait method. +func (p *PlaceHolderToken) Wait() bool { + return true +} + +// WaitTimeout implements the Token WaitTimeout method. +func (p *PlaceHolderToken) WaitTimeout(t time.Duration) bool { + return true +} + +// Done implements the Token Done method. +func (p *PlaceHolderToken) Done() <-chan struct{} { + ch := make(chan struct{}) + close(ch) + return ch +} + +func (p *PlaceHolderToken) flowComplete() { +} + +func (p *PlaceHolderToken) Error() error { + return nil +} + +func (p *PlaceHolderToken) setError(e error) {} diff --git a/mcp/dsx-exchange-mcp/vendor/github.com/eclipse/paho.mqtt.golang/net.go b/mcp/dsx-exchange-mcp/vendor/github.com/eclipse/paho.mqtt.golang/net.go new file mode 100644 index 0000000..cb3d374 --- /dev/null +++ b/mcp/dsx-exchange-mcp/vendor/github.com/eclipse/paho.mqtt.golang/net.go @@ -0,0 +1,469 @@ +/* + * Copyright (c) 2021 IBM Corp and others. + * + * All rights reserved. This program and the accompanying materials + * are made available under the terms of the Eclipse Public License v2.0 + * and Eclipse Distribution License v1.0 which accompany this distribution. + * + * The Eclipse Public License is available at + * https://www.eclipse.org/legal/epl-2.0/ + * and the Eclipse Distribution License is available at + * http://www.eclipse.org/org/documents/edl-v10.php. + * + * Contributors: + * Seth Hoenig + * Allan Stockdill-Mander + * Mike Robertson + * Matt Brittan + */ + +package mqtt + +import ( + "errors" + "io" + "net" + "reflect" + "strings" + "sync" + "time" + + "github.com/eclipse/paho.mqtt.golang/packets" +) + +const closedNetConnErrorText = "use of closed network connection" // error string for closed conn (https://golang.org/src/net/error_test.go) + +// ConnectMQTT takes a connected net.Conn and performs the initial MQTT handshake. Parameters are: +// conn - Connected net.Conn +// cm - Connect Packet with everything other than the protocol name/version populated (historical reasons) +// protocolVersion - The protocol version to attempt to connect with +// +// Note that, for backward compatibility, ConnectMQTT() suppresses the actual connection error (compare to connectMQTT()). +func ConnectMQTT(conn net.Conn, cm *packets.ConnectPacket, protocolVersion uint) (byte, bool) { + rc, sessionPresent, _ := connectMQTT(conn, cm, protocolVersion) + return rc, sessionPresent +} + +func connectMQTT(conn io.ReadWriter, cm *packets.ConnectPacket, protocolVersion uint) (byte, bool, error) { + switch protocolVersion { + case 3: + DEBUG.Println(CLI, "Using MQTT 3.1 protocol") + cm.ProtocolName = "MQIsdp" + cm.ProtocolVersion = 3 + case 0x83: + DEBUG.Println(CLI, "Using MQTT 3.1b protocol") + cm.ProtocolName = "MQIsdp" + cm.ProtocolVersion = 0x83 + case 0x84: + DEBUG.Println(CLI, "Using MQTT 3.1.1b protocol") + cm.ProtocolName = "MQTT" + cm.ProtocolVersion = 0x84 + default: + DEBUG.Println(CLI, "Using MQTT 3.1.1 protocol") + cm.ProtocolName = "MQTT" + cm.ProtocolVersion = 4 + } + + if err := cm.Write(conn); err != nil { + ERROR.Println(CLI, err) + return packets.ErrNetworkError, false, err + } + + rc, sessionPresent, err := verifyCONNACK(conn) + return rc, sessionPresent, err +} + +// This function is only used for receiving a connack +// when the connection is first started. +// This prevents receiving incoming data while resume +// is in progress if clean session is false. +func verifyCONNACK(conn io.Reader) (byte, bool, error) { + DEBUG.Println(NET, "connect started") + + ca, err := packets.ReadPacket(conn) + if err != nil { + ERROR.Println(NET, "connect got error", err) + return packets.ErrNetworkError, false, err + } + + if ca == nil { + ERROR.Println(NET, "received nil packet") + return packets.ErrNetworkError, false, errors.New("nil CONNACK packet") + } + + msg, ok := ca.(*packets.ConnackPacket) + if !ok { + ERROR.Println(NET, "received msg that was not CONNACK") + return packets.ErrNetworkError, false, errors.New("non-CONNACK first packet received") + } + + DEBUG.Println(NET, "received connack") + return msg.ReturnCode, msg.SessionPresent, nil +} + +// inbound encapsulates the output from startIncoming. +// err - If != nil then an error has occurred +// cp - A control packet received over the network link +type inbound struct { + err error + cp packets.ControlPacket +} + +// startIncoming initiates a goroutine that reads incoming messages off the wire and sends them to the channel (returned). +// If there are any issues with the network connection then the returned channel will be closed and the goroutine will exit +// (so closing the connection will terminate the goroutine) +func startIncoming(conn io.Reader) <-chan inbound { + var err error + var cp packets.ControlPacket + ibound := make(chan inbound) + + DEBUG.Println(NET, "incoming started") + + go func() { + for { + if cp, err = packets.ReadPacket(conn); err != nil { + // We do not want to log the error if it is due to the network connection having been closed + // elsewhere (i.e. after sending DisconnectPacket). Detecting this situation is the subject of + // https://github.com/golang/go/issues/4373 + if !strings.Contains(err.Error(), closedNetConnErrorText) { + ibound <- inbound{err: err} + } + close(ibound) + DEBUG.Println(NET, "incoming complete") + return + } + DEBUG.Println(NET, "startIncoming Received Message") + ibound <- inbound{cp: cp} + } + }() + + return ibound +} + +// incomingComms encapsulates the possible output of the incomingComms routine. If err != nil then an error has occurred and +// the routine will have terminated; otherwise one of the other members should be non-nil +type incomingComms struct { + err error // If non-nil then there has been an error (ignore everything else) + outbound *PacketAndToken // Packet (with token) than needs to be sent out (e.g. an acknowledgement) + incomingPub *packets.PublishPacket // A new publish has been received; this will need to be passed on to our user +} + +// startIncomingComms initiates incoming communications; this includes starting a goroutine to process incoming +// messages. +// Accepts a channel of inbound messages from the store (persisted messages); note this must be closed as soon as +// everything in the store has been sent. +// Returns a channel that will be passed any received packets; this will be closed on a network error (and inboundFromStore closed) +func startIncomingComms(conn io.Reader, + c commsFns, + inboundFromStore <-chan packets.ControlPacket, +) <-chan incomingComms { + ibound := startIncoming(conn) // Start goroutine that reads from network connection + output := make(chan incomingComms) + + DEBUG.Println(NET, "startIncomingComms started") + go func() { + for { + if inboundFromStore == nil && ibound == nil { + close(output) + DEBUG.Println(NET, "startIncomingComms goroutine complete") + return // As soon as ibound is closed we can exit (should have already processed an error) + } + DEBUG.Println(NET, "logic waiting for msg on ibound") + + var msg packets.ControlPacket + var ok bool + select { + case msg, ok = <-inboundFromStore: + if !ok { + DEBUG.Println(NET, "startIncomingComms: inboundFromStore complete") + inboundFromStore = nil // should happen quickly as this is only for persisted messages + continue + } + DEBUG.Println(NET, "startIncomingComms: got msg from store") + case ibMsg, ok := <-ibound: + if !ok { + DEBUG.Println(NET, "startIncomingComms: ibound complete") + ibound = nil + continue + } + DEBUG.Println(NET, "startIncomingComms: got msg on ibound") + // If the inbound comms routine encounters any issues it will send us an error. + if ibMsg.err != nil { + output <- incomingComms{err: ibMsg.err} + continue // Usually the channel will be closed immediately after sending an error but safer that we do not assume this + } + msg = ibMsg.cp + + c.persistInbound(msg) + c.UpdateLastReceived() // Notify keepalive logic that we recently received a packet + } + + switch m := msg.(type) { + case *packets.PingrespPacket: + DEBUG.Println(NET, "startIncomingComms: received pingresp") + c.pingRespReceived() + case *packets.SubackPacket: + DEBUG.Println(NET, "startIncomingComms: received suback, id:", m.MessageID) + token := c.getToken(m.MessageID) + + if t, ok := token.(*SubscribeToken); ok { + DEBUG.Println(NET, "startIncomingComms: granted qoss", m.ReturnCodes) + for i, qos := range m.ReturnCodes { + t.subResult[t.subs[i]] = qos + } + } + + token.flowComplete() + c.freeID(m.MessageID) + case *packets.UnsubackPacket: + DEBUG.Println(NET, "startIncomingComms: received unsuback, id:", m.MessageID) + c.getToken(m.MessageID).flowComplete() + c.freeID(m.MessageID) + case *packets.PublishPacket: + DEBUG.Println(NET, "startIncomingComms: received publish, msgId:", m.MessageID) + output <- incomingComms{incomingPub: m} + case *packets.PubackPacket: + DEBUG.Println(NET, "startIncomingComms: received puback, id:", m.MessageID) + c.getToken(m.MessageID).flowComplete() + c.freeID(m.MessageID) + case *packets.PubrecPacket: + DEBUG.Println(NET, "startIncomingComms: received pubrec, id:", m.MessageID) + prel := packets.NewControlPacket(packets.Pubrel).(*packets.PubrelPacket) + prel.MessageID = m.MessageID + output <- incomingComms{outbound: &PacketAndToken{p: prel, t: nil}} + case *packets.PubrelPacket: + DEBUG.Println(NET, "startIncomingComms: received pubrel, id:", m.MessageID) + pc := packets.NewControlPacket(packets.Pubcomp).(*packets.PubcompPacket) + pc.MessageID = m.MessageID + c.persistOutbound(pc) + output <- incomingComms{outbound: &PacketAndToken{p: pc, t: nil}} + case *packets.PubcompPacket: + DEBUG.Println(NET, "startIncomingComms: received pubcomp, id:", m.MessageID) + c.getToken(m.MessageID).flowComplete() + c.freeID(m.MessageID) + } + } + }() + return output +} + +// startOutgoingComms initiates a go routine to transmit outgoing packets. +// Pass in an open network connection and channels for outbound messages (including those triggered +// directly from incoming comms). +// Returns a channel that will receive details of any errors (closed when the goroutine exits) +// This function wil only terminate when all input channels are closed +func startOutgoingComms(conn net.Conn, + c commsFns, + oboundp <-chan *PacketAndToken, + obound <-chan *PacketAndToken, + oboundFromIncoming <-chan *PacketAndToken, +) <-chan error { + errChan := make(chan error) + DEBUG.Println(NET, "outgoing started") + + go func() { + for { + DEBUG.Println(NET, "outgoing waiting for an outbound message") + + // This goroutine will only exits when all of the input channels we receive on have been closed. This approach is taken to avoid any + // deadlocks (if the connection goes down there are limited options as to what we can do with anything waiting on us and + // throwing away the packets seems the best option) + if oboundp == nil && obound == nil && oboundFromIncoming == nil { + DEBUG.Println(NET, "outgoing comms stopping") + close(errChan) + return + } + + select { + case pub, ok := <-obound: + if !ok { + obound = nil + continue + } + msg := pub.p.(*packets.PublishPacket) + DEBUG.Println(NET, "obound msg to write", msg.MessageID) + + writeTimeout := c.getWriteTimeOut() + if writeTimeout > 0 { + if err := conn.SetWriteDeadline(time.Now().Add(writeTimeout)); err != nil { + ERROR.Println(NET, "SetWriteDeadline ", err) + } + } + + if err := msg.Write(conn); err != nil { + ERROR.Println(NET, "outgoing obound reporting error ", err) + pub.t.setError(err) + // report error if it's not due to the connection being closed elsewhere + if !strings.Contains(err.Error(), closedNetConnErrorText) { + errChan <- err + } + continue + } + + if writeTimeout > 0 { + // If we successfully wrote, we don't want the timeout to happen during an idle period + // so we reset it to infinite. + if err := conn.SetWriteDeadline(time.Time{}); err != nil { + ERROR.Println(NET, "SetWriteDeadline to 0 ", err) + } + } + + if msg.Qos == 0 { + pub.t.flowComplete() + } + DEBUG.Println(NET, "obound wrote msg, id:", msg.MessageID) + case msg, ok := <-oboundp: + if !ok { + oboundp = nil + continue + } + DEBUG.Println(NET, "obound priority msg to write, type", reflect.TypeOf(msg.p)) + if err := msg.p.Write(conn); err != nil { + ERROR.Println(NET, "outgoing oboundp reporting error ", err) + if msg.t != nil { + msg.t.setError(err) + } + errChan <- err + continue + } + + if _, ok := msg.p.(*packets.DisconnectPacket); ok { + msg.t.(*DisconnectToken).flowComplete() + DEBUG.Println(NET, "outbound wrote disconnect, closing connection") + // As per the MQTT spec "After sending a DISCONNECT Packet the Client MUST close the Network Connection" + // Closing the connection will cause the goroutines to end in sequence (starting with incoming comms) + _ = conn.Close() + } + case msg, ok := <-oboundFromIncoming: // message triggered by an inbound message (PubrecPacket or PubrelPacket) + if !ok { + oboundFromIncoming = nil + continue + } + DEBUG.Println(NET, "obound from incoming msg to write, type", reflect.TypeOf(msg.p), " ID ", msg.p.Details().MessageID) + if err := msg.p.Write(conn); err != nil { + ERROR.Println(NET, "outgoing oboundFromIncoming reporting error", err) + if msg.t != nil { + msg.t.setError(err) + } + errChan <- err + continue + } + } + c.UpdateLastSent() // Record that a packet has been received (for keepalive routine) + } + }() + return errChan +} + +// commsFns provide access to the client state (messageids, requesting disconnection and updating timing) +type commsFns interface { + getToken(id uint16) tokenCompletor // Retrieve the token for the specified messageid (if none then a dummy token must be returned) + freeID(id uint16) // Release the specified messageid (clearing out of any persistent store) + UpdateLastReceived() // Must be called whenever a packet is received + UpdateLastSent() // Must be called whenever a packet is successfully sent + getWriteTimeOut() time.Duration // Return the writetimeout (or 0 if none) + persistOutbound(m packets.ControlPacket) // add the packet to the outbound store + persistInbound(m packets.ControlPacket) // add the packet to the inbound store + pingRespReceived() // Called when a ping response is received +} + +// startComms initiates goroutines that handles communications over the network connection +// Messages will be stored (via commsFns) and deleted from the store as necessary +// It returns two channels: +// +// packets.PublishPacket - Will receive publish packets received over the network. +// Closed when incoming comms routines exit (on shutdown or if network link closed) +// error - Any errors will be sent on this channel. The channel is closed when all comms routines have shut down +// +// Note: The comms routines monitoring oboundp and obound will not shutdown until those channels are both closed. Any messages received between the +// connection being closed and those channels being closed will generate errors (and nothing will be sent). That way the chance of a deadlock is +// minimised. +func startComms(conn net.Conn, // Network connection (must be active) + c commsFns, // getters and setters to enable us to cleanly interact with client + inboundFromStore <-chan packets.ControlPacket, // Inbound packets from the persistence store (should be closed relatively soon after startup) + oboundp <-chan *PacketAndToken, + obound <-chan *PacketAndToken) ( + <-chan *packets.PublishPacket, // Publishpackages received over the network + <-chan error, // Any errors (should generally trigger a disconnect) +) { + // Start inbound comms handler; this needs to be able to transmit messages so we start a go routine to add these to the priority outbound channel + ibound := startIncomingComms(conn, c, inboundFromStore) + outboundFromIncoming := make(chan *PacketAndToken) // Will accept outgoing messages triggered by startIncomingComms (e.g. acknowledgements) + + // Start the outgoing handler. It is important to note that output from startIncomingComms is fed into startOutgoingComms (for ACK's) + oboundErr := startOutgoingComms(conn, c, oboundp, obound, outboundFromIncoming) + DEBUG.Println(NET, "startComms started") + + // Run up go routines to handle the output from the above comms functions - these are handled in separate + // go routines because they can interact (e.g. ibound triggers an ACK to obound which triggers an error) + var wg sync.WaitGroup + wg.Add(2) + + outPublish := make(chan *packets.PublishPacket) + outError := make(chan error) + + // Any messages received get passed to the appropriate channel + go func() { + for ic := range ibound { + if ic.err != nil { + outError <- ic.err + continue + } + if ic.outbound != nil { + outboundFromIncoming <- ic.outbound + continue + } + if ic.incomingPub != nil { + outPublish <- ic.incomingPub + continue + } + ERROR.Println(STR, "startComms received empty incomingComms msg") + } + // Close channels that will not be written to again (allowing other routines to exit) + close(outboundFromIncoming) + close(outPublish) + wg.Done() + }() + + // Any errors will be passed out to our caller + go func() { + for err := range oboundErr { + outError <- err + } + wg.Done() + }() + + // outError is used by both routines so can only be closed when they are both complete + go func() { + wg.Wait() + close(outError) + DEBUG.Println(NET, "startComms closing outError") + }() + + return outPublish, outError +} + +// ackFunc acknowledges a packet +// WARNING sendAck may be called at any time (even after the connection is dead). At the time of writing ACK sent after +// connection loss will be dropped (this is not ideal) +func ackFunc(sendAck func(*PacketAndToken), persist Store, packet *packets.PublishPacket) func() { + return func() { + switch packet.Qos { + case 2: + pr := packets.NewControlPacket(packets.Pubrec).(*packets.PubrecPacket) + pr.MessageID = packet.MessageID + DEBUG.Println(NET, "putting pubrec msg on obound") + sendAck(&PacketAndToken{p: pr, t: nil}) + DEBUG.Println(NET, "done putting pubrec msg on obound") + case 1: + pa := packets.NewControlPacket(packets.Puback).(*packets.PubackPacket) + pa.MessageID = packet.MessageID + DEBUG.Println(NET, "putting puback msg on obound") + persistOutbound(persist, pa) // May fail if store has been closed + sendAck(&PacketAndToken{p: pa, t: nil}) + DEBUG.Println(NET, "done putting puback msg on obound") + case 0: + // do nothing, since there is no need to send an ack packet back + } + } +} diff --git a/mcp/dsx-exchange-mcp/vendor/github.com/eclipse/paho.mqtt.golang/netconn.go b/mcp/dsx-exchange-mcp/vendor/github.com/eclipse/paho.mqtt.golang/netconn.go new file mode 100644 index 0000000..e6f64e5 --- /dev/null +++ b/mcp/dsx-exchange-mcp/vendor/github.com/eclipse/paho.mqtt.golang/netconn.go @@ -0,0 +1,101 @@ +/* + * Copyright (c) 2021 IBM Corp and others. + * + * All rights reserved. This program and the accompanying materials + * are made available under the terms of the Eclipse Public License v2.0 + * and Eclipse Distribution License v1.0 which accompany this distribution. + * + * The Eclipse Public License is available at + * https://www.eclipse.org/legal/epl-2.0/ + * and the Eclipse Distribution License is available at + * http://www.eclipse.org/org/documents/edl-v10.php. + * + * Contributors: + * Seth Hoenig + * Allan Stockdill-Mander + * Mike Robertson + * MAtt Brittan + */ + +package mqtt + +import ( + "crypto/tls" + "errors" + "net" + "net/http" + "net/url" + "os" + "time" + + "golang.org/x/net/proxy" +) + +// +// This just establishes the network connection; once established the type of connection should be irrelevant +// + +// openConnection opens a network connection using the protocol indicated in the URL. +// Does not carry out any MQTT specific handshakes. +func openConnection(uri *url.URL, tlsc *tls.Config, timeout time.Duration, headers http.Header, websocketOptions *WebsocketOptions, dialer *net.Dialer) (net.Conn, error) { + switch uri.Scheme { + case "ws": + dialURI := *uri // #623 - Gorilla Websockets does not accept URL's where uri.User != nil + dialURI.User = nil + conn, err := NewWebsocket(dialURI.String(), nil, timeout, headers, websocketOptions) + return conn, err + case "wss": + dialURI := *uri // #623 - Gorilla Websockets does not accept URL's where uri.User != nil + dialURI.User = nil + conn, err := NewWebsocket(dialURI.String(), tlsc, timeout, headers, websocketOptions) + return conn, err + case "mqtt", "tcp": + proxyDialer := proxy.FromEnvironmentUsing(dialer) + conn, err := proxyDialer.Dial("tcp", uri.Host) + if err != nil { + return nil, err + } + return conn, nil + case "unix": + var conn net.Conn + var err error + + // this check is preserved for compatibility with older versions + // which used uri.Host only (it works for local paths, e.g. unix://socket.sock in current dir) + if len(uri.Host) > 0 { + conn, err = dialer.Dial("unix", uri.Host) + } else { + conn, err = dialer.Dial("unix", uri.Path) + } + + if err != nil { + return nil, err + } + return conn, nil + case "ssl", "tls", "mqtts", "mqtt+ssl", "tcps": + allProxy := os.Getenv("all_proxy") + if len(allProxy) == 0 { + conn, err := tls.DialWithDialer(dialer, "tcp", uri.Host, tlsc) + if err != nil { + return nil, err + } + return conn, nil + } + proxyDialer := proxy.FromEnvironment() + conn, err := proxyDialer.Dial("tcp", uri.Host) + if err != nil { + return nil, err + } + + tlsConn := tls.Client(conn, tlsc) + + err = tlsConn.Handshake() + if err != nil { + _ = conn.Close() + return nil, err + } + + return tlsConn, nil + } + return nil, errors.New("unknown protocol") +} diff --git a/mcp/dsx-exchange-mcp/vendor/github.com/eclipse/paho.mqtt.golang/oops.go b/mcp/dsx-exchange-mcp/vendor/github.com/eclipse/paho.mqtt.golang/oops.go new file mode 100644 index 0000000..c454aeb --- /dev/null +++ b/mcp/dsx-exchange-mcp/vendor/github.com/eclipse/paho.mqtt.golang/oops.go @@ -0,0 +1,25 @@ +/* + * Copyright (c) 2021 IBM Corp and others. + * + * All rights reserved. This program and the accompanying materials + * are made available under the terms of the Eclipse Public License v2.0 + * and Eclipse Distribution License v1.0 which accompany this distribution. + * + * The Eclipse Public License is available at + * https://www.eclipse.org/legal/epl-2.0/ + * and the Eclipse Distribution License is available at + * http://www.eclipse.org/org/documents/edl-v10.php. + * + * Contributors: + * Seth Hoenig + * Allan Stockdill-Mander + * Mike Robertson + */ + +package mqtt + +func chkerr(e error) { + if e != nil { + panic(e) + } +} diff --git a/mcp/dsx-exchange-mcp/vendor/github.com/eclipse/paho.mqtt.golang/options.go b/mcp/dsx-exchange-mcp/vendor/github.com/eclipse/paho.mqtt.golang/options.go new file mode 100644 index 0000000..8ce1c3c --- /dev/null +++ b/mcp/dsx-exchange-mcp/vendor/github.com/eclipse/paho.mqtt.golang/options.go @@ -0,0 +1,471 @@ +/* + * Copyright (c) 2021 IBM Corp and others. + * + * All rights reserved. This program and the accompanying materials + * are made available under the terms of the Eclipse Public License v2.0 + * and Eclipse Distribution License v1.0 which accompany this distribution. + * + * The Eclipse Public License is available at + * https://www.eclipse.org/legal/epl-2.0/ + * and the Eclipse Distribution License is available at + * http://www.eclipse.org/org/documents/edl-v10.php. + * + * Contributors: + * Seth Hoenig + * Allan Stockdill-Mander + * Mike Robertson + * Måns Ansgariusson + */ + +// Portions copyright © 2018 TIBCO Software Inc. + +package mqtt + +import ( + "crypto/tls" + "net" + "net/http" + "net/url" + "strings" + "time" +) + +// CredentialsProvider allows the username and password to be updated +// before reconnecting. It should return the current username and password. +type CredentialsProvider func() (username string, password string) + +// MessageHandler is a callback type which can be set to be +// executed upon the arrival of messages published to topics +// to which the client is subscribed. +type MessageHandler func(Client, Message) + +// ConnectionLostHandler is a callback type which can be set to be +// executed upon an unintended disconnection from the MQTT broker. +// Disconnects caused by calling Disconnect or ForceDisconnect will +// not cause an OnConnectionLost callback to execute. +type ConnectionLostHandler func(Client, error) + +// OnConnectHandler is a callback that is called when the client +// state changes from unconnected/disconnected to connected. Both +// at initial connection and on reconnection +type OnConnectHandler func(Client) + +// ReconnectHandler is invoked prior to reconnecting after +// the initial connection is lost +type ReconnectHandler func(Client, *ClientOptions) + +// ConnectionAttemptHandler is invoked prior to making the initial connection. +type ConnectionAttemptHandler func(broker *url.URL, tlsCfg *tls.Config) *tls.Config + +// OpenConnectionFunc is invoked to establish the underlying network connection +// Its purpose if for custom network transports. +// Does not carry out any MQTT specific handshakes. +type OpenConnectionFunc func(uri *url.URL, options ClientOptions) (net.Conn, error) + +// ConnectionNotificationHandler is invoked for any type of connection event. +type ConnectionNotificationHandler func(Client, ConnectionNotification) + +// ClientOptions contains configurable options for an Client. Note that these should be set using the +// relevant methods (e.g. AddBroker) rather than directly. See those functions for information on usage. +// WARNING: Create the below using NewClientOptions unless you have a compelling reason not to. It is easy +// to create a configuration with difficult to trace issues (e.g. Mosquitto 2.0.12+ will reject connections +// with KeepAlive=0 by default). +type ClientOptions struct { + Servers []*url.URL + ClientID string + Username string + Password string + CredentialsProvider CredentialsProvider + CleanSession bool + Order bool + WillEnabled bool + WillTopic string + WillPayload []byte + WillQos byte + WillRetained bool + ProtocolVersion uint + protocolVersionExplicit bool + TLSConfig *tls.Config + KeepAlive int64 // Warning: Some brokers may reject connections with Keepalive = 0. + PingTimeout time.Duration + ConnectTimeout time.Duration + MaxReconnectInterval time.Duration + AutoReconnect bool + ConnectRetryInterval time.Duration + ConnectRetry bool + Store Store + DefaultPublishHandler MessageHandler + OnConnect OnConnectHandler + OnConnectionLost ConnectionLostHandler + OnReconnecting ReconnectHandler + OnConnectAttempt ConnectionAttemptHandler + OnConnectionNotification ConnectionNotificationHandler + WriteTimeout time.Duration + MessageChannelDepth uint + ResumeSubs bool + HTTPHeaders http.Header + WebsocketOptions *WebsocketOptions + MaxResumePubInFlight int // // 0 = no limit; otherwise this is the maximum simultaneous messages sent while resuming + Dialer *net.Dialer + CustomOpenConnectionFn OpenConnectionFunc + AutoAckDisabled bool +} + +// NewClientOptions will create a new ClientClientOptions type with some +// default values. +// +// Port: 1883 +// CleanSession: True +// Order: True (note: it is recommended that this be set to FALSE unless order is important) +// KeepAlive: 30 (seconds) +// ConnectTimeout: 30 (seconds) +// MaxReconnectInterval 10 (minutes) +// AutoReconnect: True +func NewClientOptions() *ClientOptions { + o := &ClientOptions{ + Servers: nil, + ClientID: "", + Username: "", + Password: "", + CleanSession: true, + Order: true, + WillEnabled: false, + WillTopic: "", + WillPayload: nil, + WillQos: 0, + WillRetained: false, + ProtocolVersion: 0, + protocolVersionExplicit: false, + KeepAlive: 30, + PingTimeout: 10 * time.Second, + ConnectTimeout: 30 * time.Second, + MaxReconnectInterval: 10 * time.Minute, + AutoReconnect: true, + ConnectRetryInterval: 30 * time.Second, + ConnectRetry: false, + Store: nil, + OnConnect: nil, + OnConnectionLost: DefaultConnectionLostHandler, + OnConnectAttempt: nil, + OnConnectionNotification: nil, + WriteTimeout: 0, // 0 represents timeout disabled + ResumeSubs: false, + HTTPHeaders: make(map[string][]string), + WebsocketOptions: &WebsocketOptions{}, + Dialer: &net.Dialer{Timeout: 30 * time.Second}, + CustomOpenConnectionFn: nil, + AutoAckDisabled: false, + } + return o +} + +// AddBroker adds a broker URI to the list of brokers to be used. The format should be +// scheme://host:port +// Where "scheme" is one of "tcp", "ssl", or "ws", "host" is the ip-address (or hostname) +// and "port" is the port on which the broker is accepting connections. +// +// Default values for hostname is "127.0.0.1", for schema is "tcp://". +// +// An example broker URI would look like: tcp://foobar.com:1883 +func (o *ClientOptions) AddBroker(server string) *ClientOptions { + if len(server) > 0 && server[0] == ':' { + server = "127.0.0.1" + server + } + if !strings.Contains(server, "://") { + server = "tcp://" + server + } + brokerURI, err := url.Parse(server) + if err != nil { + ERROR.Println(CLI, "Failed to parse %q broker address: %s", server, err) + return o + } + o.Servers = append(o.Servers, brokerURI) + return o +} + +// SetResumeSubs will enable resuming of stored (un)subscribe messages when connecting +// but not reconnecting if CleanSession is false. Otherwise these messages are discarded. +func (o *ClientOptions) SetResumeSubs(resume bool) *ClientOptions { + o.ResumeSubs = resume + return o +} + +// SetClientID will set the client id to be used by this client when +// connecting to the MQTT broker. According to the MQTT v3.1 specification, +// a client id must be no longer than 23 characters. +func (o *ClientOptions) SetClientID(id string) *ClientOptions { + o.ClientID = id + return o +} + +// SetUsername will set the username to be used by this client when connecting +// to the MQTT broker. Note: without the use of SSL/TLS, this information will +// be sent in plaintext across the wire. +func (o *ClientOptions) SetUsername(u string) *ClientOptions { + o.Username = u + return o +} + +// SetPassword will set the password to be used by this client when connecting +// to the MQTT broker. Note: without the use of SSL/TLS, this information will +// be sent in plaintext across the wire. +func (o *ClientOptions) SetPassword(p string) *ClientOptions { + o.Password = p + return o +} + +// SetCredentialsProvider will set a method to be called by this client when +// connecting to the MQTT broker that provide the current username and password. +// Note: without the use of SSL/TLS, this information will be sent +// in plaintext across the wire. +func (o *ClientOptions) SetCredentialsProvider(p CredentialsProvider) *ClientOptions { + o.CredentialsProvider = p + return o +} + +// SetCleanSession will set the "clean session" flag in the connect message +// when this client connects to an MQTT broker. By setting this flag, you are +// indicating that no messages saved by the broker for this client should be +// delivered. Any messages that were going to be sent by this client before +// disconnecting previously but didn't will not be sent upon connecting to the +// broker. +func (o *ClientOptions) SetCleanSession(clean bool) *ClientOptions { + o.CleanSession = clean + return o +} + +// SetOrderMatters will set the message routing to guarantee order within +// each QoS level. By default, this value is true. If set to false (recommended), +// this flag indicates that messages can be delivered asynchronously +// from the client to the application and possibly arrive out of order. +// Specifically, the message handler is called in its own go routine. +// Note that setting this to true does not guarantee in-order delivery +// (this is subject to broker settings like "max_inflight_messages=1" in mosquitto) +// and if true then handlers must not block. +func (o *ClientOptions) SetOrderMatters(order bool) *ClientOptions { + o.Order = order + return o +} + +// SetTLSConfig will set an SSL/TLS configuration to be used when connecting +// to an MQTT broker. Please read the official Go documentation for more +// information. +func (o *ClientOptions) SetTLSConfig(t *tls.Config) *ClientOptions { + o.TLSConfig = t + return o +} + +// SetStore will set the implementation of the Store interface +// used to provide message persistence in cases where QoS levels +// QoS_ONE or QoS_TWO are used. If no store is provided, then the +// client will use MemoryStore by default. +func (o *ClientOptions) SetStore(s Store) *ClientOptions { + o.Store = s + return o +} + +// SetKeepAlive will set the amount of time (in seconds) that the client +// should wait before sending a PING request to the broker. This will +// allow the client to know that a connection has not been lost with the +// server. +func (o *ClientOptions) SetKeepAlive(k time.Duration) *ClientOptions { + o.KeepAlive = int64(k / time.Second) + return o +} + +// SetPingTimeout will set the amount of time (in seconds) that the client +// will wait after sending a PING request to the broker, before deciding +// that the connection has been lost. Default is 10 seconds. +func (o *ClientOptions) SetPingTimeout(k time.Duration) *ClientOptions { + o.PingTimeout = k + return o +} + +// SetProtocolVersion sets the MQTT version to be used to connect to the +// broker. Legitimate values are currently 3 - MQTT 3.1 or 4 - MQTT 3.1.1 +func (o *ClientOptions) SetProtocolVersion(pv uint) *ClientOptions { + if (pv >= 3 && pv <= 4) || (pv > 0x80) { + o.ProtocolVersion = pv + o.protocolVersionExplicit = true + } + return o +} + +// UnsetWill will cause any set will message to be disregarded. +func (o *ClientOptions) UnsetWill() *ClientOptions { + o.WillEnabled = false + return o +} + +// SetWill accepts a string will message to be set. When the client connects, +// it will give this will message to the broker, which will then publish the +// provided payload (the will) to any clients that are subscribed to the provided +// topic. +func (o *ClientOptions) SetWill(topic string, payload string, qos byte, retained bool) *ClientOptions { + o.SetBinaryWill(topic, []byte(payload), qos, retained) + return o +} + +// SetBinaryWill accepts a []byte will message to be set. When the client connects, +// it will give this will message to the broker, which will then publish the +// provided payload (the will) to any clients that are subscribed to the provided +// topic. +func (o *ClientOptions) SetBinaryWill(topic string, payload []byte, qos byte, retained bool) *ClientOptions { + o.WillEnabled = true + o.WillTopic = topic + o.WillPayload = payload + o.WillQos = qos + o.WillRetained = retained + return o +} + +// SetDefaultPublishHandler sets the MessageHandler that will be called when a message +// is received that does not match any known subscriptions. +// +// If OrderMatters is true (the defaultHandler) then callback must not block or +// call functions within this package that may block (e.g. Publish) other than in +// a new go routine. +// defaultHandler must be safe for concurrent use by multiple goroutines. +func (o *ClientOptions) SetDefaultPublishHandler(defaultHandler MessageHandler) *ClientOptions { + o.DefaultPublishHandler = defaultHandler + return o +} + +// SetOnConnectHandler sets the function to be called when the client is connected. Both +// at initial connection time and upon automatic reconnect. +func (o *ClientOptions) SetOnConnectHandler(onConn OnConnectHandler) *ClientOptions { + o.OnConnect = onConn + return o +} + +// SetConnectionLostHandler will set the OnConnectionLost callback to be executed +// in the case where the client unexpectedly loses connection with the MQTT broker. +func (o *ClientOptions) SetConnectionLostHandler(onLost ConnectionLostHandler) *ClientOptions { + o.OnConnectionLost = onLost + return o +} + +// SetReconnectingHandler sets the OnReconnecting callback to be executed prior +// to the client attempting a reconnect to the MQTT broker. +func (o *ClientOptions) SetReconnectingHandler(cb ReconnectHandler) *ClientOptions { + o.OnReconnecting = cb + return o +} + +// SetConnectionAttemptHandler sets the ConnectionAttemptHandler callback to be executed prior +// to each attempt to connect to an MQTT broker. Returns the *tls.Config that will be used when establishing +// the connection (a copy of the tls.Config from ClientOptions will be passed in along with the broker URL). +// This allows connection specific changes to be made to the *tls.Config. +func (o *ClientOptions) SetConnectionAttemptHandler(onConnectAttempt ConnectionAttemptHandler) *ClientOptions { + o.OnConnectAttempt = onConnectAttempt + return o +} + +// SetConnectionNotificationHandler sets the ConnectionNotificationHandler callback to receive all types of connection +// events. +func (o *ClientOptions) SetConnectionNotificationHandler(onConnectionNotification ConnectionNotificationHandler) *ClientOptions { + o.OnConnectionNotification = onConnectionNotification + return o +} + +// SetWriteTimeout puts a limit on how long a mqtt publish should block until it unblocks with a +// timeout error. A duration of 0 never times out. Default never times out +func (o *ClientOptions) SetWriteTimeout(t time.Duration) *ClientOptions { + o.WriteTimeout = t + return o +} + +// SetConnectTimeout limits how long the client will wait when trying to open a connection +// to an MQTT server before timing out. A duration of 0 never times out. +// Default 30 seconds. Currently only operational on TCP/TLS connections. +func (o *ClientOptions) SetConnectTimeout(t time.Duration) *ClientOptions { + o.ConnectTimeout = t + o.Dialer.Timeout = t + return o +} + +// SetMaxReconnectInterval sets the maximum time that will be waited between reconnection attempts +// when connection is lost +func (o *ClientOptions) SetMaxReconnectInterval(t time.Duration) *ClientOptions { + o.MaxReconnectInterval = t + return o +} + +// SetAutoReconnect sets whether the automatic reconnection logic should be used +// when the connection is lost, even if disabled the ConnectionLostHandler is still +// called +func (o *ClientOptions) SetAutoReconnect(a bool) *ClientOptions { + o.AutoReconnect = a + return o +} + +// SetConnectRetryInterval sets the time that will be waited between connection attempts +// when initially connecting if ConnectRetry is TRUE +func (o *ClientOptions) SetConnectRetryInterval(t time.Duration) *ClientOptions { + o.ConnectRetryInterval = t + return o +} + +// SetConnectRetry sets whether the connect function will automatically retry the connection +// in the event of a failure (when true the token returned by the Connect function will +// not complete until the connection is up or it is cancelled) +// If ConnectRetry is true then subscriptions should be requested in OnConnect handler +// Setting this to TRUE permits messages to be published before the connection is established +func (o *ClientOptions) SetConnectRetry(a bool) *ClientOptions { + o.ConnectRetry = a + return o +} + +// SetMessageChannelDepth DEPRECATED The value set here no longer has any effect, this function +// remains so the API is not altered. +func (o *ClientOptions) SetMessageChannelDepth(s uint) *ClientOptions { + o.MessageChannelDepth = s + return o +} + +// SetHTTPHeaders sets the additional HTTP headers that will be sent in the WebSocket +// opening handshake. +func (o *ClientOptions) SetHTTPHeaders(h http.Header) *ClientOptions { + o.HTTPHeaders = h + return o +} + +// SetWebsocketOptions sets the additional websocket options used in a WebSocket connection +func (o *ClientOptions) SetWebsocketOptions(w *WebsocketOptions) *ClientOptions { + o.WebsocketOptions = w + return o +} + +// SetMaxResumePubInFlight sets the maximum simultaneous publish messages that will be sent while resuming. Note that +// this only applies to messages coming from the store (so additional sends may push us over the limit) +// Note that the connect token will not be flagged as complete until all messages have been sent from the +// store. If broker does not respond to messages then resume may not complete. +// This option was put in place because resuming after downtime can saturate low capacity links. +func (o *ClientOptions) SetMaxResumePubInFlight(MaxResumePubInFlight int) *ClientOptions { + o.MaxResumePubInFlight = MaxResumePubInFlight + return o +} + +// SetDialer sets the tcp dialer options used in a tcp connection +func (o *ClientOptions) SetDialer(dialer *net.Dialer) *ClientOptions { + o.Dialer = dialer + return o +} + +// SetCustomOpenConnectionFn replaces the inbuilt function that establishes a network connection with a custom function. +// The passed in function should return an open `net.Conn` or an error (see the existing openConnection function for an example) +// It enables custom networking types in addition to the defaults (tcp, tls, websockets...) +func (o *ClientOptions) SetCustomOpenConnectionFn(customOpenConnectionFn OpenConnectionFunc) *ClientOptions { + if customOpenConnectionFn != nil { + o.CustomOpenConnectionFn = customOpenConnectionFn + } + return o +} + +// SetAutoAckDisabled enables or disables the Automated Acking of Messages received by the handler. +// +// By default it is set to false. Setting it to true will disable the auto-ack globally. +func (o *ClientOptions) SetAutoAckDisabled(autoAckDisabled bool) *ClientOptions { + o.AutoAckDisabled = autoAckDisabled + return o +} diff --git a/mcp/dsx-exchange-mcp/vendor/github.com/eclipse/paho.mqtt.golang/options_reader.go b/mcp/dsx-exchange-mcp/vendor/github.com/eclipse/paho.mqtt.golang/options_reader.go new file mode 100644 index 0000000..395075f --- /dev/null +++ b/mcp/dsx-exchange-mcp/vendor/github.com/eclipse/paho.mqtt.golang/options_reader.go @@ -0,0 +1,186 @@ +/* + * Copyright (c) 2021 IBM Corp and others. + * + * All rights reserved. This program and the accompanying materials + * are made available under the terms of the Eclipse Public License v2.0 + * and Eclipse Distribution License v1.0 which accompany this distribution. + * + * The Eclipse Public License is available at + * https://www.eclipse.org/legal/epl-2.0/ + * and the Eclipse Distribution License is available at + * http://www.eclipse.org/org/documents/edl-v10.php. + * + * Contributors: + * Seth Hoenig + * Allan Stockdill-Mander + * Mike Robertson + */ + +package mqtt + +import ( + "crypto/tls" + "net/http" + "net/url" + "time" +) + +// ClientOptionsReader provides an interface for reading ClientOptions after the client has been initialized. +type ClientOptionsReader struct { + options *ClientOptions +} + +// NewOptionsReader creates a ClientOptionsReader, this should only be used for mocking purposes. +// +// An example implementation: +// +// func (c *mqttClientMock) OptionsReader() mqtt.ClientOptionsReader { +// opts := mqtt.NewClientOptions() +// opts.UserName = "TestUserName" +// return mqtt.NewOptionsReader(opts) +// } +func NewOptionsReader(o *ClientOptions) ClientOptionsReader { + return ClientOptionsReader{ + options: o, + } +} + +// Servers returns a slice of the servers defined in the clientoptions +func (r *ClientOptionsReader) Servers() []*url.URL { + s := make([]*url.URL, len(r.options.Servers)) + + for i, u := range r.options.Servers { + nu := *u + s[i] = &nu + } + + return s +} + +// ResumeSubs returns true if resuming stored (un)sub is enabled +func (r *ClientOptionsReader) ResumeSubs() bool { + s := r.options.ResumeSubs + return s +} + +// ClientID returns the set client id +func (r *ClientOptionsReader) ClientID() string { + s := r.options.ClientID + return s +} + +// Username returns the set username +func (r *ClientOptionsReader) Username() string { + s := r.options.Username + return s +} + +// Password returns the set password +func (r *ClientOptionsReader) Password() string { + s := r.options.Password + return s +} + +// CleanSession returns whether Cleansession is set +func (r *ClientOptionsReader) CleanSession() bool { + s := r.options.CleanSession + return s +} + +func (r *ClientOptionsReader) Order() bool { + s := r.options.Order + return s +} + +func (r *ClientOptionsReader) WillEnabled() bool { + s := r.options.WillEnabled + return s +} + +func (r *ClientOptionsReader) WillTopic() string { + s := r.options.WillTopic + return s +} + +func (r *ClientOptionsReader) WillPayload() []byte { + s := r.options.WillPayload + return s +} + +func (r *ClientOptionsReader) WillQos() byte { + s := r.options.WillQos + return s +} + +func (r *ClientOptionsReader) WillRetained() bool { + s := r.options.WillRetained + return s +} + +func (r *ClientOptionsReader) ProtocolVersion() uint { + s := r.options.ProtocolVersion + return s +} + +func (r *ClientOptionsReader) TLSConfig() *tls.Config { + s := r.options.TLSConfig + return s +} + +func (r *ClientOptionsReader) KeepAlive() time.Duration { + s := time.Duration(r.options.KeepAlive * int64(time.Second)) + return s +} + +func (r *ClientOptionsReader) PingTimeout() time.Duration { + s := r.options.PingTimeout + return s +} + +func (r *ClientOptionsReader) ConnectTimeout() time.Duration { + s := r.options.ConnectTimeout + return s +} + +func (r *ClientOptionsReader) MaxReconnectInterval() time.Duration { + s := r.options.MaxReconnectInterval + return s +} + +func (r *ClientOptionsReader) AutoReconnect() bool { + s := r.options.AutoReconnect + return s +} + +// ConnectRetryInterval returns the delay between retries on the initial connection (if ConnectRetry true) +func (r *ClientOptionsReader) ConnectRetryInterval() time.Duration { + s := r.options.ConnectRetryInterval + return s +} + +// ConnectRetry returns whether the initial connection request will be retried until connection established +func (r *ClientOptionsReader) ConnectRetry() bool { + s := r.options.ConnectRetry + return s +} + +func (r *ClientOptionsReader) WriteTimeout() time.Duration { + s := r.options.WriteTimeout + return s +} + +func (r *ClientOptionsReader) MessageChannelDepth() uint { + s := r.options.MessageChannelDepth + return s +} + +func (r *ClientOptionsReader) HTTPHeaders() http.Header { + h := r.options.HTTPHeaders + return h +} + +// WebsocketOptions returns the currently configured WebSocket options +func (r *ClientOptionsReader) WebsocketOptions() *WebsocketOptions { + s := r.options.WebsocketOptions + return s +} diff --git a/mcp/dsx-exchange-mcp/vendor/github.com/eclipse/paho.mqtt.golang/packets/connack.go b/mcp/dsx-exchange-mcp/vendor/github.com/eclipse/paho.mqtt.golang/packets/connack.go new file mode 100644 index 0000000..3a7b98f --- /dev/null +++ b/mcp/dsx-exchange-mcp/vendor/github.com/eclipse/paho.mqtt.golang/packets/connack.go @@ -0,0 +1,68 @@ +/* + * Copyright (c) 2021 IBM Corp and others. + * + * All rights reserved. This program and the accompanying materials + * are made available under the terms of the Eclipse Public License v2.0 + * and Eclipse Distribution License v1.0 which accompany this distribution. + * + * The Eclipse Public License is available at + * https://www.eclipse.org/legal/epl-2.0/ + * and the Eclipse Distribution License is available at + * http://www.eclipse.org/org/documents/edl-v10.php. + * + * Contributors: + * Allan Stockdill-Mander + */ + +package packets + +import ( + "bytes" + "fmt" + "io" +) + +// ConnackPacket is an internal representation of the fields of the +// Connack MQTT packet +type ConnackPacket struct { + FixedHeader + SessionPresent bool + ReturnCode byte +} + +func (ca *ConnackPacket) String() string { + return fmt.Sprintf("%s sessionpresent: %t returncode: %d", ca.FixedHeader, ca.SessionPresent, ca.ReturnCode) +} + +func (ca *ConnackPacket) Write(w io.Writer) error { + var body bytes.Buffer + var err error + + body.WriteByte(boolToByte(ca.SessionPresent)) + body.WriteByte(ca.ReturnCode) + ca.FixedHeader.RemainingLength = 2 + packet := ca.FixedHeader.pack() + packet.Write(body.Bytes()) + _, err = packet.WriteTo(w) + + return err +} + +// Unpack decodes the details of a ControlPacket after the fixed +// header has been read +func (ca *ConnackPacket) Unpack(b io.Reader) error { + flags, err := decodeByte(b) + if err != nil { + return err + } + ca.SessionPresent = 1&flags > 0 + ca.ReturnCode, err = decodeByte(b) + + return err +} + +// Details returns a Details struct containing the Qos and +// MessageID of this ControlPacket +func (ca *ConnackPacket) Details() Details { + return Details{Qos: 0, MessageID: 0} +} diff --git a/mcp/dsx-exchange-mcp/vendor/github.com/eclipse/paho.mqtt.golang/packets/connect.go b/mcp/dsx-exchange-mcp/vendor/github.com/eclipse/paho.mqtt.golang/packets/connect.go new file mode 100644 index 0000000..b4446a5 --- /dev/null +++ b/mcp/dsx-exchange-mcp/vendor/github.com/eclipse/paho.mqtt.golang/packets/connect.go @@ -0,0 +1,171 @@ +/* + * Copyright (c) 2021 IBM Corp and others. + * + * All rights reserved. This program and the accompanying materials + * are made available under the terms of the Eclipse Public License v2.0 + * and Eclipse Distribution License v1.0 which accompany this distribution. + * + * The Eclipse Public License is available at + * https://www.eclipse.org/legal/epl-2.0/ + * and the Eclipse Distribution License is available at + * http://www.eclipse.org/org/documents/edl-v10.php. + * + * Contributors: + * Allan Stockdill-Mander + */ + +package packets + +import ( + "bytes" + "fmt" + "io" +) + +// ConnectPacket is an internal representation of the fields of the +// Connect MQTT packet +type ConnectPacket struct { + FixedHeader + ProtocolName string + ProtocolVersion byte + CleanSession bool + WillFlag bool + WillQos byte + WillRetain bool + UsernameFlag bool + PasswordFlag bool + ReservedBit byte + Keepalive uint16 + + ClientIdentifier string + WillTopic string + WillMessage []byte + Username string + Password []byte +} + +func (c *ConnectPacket) String() string { + var password string + if len(c.Password) > 0 { + password = "" + } + return fmt.Sprintf("%s protocolversion: %d protocolname: %s cleansession: %t willflag: %t WillQos: %d WillRetain: %t Usernameflag: %t Passwordflag: %t keepalive: %d clientId: %s willtopic: %s willmessage: %s Username: %s Password: %s", c.FixedHeader, c.ProtocolVersion, c.ProtocolName, c.CleanSession, c.WillFlag, c.WillQos, c.WillRetain, c.UsernameFlag, c.PasswordFlag, c.Keepalive, c.ClientIdentifier, c.WillTopic, c.WillMessage, c.Username, password) +} + +func (c *ConnectPacket) Write(w io.Writer) error { + var body bytes.Buffer + var err error + + body.Write(encodeString(c.ProtocolName)) + body.WriteByte(c.ProtocolVersion) + body.WriteByte(boolToByte(c.CleanSession)<<1 | boolToByte(c.WillFlag)<<2 | c.WillQos<<3 | boolToByte(c.WillRetain)<<5 | boolToByte(c.PasswordFlag)<<6 | boolToByte(c.UsernameFlag)<<7) + body.Write(encodeUint16(c.Keepalive)) + body.Write(encodeString(c.ClientIdentifier)) + if c.WillFlag { + body.Write(encodeString(c.WillTopic)) + body.Write(encodeBytes(c.WillMessage)) + } + if c.UsernameFlag { + body.Write(encodeString(c.Username)) + } + if c.PasswordFlag { + body.Write(encodeBytes(c.Password)) + } + c.FixedHeader.RemainingLength = body.Len() + packet := c.FixedHeader.pack() + packet.Write(body.Bytes()) + _, err = packet.WriteTo(w) + + return err +} + +// Unpack decodes the details of a ControlPacket after the fixed +// header has been read +func (c *ConnectPacket) Unpack(b io.Reader) error { + var err error + c.ProtocolName, err = decodeString(b) + if err != nil { + return err + } + c.ProtocolVersion, err = decodeByte(b) + if err != nil { + return err + } + options, err := decodeByte(b) + if err != nil { + return err + } + c.ReservedBit = 1 & options + c.CleanSession = 1&(options>>1) > 0 + c.WillFlag = 1&(options>>2) > 0 + c.WillQos = 3 & (options >> 3) + c.WillRetain = 1&(options>>5) > 0 + c.PasswordFlag = 1&(options>>6) > 0 + c.UsernameFlag = 1&(options>>7) > 0 + c.Keepalive, err = decodeUint16(b) + if err != nil { + return err + } + c.ClientIdentifier, err = decodeString(b) + if err != nil { + return err + } + if c.WillFlag { + c.WillTopic, err = decodeString(b) + if err != nil { + return err + } + c.WillMessage, err = decodeBytes(b) + if err != nil { + return err + } + } + if c.UsernameFlag { + c.Username, err = decodeString(b) + if err != nil { + return err + } + } + if c.PasswordFlag { + c.Password, err = decodeBytes(b) + if err != nil { + return err + } + } + + return nil +} + +// Validate performs validation of the fields of a Connect packet +func (c *ConnectPacket) Validate() byte { + if c.PasswordFlag && !c.UsernameFlag { + return ErrRefusedBadUsernameOrPassword + } + if c.ReservedBit != 0 { + // Bad reserved bit + return ErrProtocolViolation + } + if (c.ProtocolName == "MQIsdp" && c.ProtocolVersion != 3) || (c.ProtocolName == "MQTT" && c.ProtocolVersion != 4) { + // Mismatched or unsupported protocol version + return ErrRefusedBadProtocolVersion + } + if c.ProtocolName != "MQIsdp" && c.ProtocolName != "MQTT" { + // Bad protocol name + return ErrProtocolViolation + } + if len(c.ClientIdentifier) > 65535 || len(c.Username) > 65535 || len(c.Password) > 65535 { + // Bad size field + return ErrProtocolViolation + } + if len(c.ClientIdentifier) == 0 && !c.CleanSession { + // Bad client identifier + return ErrRefusedIDRejected + } + return Accepted +} + +// Details returns a Details struct containing the Qos and +// MessageID of this ControlPacket +func (c *ConnectPacket) Details() Details { + return Details{Qos: 0, MessageID: 0} +} diff --git a/mcp/dsx-exchange-mcp/vendor/github.com/eclipse/paho.mqtt.golang/packets/disconnect.go b/mcp/dsx-exchange-mcp/vendor/github.com/eclipse/paho.mqtt.golang/packets/disconnect.go new file mode 100644 index 0000000..cf352a3 --- /dev/null +++ b/mcp/dsx-exchange-mcp/vendor/github.com/eclipse/paho.mqtt.golang/packets/disconnect.go @@ -0,0 +1,50 @@ +/* + * Copyright (c) 2021 IBM Corp and others. + * + * All rights reserved. This program and the accompanying materials + * are made available under the terms of the Eclipse Public License v2.0 + * and Eclipse Distribution License v1.0 which accompany this distribution. + * + * The Eclipse Public License is available at + * https://www.eclipse.org/legal/epl-2.0/ + * and the Eclipse Distribution License is available at + * http://www.eclipse.org/org/documents/edl-v10.php. + * + * Contributors: + * Allan Stockdill-Mander + */ + +package packets + +import ( + "io" +) + +// DisconnectPacket is an internal representation of the fields of the +// Disconnect MQTT packet +type DisconnectPacket struct { + FixedHeader +} + +func (d *DisconnectPacket) String() string { + return d.FixedHeader.String() +} + +func (d *DisconnectPacket) Write(w io.Writer) error { + packet := d.FixedHeader.pack() + _, err := packet.WriteTo(w) + + return err +} + +// Unpack decodes the details of a ControlPacket after the fixed +// header has been read +func (d *DisconnectPacket) Unpack(b io.Reader) error { + return nil +} + +// Details returns a Details struct containing the Qos and +// MessageID of this ControlPacket +func (d *DisconnectPacket) Details() Details { + return Details{Qos: 0, MessageID: 0} +} diff --git a/mcp/dsx-exchange-mcp/vendor/github.com/eclipse/paho.mqtt.golang/packets/packets.go b/mcp/dsx-exchange-mcp/vendor/github.com/eclipse/paho.mqtt.golang/packets/packets.go new file mode 100644 index 0000000..7cc3c6d --- /dev/null +++ b/mcp/dsx-exchange-mcp/vendor/github.com/eclipse/paho.mqtt.golang/packets/packets.go @@ -0,0 +1,377 @@ +/* + * Copyright (c) 2021 IBM Corp and others. + * + * All rights reserved. This program and the accompanying materials + * are made available under the terms of the Eclipse Public License v2.0 + * and Eclipse Distribution License v1.0 which accompany this distribution. + * + * The Eclipse Public License is available at + * https://www.eclipse.org/legal/epl-2.0/ + * and the Eclipse Distribution License is available at + * http://www.eclipse.org/org/documents/edl-v10.php. + * + * Contributors: + * Allan Stockdill-Mander + */ + +package packets + +import ( + "bytes" + "encoding/binary" + "errors" + "fmt" + "io" +) + +// ControlPacket defines the interface for structs intended to hold +// decoded MQTT packets, either from being read or before being +// written +type ControlPacket interface { + Write(io.Writer) error + Unpack(io.Reader) error + String() string + Details() Details +} + +// PacketNames maps the constants for each of the MQTT packet types +// to a string representation of their name. +var PacketNames = map[uint8]string{ + 1: "CONNECT", + 2: "CONNACK", + 3: "PUBLISH", + 4: "PUBACK", + 5: "PUBREC", + 6: "PUBREL", + 7: "PUBCOMP", + 8: "SUBSCRIBE", + 9: "SUBACK", + 10: "UNSUBSCRIBE", + 11: "UNSUBACK", + 12: "PINGREQ", + 13: "PINGRESP", + 14: "DISCONNECT", +} + +// Below are the constants assigned to each of the MQTT packet types +const ( + Connect = 1 + Connack = 2 + Publish = 3 + Puback = 4 + Pubrec = 5 + Pubrel = 6 + Pubcomp = 7 + Subscribe = 8 + Suback = 9 + Unsubscribe = 10 + Unsuback = 11 + Pingreq = 12 + Pingresp = 13 + Disconnect = 14 +) + +// Below are the const definitions for error codes returned by +// Connect() +const ( + Accepted = 0x00 + ErrRefusedBadProtocolVersion = 0x01 + ErrRefusedIDRejected = 0x02 + ErrRefusedServerUnavailable = 0x03 + ErrRefusedBadUsernameOrPassword = 0x04 + ErrRefusedNotAuthorised = 0x05 + ErrNetworkError = 0xFE + ErrProtocolViolation = 0xFF +) + +// ConnackReturnCodes is a map of the error codes constants for Connect() +// to a string representation of the error +var ConnackReturnCodes = map[uint8]string{ + 0: "Connection Accepted", + 1: "Connection Refused: Bad Protocol Version", + 2: "Connection Refused: Client Identifier Rejected", + 3: "Connection Refused: Server Unavailable", + 4: "Connection Refused: Username or Password in unknown format", + 5: "Connection Refused: Not Authorised", + 254: "Connection Error", + 255: "Connection Refused: Protocol Violation", +} + +var ( + ErrorRefusedBadProtocolVersion = errors.New("unacceptable protocol version") + ErrorRefusedIDRejected = errors.New("identifier rejected") + ErrorRefusedServerUnavailable = errors.New("server Unavailable") + ErrorRefusedBadUsernameOrPassword = errors.New("bad user name or password") + ErrorRefusedNotAuthorised = errors.New("not Authorized") + ErrorNetworkError = errors.New("network Error") + ErrorProtocolViolation = errors.New("protocol Violation") +) + +// ConnErrors is a map of the errors codes constants for Connect() +// to a Go error +var ConnErrors = map[byte]error{ + Accepted: nil, + ErrRefusedBadProtocolVersion: ErrorRefusedBadProtocolVersion, + ErrRefusedIDRejected: ErrorRefusedIDRejected, + ErrRefusedServerUnavailable: ErrorRefusedServerUnavailable, + ErrRefusedBadUsernameOrPassword: ErrorRefusedBadUsernameOrPassword, + ErrRefusedNotAuthorised: ErrorRefusedNotAuthorised, + ErrNetworkError: ErrorNetworkError, + ErrProtocolViolation: ErrorProtocolViolation, +} + +// ReadPacket takes an instance of an io.Reader (such as net.Conn) and attempts +// to read an MQTT packet from the stream. It returns a ControlPacket +// representing the decoded MQTT packet and an error. One of these returns will +// always be nil, a nil ControlPacket indicating an error occurred. +func ReadPacket(r io.Reader) (ControlPacket, error) { + var fh FixedHeader + b := make([]byte, 1) + + _, err := io.ReadFull(r, b) + if err != nil { + return nil, err + } + + err = fh.unpack(b[0], r) + if err != nil { + return nil, err + } + + cp, err := NewControlPacketWithHeader(fh) + if err != nil { + return nil, err + } + + packetBytes := make([]byte, fh.RemainingLength) + n, err := io.ReadFull(r, packetBytes) + if err != nil { + return nil, err + } + if n != fh.RemainingLength { + return nil, errors.New("failed to read expected data") + } + + err = cp.Unpack(bytes.NewBuffer(packetBytes)) + return cp, err +} + +// NewControlPacket is used to create a new ControlPacket of the type specified +// by packetType, this is usually done by reference to the packet type constants +// defined in packets.go. The newly created ControlPacket is empty and a pointer +// is returned. +func NewControlPacket(packetType byte) ControlPacket { + switch packetType { + case Connect: + return &ConnectPacket{FixedHeader: FixedHeader{MessageType: Connect}} + case Connack: + return &ConnackPacket{FixedHeader: FixedHeader{MessageType: Connack}} + case Disconnect: + return &DisconnectPacket{FixedHeader: FixedHeader{MessageType: Disconnect}} + case Publish: + return &PublishPacket{FixedHeader: FixedHeader{MessageType: Publish}} + case Puback: + return &PubackPacket{FixedHeader: FixedHeader{MessageType: Puback}} + case Pubrec: + return &PubrecPacket{FixedHeader: FixedHeader{MessageType: Pubrec}} + case Pubrel: + return &PubrelPacket{FixedHeader: FixedHeader{MessageType: Pubrel, Qos: 1}} + case Pubcomp: + return &PubcompPacket{FixedHeader: FixedHeader{MessageType: Pubcomp}} + case Subscribe: + return &SubscribePacket{FixedHeader: FixedHeader{MessageType: Subscribe, Qos: 1}} + case Suback: + return &SubackPacket{FixedHeader: FixedHeader{MessageType: Suback}} + case Unsubscribe: + return &UnsubscribePacket{FixedHeader: FixedHeader{MessageType: Unsubscribe, Qos: 1}} + case Unsuback: + return &UnsubackPacket{FixedHeader: FixedHeader{MessageType: Unsuback}} + case Pingreq: + return &PingreqPacket{FixedHeader: FixedHeader{MessageType: Pingreq}} + case Pingresp: + return &PingrespPacket{FixedHeader: FixedHeader{MessageType: Pingresp}} + } + return nil +} + +// NewControlPacketWithHeader is used to create a new ControlPacket of the type +// specified within the FixedHeader that is passed to the function. +// The newly created ControlPacket is empty and a pointer is returned. +func NewControlPacketWithHeader(fh FixedHeader) (ControlPacket, error) { + switch fh.MessageType { + case Connect: + return &ConnectPacket{FixedHeader: fh}, nil + case Connack: + return &ConnackPacket{FixedHeader: fh}, nil + case Disconnect: + return &DisconnectPacket{FixedHeader: fh}, nil + case Publish: + return &PublishPacket{FixedHeader: fh}, nil + case Puback: + return &PubackPacket{FixedHeader: fh}, nil + case Pubrec: + return &PubrecPacket{FixedHeader: fh}, nil + case Pubrel: + return &PubrelPacket{FixedHeader: fh}, nil + case Pubcomp: + return &PubcompPacket{FixedHeader: fh}, nil + case Subscribe: + return &SubscribePacket{FixedHeader: fh}, nil + case Suback: + return &SubackPacket{FixedHeader: fh}, nil + case Unsubscribe: + return &UnsubscribePacket{FixedHeader: fh}, nil + case Unsuback: + return &UnsubackPacket{FixedHeader: fh}, nil + case Pingreq: + return &PingreqPacket{FixedHeader: fh}, nil + case Pingresp: + return &PingrespPacket{FixedHeader: fh}, nil + } + return nil, fmt.Errorf("unsupported packet type 0x%x", fh.MessageType) +} + +// Details struct returned by the Details() function called on +// ControlPackets to present details of the Qos and MessageID +// of the ControlPacket +type Details struct { + Qos byte + MessageID uint16 +} + +// FixedHeader is a struct to hold the decoded information from +// the fixed header of an MQTT ControlPacket +type FixedHeader struct { + MessageType byte + Dup bool + Qos byte + Retain bool + RemainingLength int +} + +func (fh FixedHeader) String() string { + return fmt.Sprintf("%s: dup: %t qos: %d retain: %t rLength: %d", PacketNames[fh.MessageType], fh.Dup, fh.Qos, fh.Retain, fh.RemainingLength) +} + +func boolToByte(b bool) byte { + switch b { + case true: + return 1 + default: + return 0 + } +} + +func (fh *FixedHeader) pack() bytes.Buffer { + var header bytes.Buffer + header.WriteByte(fh.MessageType<<4 | boolToByte(fh.Dup)<<3 | fh.Qos<<1 | boolToByte(fh.Retain)) + header.Write(encodeLength(fh.RemainingLength)) + return header +} + +func (fh *FixedHeader) unpack(typeAndFlags byte, r io.Reader) error { + fh.MessageType = typeAndFlags >> 4 + fh.Dup = (typeAndFlags>>3)&0x01 > 0 + fh.Qos = (typeAndFlags >> 1) & 0x03 + fh.Retain = typeAndFlags&0x01 > 0 + + var err error + fh.RemainingLength, err = decodeLength(r) + return err +} + +func decodeByte(b io.Reader) (byte, error) { + num := make([]byte, 1) + _, err := b.Read(num) + if err != nil { + return 0, err + } + + return num[0], nil +} + +func decodeUint16(b io.Reader) (uint16, error) { + num := make([]byte, 2) + _, err := b.Read(num) + if err != nil { + return 0, err + } + return binary.BigEndian.Uint16(num), nil +} + +func encodeUint16(num uint16) []byte { + bytesResult := make([]byte, 2) + binary.BigEndian.PutUint16(bytesResult, num) + return bytesResult +} + +func encodeString(field string) []byte { + return encodeBytes([]byte(field)) +} + +func decodeString(b io.Reader) (string, error) { + buf, err := decodeBytes(b) + return string(buf), err +} + +func decodeBytes(b io.Reader) ([]byte, error) { + fieldLength, err := decodeUint16(b) + if err != nil { + return nil, err + } + + field := make([]byte, fieldLength) + _, err = b.Read(field) + if err != nil { + return nil, err + } + + return field, nil +} + +func encodeBytes(field []byte) []byte { + // Attempting to encode more than 65,535 bytes would lead to an unexpected 16-bit length and extra data written + // (which would be parsed as later parts of the message). The safest option is to truncate. + if len(field) > 65535 { + field = field[0:65535] + } + fieldLength := make([]byte, 2) + binary.BigEndian.PutUint16(fieldLength, uint16(len(field))) + return append(fieldLength, field...) +} + +func encodeLength(length int) []byte { + var encLength []byte + for { + digit := byte(length % 128) + length /= 128 + if length > 0 { + digit |= 0x80 + } + encLength = append(encLength, digit) + if length == 0 { + break + } + } + return encLength +} + +func decodeLength(r io.Reader) (int, error) { + var rLength uint32 + var multiplier uint32 + b := make([]byte, 1) + for multiplier < 27 { // fix: Infinite '(digit & 128) == 1' will cause the dead loop + _, err := io.ReadFull(r, b) + if err != nil { + return 0, err + } + + digit := b[0] + rLength |= uint32(digit&127) << multiplier + if (digit & 128) == 0 { + break + } + multiplier += 7 + } + return int(rLength), nil +} diff --git a/mcp/dsx-exchange-mcp/vendor/github.com/eclipse/paho.mqtt.golang/packets/pingreq.go b/mcp/dsx-exchange-mcp/vendor/github.com/eclipse/paho.mqtt.golang/packets/pingreq.go new file mode 100644 index 0000000..cd52948 --- /dev/null +++ b/mcp/dsx-exchange-mcp/vendor/github.com/eclipse/paho.mqtt.golang/packets/pingreq.go @@ -0,0 +1,50 @@ +/* + * Copyright (c) 2021 IBM Corp and others. + * + * All rights reserved. This program and the accompanying materials + * are made available under the terms of the Eclipse Public License v2.0 + * and Eclipse Distribution License v1.0 which accompany this distribution. + * + * The Eclipse Public License is available at + * https://www.eclipse.org/legal/epl-2.0/ + * and the Eclipse Distribution License is available at + * http://www.eclipse.org/org/documents/edl-v10.php. + * + * Contributors: + * Allan Stockdill-Mander + */ + +package packets + +import ( + "io" +) + +// PingreqPacket is an internal representation of the fields of the +// Pingreq MQTT packet +type PingreqPacket struct { + FixedHeader +} + +func (pr *PingreqPacket) String() string { + return pr.FixedHeader.String() +} + +func (pr *PingreqPacket) Write(w io.Writer) error { + packet := pr.FixedHeader.pack() + _, err := packet.WriteTo(w) + + return err +} + +// Unpack decodes the details of a ControlPacket after the fixed +// header has been read +func (pr *PingreqPacket) Unpack(b io.Reader) error { + return nil +} + +// Details returns a Details struct containing the Qos and +// MessageID of this ControlPacket +func (pr *PingreqPacket) Details() Details { + return Details{Qos: 0, MessageID: 0} +} diff --git a/mcp/dsx-exchange-mcp/vendor/github.com/eclipse/paho.mqtt.golang/packets/pingresp.go b/mcp/dsx-exchange-mcp/vendor/github.com/eclipse/paho.mqtt.golang/packets/pingresp.go new file mode 100644 index 0000000..d7becdf --- /dev/null +++ b/mcp/dsx-exchange-mcp/vendor/github.com/eclipse/paho.mqtt.golang/packets/pingresp.go @@ -0,0 +1,50 @@ +/* + * Copyright (c) 2021 IBM Corp and others. + * + * All rights reserved. This program and the accompanying materials + * are made available under the terms of the Eclipse Public License v2.0 + * and Eclipse Distribution License v1.0 which accompany this distribution. + * + * The Eclipse Public License is available at + * https://www.eclipse.org/legal/epl-2.0/ + * and the Eclipse Distribution License is available at + * http://www.eclipse.org/org/documents/edl-v10.php. + * + * Contributors: + * Allan Stockdill-Mander + */ + +package packets + +import ( + "io" +) + +// PingrespPacket is an internal representation of the fields of the +// Pingresp MQTT packet +type PingrespPacket struct { + FixedHeader +} + +func (pr *PingrespPacket) String() string { + return pr.FixedHeader.String() +} + +func (pr *PingrespPacket) Write(w io.Writer) error { + packet := pr.FixedHeader.pack() + _, err := packet.WriteTo(w) + + return err +} + +// Unpack decodes the details of a ControlPacket after the fixed +// header has been read +func (pr *PingrespPacket) Unpack(b io.Reader) error { + return nil +} + +// Details returns a Details struct containing the Qos and +// MessageID of this ControlPacket +func (pr *PingrespPacket) Details() Details { + return Details{Qos: 0, MessageID: 0} +} diff --git a/mcp/dsx-exchange-mcp/vendor/github.com/eclipse/paho.mqtt.golang/packets/puback.go b/mcp/dsx-exchange-mcp/vendor/github.com/eclipse/paho.mqtt.golang/packets/puback.go new file mode 100644 index 0000000..f6e727e --- /dev/null +++ b/mcp/dsx-exchange-mcp/vendor/github.com/eclipse/paho.mqtt.golang/packets/puback.go @@ -0,0 +1,58 @@ +/* + * Copyright (c) 2021 IBM Corp and others. + * + * All rights reserved. This program and the accompanying materials + * are made available under the terms of the Eclipse Public License v2.0 + * and Eclipse Distribution License v1.0 which accompany this distribution. + * + * The Eclipse Public License is available at + * https://www.eclipse.org/legal/epl-2.0/ + * and the Eclipse Distribution License is available at + * http://www.eclipse.org/org/documents/edl-v10.php. + * + * Contributors: + * Allan Stockdill-Mander + */ + +package packets + +import ( + "fmt" + "io" +) + +// PubackPacket is an internal representation of the fields of the +// Puback MQTT packet +type PubackPacket struct { + FixedHeader + MessageID uint16 +} + +func (pa *PubackPacket) String() string { + return fmt.Sprintf("%s MessageID: %d", pa.FixedHeader, pa.MessageID) +} + +func (pa *PubackPacket) Write(w io.Writer) error { + var err error + pa.FixedHeader.RemainingLength = 2 + packet := pa.FixedHeader.pack() + packet.Write(encodeUint16(pa.MessageID)) + _, err = packet.WriteTo(w) + + return err +} + +// Unpack decodes the details of a ControlPacket after the fixed +// header has been read +func (pa *PubackPacket) Unpack(b io.Reader) error { + var err error + pa.MessageID, err = decodeUint16(b) + + return err +} + +// Details returns a Details struct containing the Qos and +// MessageID of this ControlPacket +func (pa *PubackPacket) Details() Details { + return Details{Qos: pa.Qos, MessageID: pa.MessageID} +} diff --git a/mcp/dsx-exchange-mcp/vendor/github.com/eclipse/paho.mqtt.golang/packets/pubcomp.go b/mcp/dsx-exchange-mcp/vendor/github.com/eclipse/paho.mqtt.golang/packets/pubcomp.go new file mode 100644 index 0000000..84a1af5 --- /dev/null +++ b/mcp/dsx-exchange-mcp/vendor/github.com/eclipse/paho.mqtt.golang/packets/pubcomp.go @@ -0,0 +1,58 @@ +/* + * Copyright (c) 2021 IBM Corp and others. + * + * All rights reserved. This program and the accompanying materials + * are made available under the terms of the Eclipse Public License v2.0 + * and Eclipse Distribution License v1.0 which accompany this distribution. + * + * The Eclipse Public License is available at + * https://www.eclipse.org/legal/epl-2.0/ + * and the Eclipse Distribution License is available at + * http://www.eclipse.org/org/documents/edl-v10.php. + * + * Contributors: + * Allan Stockdill-Mander + */ + +package packets + +import ( + "fmt" + "io" +) + +// PubcompPacket is an internal representation of the fields of the +// Pubcomp MQTT packet +type PubcompPacket struct { + FixedHeader + MessageID uint16 +} + +func (pc *PubcompPacket) String() string { + return fmt.Sprintf("%s MessageID: %d", pc.FixedHeader, pc.MessageID) +} + +func (pc *PubcompPacket) Write(w io.Writer) error { + var err error + pc.FixedHeader.RemainingLength = 2 + packet := pc.FixedHeader.pack() + packet.Write(encodeUint16(pc.MessageID)) + _, err = packet.WriteTo(w) + + return err +} + +// Unpack decodes the details of a ControlPacket after the fixed +// header has been read +func (pc *PubcompPacket) Unpack(b io.Reader) error { + var err error + pc.MessageID, err = decodeUint16(b) + + return err +} + +// Details returns a Details struct containing the Qos and +// MessageID of this ControlPacket +func (pc *PubcompPacket) Details() Details { + return Details{Qos: pc.Qos, MessageID: pc.MessageID} +} diff --git a/mcp/dsx-exchange-mcp/vendor/github.com/eclipse/paho.mqtt.golang/packets/publish.go b/mcp/dsx-exchange-mcp/vendor/github.com/eclipse/paho.mqtt.golang/packets/publish.go new file mode 100644 index 0000000..9fba5df --- /dev/null +++ b/mcp/dsx-exchange-mcp/vendor/github.com/eclipse/paho.mqtt.golang/packets/publish.go @@ -0,0 +1,99 @@ +/* + * Copyright (c) 2021 IBM Corp and others. + * + * All rights reserved. This program and the accompanying materials + * are made available under the terms of the Eclipse Public License v2.0 + * and Eclipse Distribution License v1.0 which accompany this distribution. + * + * The Eclipse Public License is available at + * https://www.eclipse.org/legal/epl-2.0/ + * and the Eclipse Distribution License is available at + * http://www.eclipse.org/org/documents/edl-v10.php. + * + * Contributors: + * Allan Stockdill-Mander + */ + +package packets + +import ( + "bytes" + "fmt" + "io" +) + +// PublishPacket is an internal representation of the fields of the +// Publish MQTT packet +type PublishPacket struct { + FixedHeader + TopicName string + MessageID uint16 + Payload []byte +} + +func (p *PublishPacket) String() string { + return fmt.Sprintf("%s topicName: %s MessageID: %d payload: %s", p.FixedHeader, p.TopicName, p.MessageID, string(p.Payload)) +} + +func (p *PublishPacket) Write(w io.Writer) error { + var body bytes.Buffer + var err error + + body.Write(encodeString(p.TopicName)) + if p.Qos > 0 { + body.Write(encodeUint16(p.MessageID)) + } + p.FixedHeader.RemainingLength = body.Len() + len(p.Payload) + packet := p.FixedHeader.pack() + packet.Write(body.Bytes()) + packet.Write(p.Payload) + _, err = w.Write(packet.Bytes()) + + return err +} + +// Unpack decodes the details of a ControlPacket after the fixed +// header has been read +func (p *PublishPacket) Unpack(b io.Reader) error { + var payloadLength = p.FixedHeader.RemainingLength + var err error + p.TopicName, err = decodeString(b) + if err != nil { + return err + } + + if p.Qos > 0 { + p.MessageID, err = decodeUint16(b) + if err != nil { + return err + } + payloadLength -= len(p.TopicName) + 4 + } else { + payloadLength -= len(p.TopicName) + 2 + } + if payloadLength < 0 { + return fmt.Errorf("error unpacking publish, payload length < 0") + } + p.Payload = make([]byte, payloadLength) + _, err = b.Read(p.Payload) + + return err +} + +// Copy creates a new PublishPacket with the same topic and payload +// but an empty fixed header, useful for when you want to deliver +// a message with different properties such as Qos but the same +// content +func (p *PublishPacket) Copy() *PublishPacket { + newP := NewControlPacket(Publish).(*PublishPacket) + newP.TopicName = p.TopicName + newP.Payload = p.Payload + + return newP +} + +// Details returns a Details struct containing the Qos and +// MessageID of this ControlPacket +func (p *PublishPacket) Details() Details { + return Details{Qos: p.Qos, MessageID: p.MessageID} +} diff --git a/mcp/dsx-exchange-mcp/vendor/github.com/eclipse/paho.mqtt.golang/packets/pubrec.go b/mcp/dsx-exchange-mcp/vendor/github.com/eclipse/paho.mqtt.golang/packets/pubrec.go new file mode 100644 index 0000000..da9ed2a --- /dev/null +++ b/mcp/dsx-exchange-mcp/vendor/github.com/eclipse/paho.mqtt.golang/packets/pubrec.go @@ -0,0 +1,58 @@ +/* + * Copyright (c) 2021 IBM Corp and others. + * + * All rights reserved. This program and the accompanying materials + * are made available under the terms of the Eclipse Public License v2.0 + * and Eclipse Distribution License v1.0 which accompany this distribution. + * + * The Eclipse Public License is available at + * https://www.eclipse.org/legal/epl-2.0/ + * and the Eclipse Distribution License is available at + * http://www.eclipse.org/org/documents/edl-v10.php. + * + * Contributors: + * Allan Stockdill-Mander + */ + +package packets + +import ( + "fmt" + "io" +) + +// PubrecPacket is an internal representation of the fields of the +// Pubrec MQTT packet +type PubrecPacket struct { + FixedHeader + MessageID uint16 +} + +func (pr *PubrecPacket) String() string { + return fmt.Sprintf("%s MessageID: %d", pr.FixedHeader, pr.MessageID) +} + +func (pr *PubrecPacket) Write(w io.Writer) error { + var err error + pr.FixedHeader.RemainingLength = 2 + packet := pr.FixedHeader.pack() + packet.Write(encodeUint16(pr.MessageID)) + _, err = packet.WriteTo(w) + + return err +} + +// Unpack decodes the details of a ControlPacket after the fixed +// header has been read +func (pr *PubrecPacket) Unpack(b io.Reader) error { + var err error + pr.MessageID, err = decodeUint16(b) + + return err +} + +// Details returns a Details struct containing the Qos and +// MessageID of this ControlPacket +func (pr *PubrecPacket) Details() Details { + return Details{Qos: pr.Qos, MessageID: pr.MessageID} +} diff --git a/mcp/dsx-exchange-mcp/vendor/github.com/eclipse/paho.mqtt.golang/packets/pubrel.go b/mcp/dsx-exchange-mcp/vendor/github.com/eclipse/paho.mqtt.golang/packets/pubrel.go new file mode 100644 index 0000000..f418ff8 --- /dev/null +++ b/mcp/dsx-exchange-mcp/vendor/github.com/eclipse/paho.mqtt.golang/packets/pubrel.go @@ -0,0 +1,58 @@ +/* + * Copyright (c) 2021 IBM Corp and others. + * + * All rights reserved. This program and the accompanying materials + * are made available under the terms of the Eclipse Public License v2.0 + * and Eclipse Distribution License v1.0 which accompany this distribution. + * + * The Eclipse Public License is available at + * https://www.eclipse.org/legal/epl-2.0/ + * and the Eclipse Distribution License is available at + * http://www.eclipse.org/org/documents/edl-v10.php. + * + * Contributors: + * Allan Stockdill-Mander + */ + +package packets + +import ( + "fmt" + "io" +) + +// PubrelPacket is an internal representation of the fields of the +// Pubrel MQTT packet +type PubrelPacket struct { + FixedHeader + MessageID uint16 +} + +func (pr *PubrelPacket) String() string { + return fmt.Sprintf("%s MessageID: %d", pr.FixedHeader, pr.MessageID) +} + +func (pr *PubrelPacket) Write(w io.Writer) error { + var err error + pr.FixedHeader.RemainingLength = 2 + packet := pr.FixedHeader.pack() + packet.Write(encodeUint16(pr.MessageID)) + _, err = packet.WriteTo(w) + + return err +} + +// Unpack decodes the details of a ControlPacket after the fixed +// header has been read +func (pr *PubrelPacket) Unpack(b io.Reader) error { + var err error + pr.MessageID, err = decodeUint16(b) + + return err +} + +// Details returns a Details struct containing the Qos and +// MessageID of this ControlPacket +func (pr *PubrelPacket) Details() Details { + return Details{Qos: pr.Qos, MessageID: pr.MessageID} +} diff --git a/mcp/dsx-exchange-mcp/vendor/github.com/eclipse/paho.mqtt.golang/packets/suback.go b/mcp/dsx-exchange-mcp/vendor/github.com/eclipse/paho.mqtt.golang/packets/suback.go new file mode 100644 index 0000000..261cf21 --- /dev/null +++ b/mcp/dsx-exchange-mcp/vendor/github.com/eclipse/paho.mqtt.golang/packets/suback.go @@ -0,0 +1,73 @@ +/* + * Copyright (c) 2021 IBM Corp and others. + * + * All rights reserved. This program and the accompanying materials + * are made available under the terms of the Eclipse Public License v2.0 + * and Eclipse Distribution License v1.0 which accompany this distribution. + * + * The Eclipse Public License is available at + * https://www.eclipse.org/legal/epl-2.0/ + * and the Eclipse Distribution License is available at + * http://www.eclipse.org/org/documents/edl-v10.php. + * + * Contributors: + * Allan Stockdill-Mander + */ + +package packets + +import ( + "bytes" + "fmt" + "io" +) + +// SubackPacket is an internal representation of the fields of the +// Suback MQTT packet +type SubackPacket struct { + FixedHeader + MessageID uint16 + ReturnCodes []byte +} + +func (sa *SubackPacket) String() string { + return fmt.Sprintf("%s MessageID: %d", sa.FixedHeader, sa.MessageID) +} + +func (sa *SubackPacket) Write(w io.Writer) error { + var body bytes.Buffer + var err error + body.Write(encodeUint16(sa.MessageID)) + body.Write(sa.ReturnCodes) + sa.FixedHeader.RemainingLength = body.Len() + packet := sa.FixedHeader.pack() + packet.Write(body.Bytes()) + _, err = packet.WriteTo(w) + + return err +} + +// Unpack decodes the details of a ControlPacket after the fixed +// header has been read +func (sa *SubackPacket) Unpack(b io.Reader) error { + var qosBuffer bytes.Buffer + var err error + sa.MessageID, err = decodeUint16(b) + if err != nil { + return err + } + + _, err = qosBuffer.ReadFrom(b) + if err != nil { + return err + } + sa.ReturnCodes = qosBuffer.Bytes() + + return nil +} + +// Details returns a Details struct containing the Qos and +// MessageID of this ControlPacket +func (sa *SubackPacket) Details() Details { + return Details{Qos: 0, MessageID: sa.MessageID} +} diff --git a/mcp/dsx-exchange-mcp/vendor/github.com/eclipse/paho.mqtt.golang/packets/subscribe.go b/mcp/dsx-exchange-mcp/vendor/github.com/eclipse/paho.mqtt.golang/packets/subscribe.go new file mode 100644 index 0000000..313bf5a --- /dev/null +++ b/mcp/dsx-exchange-mcp/vendor/github.com/eclipse/paho.mqtt.golang/packets/subscribe.go @@ -0,0 +1,85 @@ +/* + * Copyright (c) 2021 IBM Corp and others. + * + * All rights reserved. This program and the accompanying materials + * are made available under the terms of the Eclipse Public License v2.0 + * and Eclipse Distribution License v1.0 which accompany this distribution. + * + * The Eclipse Public License is available at + * https://www.eclipse.org/legal/epl-2.0/ + * and the Eclipse Distribution License is available at + * http://www.eclipse.org/org/documents/edl-v10.php. + * + * Contributors: + * Allan Stockdill-Mander + */ + +package packets + +import ( + "bytes" + "fmt" + "io" +) + +// SubscribePacket is an internal representation of the fields of the +// Subscribe MQTT packet +type SubscribePacket struct { + FixedHeader + MessageID uint16 + Topics []string + Qoss []byte +} + +func (s *SubscribePacket) String() string { + return fmt.Sprintf("%s MessageID: %d topics: %s", s.FixedHeader, s.MessageID, s.Topics) +} + +func (s *SubscribePacket) Write(w io.Writer) error { + var body bytes.Buffer + var err error + + body.Write(encodeUint16(s.MessageID)) + for i, topic := range s.Topics { + body.Write(encodeString(topic)) + body.WriteByte(s.Qoss[i]) + } + s.FixedHeader.RemainingLength = body.Len() + packet := s.FixedHeader.pack() + packet.Write(body.Bytes()) + _, err = packet.WriteTo(w) + + return err +} + +// Unpack decodes the details of a ControlPacket after the fixed +// header has been read +func (s *SubscribePacket) Unpack(b io.Reader) error { + var err error + s.MessageID, err = decodeUint16(b) + if err != nil { + return err + } + payloadLength := s.FixedHeader.RemainingLength - 2 + for payloadLength > 0 { + topic, err := decodeString(b) + if err != nil { + return err + } + s.Topics = append(s.Topics, topic) + qos, err := decodeByte(b) + if err != nil { + return err + } + s.Qoss = append(s.Qoss, qos) + payloadLength -= 2 + len(topic) + 1 // 2 bytes of string length, plus string, plus 1 byte for Qos + } + + return nil +} + +// Details returns a Details struct containing the Qos and +// MessageID of this ControlPacket +func (s *SubscribePacket) Details() Details { + return Details{Qos: 1, MessageID: s.MessageID} +} diff --git a/mcp/dsx-exchange-mcp/vendor/github.com/eclipse/paho.mqtt.golang/packets/unsuback.go b/mcp/dsx-exchange-mcp/vendor/github.com/eclipse/paho.mqtt.golang/packets/unsuback.go new file mode 100644 index 0000000..acdd400 --- /dev/null +++ b/mcp/dsx-exchange-mcp/vendor/github.com/eclipse/paho.mqtt.golang/packets/unsuback.go @@ -0,0 +1,58 @@ +/* + * Copyright (c) 2021 IBM Corp and others. + * + * All rights reserved. This program and the accompanying materials + * are made available under the terms of the Eclipse Public License v2.0 + * and Eclipse Distribution License v1.0 which accompany this distribution. + * + * The Eclipse Public License is available at + * https://www.eclipse.org/legal/epl-2.0/ + * and the Eclipse Distribution License is available at + * http://www.eclipse.org/org/documents/edl-v10.php. + * + * Contributors: + * Allan Stockdill-Mander + */ + +package packets + +import ( + "fmt" + "io" +) + +// UnsubackPacket is an internal representation of the fields of the +// Unsuback MQTT packet +type UnsubackPacket struct { + FixedHeader + MessageID uint16 +} + +func (ua *UnsubackPacket) String() string { + return fmt.Sprintf("%s MessageID: %d", ua.FixedHeader, ua.MessageID) +} + +func (ua *UnsubackPacket) Write(w io.Writer) error { + var err error + ua.FixedHeader.RemainingLength = 2 + packet := ua.FixedHeader.pack() + packet.Write(encodeUint16(ua.MessageID)) + _, err = packet.WriteTo(w) + + return err +} + +// Unpack decodes the details of a ControlPacket after the fixed +// header has been read +func (ua *UnsubackPacket) Unpack(b io.Reader) error { + var err error + ua.MessageID, err = decodeUint16(b) + + return err +} + +// Details returns a Details struct containing the Qos and +// MessageID of this ControlPacket +func (ua *UnsubackPacket) Details() Details { + return Details{Qos: 0, MessageID: ua.MessageID} +} diff --git a/mcp/dsx-exchange-mcp/vendor/github.com/eclipse/paho.mqtt.golang/packets/unsubscribe.go b/mcp/dsx-exchange-mcp/vendor/github.com/eclipse/paho.mqtt.golang/packets/unsubscribe.go new file mode 100644 index 0000000..54d06aa --- /dev/null +++ b/mcp/dsx-exchange-mcp/vendor/github.com/eclipse/paho.mqtt.golang/packets/unsubscribe.go @@ -0,0 +1,72 @@ +/* + * Copyright (c) 2021 IBM Corp and others. + * + * All rights reserved. This program and the accompanying materials + * are made available under the terms of the Eclipse Public License v2.0 + * and Eclipse Distribution License v1.0 which accompany this distribution. + * + * The Eclipse Public License is available at + * https://www.eclipse.org/legal/epl-2.0/ + * and the Eclipse Distribution License is available at + * http://www.eclipse.org/org/documents/edl-v10.php. + * + * Contributors: + * Allan Stockdill-Mander + */ + +package packets + +import ( + "bytes" + "fmt" + "io" +) + +// UnsubscribePacket is an internal representation of the fields of the +// Unsubscribe MQTT packet +type UnsubscribePacket struct { + FixedHeader + MessageID uint16 + Topics []string +} + +func (u *UnsubscribePacket) String() string { + return fmt.Sprintf("%s MessageID: %d", u.FixedHeader, u.MessageID) +} + +func (u *UnsubscribePacket) Write(w io.Writer) error { + var body bytes.Buffer + var err error + body.Write(encodeUint16(u.MessageID)) + for _, topic := range u.Topics { + body.Write(encodeString(topic)) + } + u.FixedHeader.RemainingLength = body.Len() + packet := u.FixedHeader.pack() + packet.Write(body.Bytes()) + _, err = packet.WriteTo(w) + + return err +} + +// Unpack decodes the details of a ControlPacket after the fixed +// header has been read +func (u *UnsubscribePacket) Unpack(b io.Reader) error { + var err error + u.MessageID, err = decodeUint16(b) + if err != nil { + return err + } + + for topic, err := decodeString(b); err == nil && topic != ""; topic, err = decodeString(b) { + u.Topics = append(u.Topics, topic) + } + + return err +} + +// Details returns a Details struct containing the Qos and +// MessageID of this ControlPacket +func (u *UnsubscribePacket) Details() Details { + return Details{Qos: 1, MessageID: u.MessageID} +} diff --git a/mcp/dsx-exchange-mcp/vendor/github.com/eclipse/paho.mqtt.golang/ping.go b/mcp/dsx-exchange-mcp/vendor/github.com/eclipse/paho.mqtt.golang/ping.go new file mode 100644 index 0000000..48fe91a --- /dev/null +++ b/mcp/dsx-exchange-mcp/vendor/github.com/eclipse/paho.mqtt.golang/ping.go @@ -0,0 +1,78 @@ +/* + * Copyright (c) 2021 IBM Corp and others. + * + * All rights reserved. This program and the accompanying materials + * are made available under the terms of the Eclipse Public License v2.0 + * and Eclipse Distribution License v1.0 which accompany this distribution. + * + * The Eclipse Public License is available at + * https://www.eclipse.org/legal/epl-2.0/ + * and the Eclipse Distribution License is available at + * http://www.eclipse.org/org/documents/edl-v10.php. + * + * Contributors: + * Seth Hoenig + * Allan Stockdill-Mander + * Mike Robertson + */ + +package mqtt + +import ( + "errors" + "io" + "sync/atomic" + "time" + + "github.com/eclipse/paho.mqtt.golang/packets" +) + +// keepalive - Send ping when connection unused for set period +// connection passed in to avoid race condition on shutdown +func keepalive(c *client, conn io.Writer) { + defer c.workers.Done() + DEBUG.Println(PNG, "keepalive starting") + var checkInterval time.Duration + var pingSent time.Time + + if c.options.KeepAlive > 10 { + checkInterval = 5 * time.Second + } else { + checkInterval = time.Duration(c.options.KeepAlive) * time.Second / 4 + } + + intervalTicker := time.NewTicker(checkInterval) + defer intervalTicker.Stop() + + for { + select { + case <-c.stop: + DEBUG.Println(PNG, "keepalive stopped") + return + case <-intervalTicker.C: + lastSent := c.lastSent.Load().(time.Time) + lastReceived := c.lastReceived.Load().(time.Time) + + DEBUG.Println(PNG, "ping check", time.Since(lastSent).Seconds()) + if time.Since(lastSent) >= time.Duration(c.options.KeepAlive*int64(time.Second)) || time.Since(lastReceived) >= time.Duration(c.options.KeepAlive*int64(time.Second)) { + if atomic.LoadInt32(&c.pingOutstanding) == 0 { + DEBUG.Println(PNG, "keepalive sending ping") + ping := packets.NewControlPacket(packets.Pingreq).(*packets.PingreqPacket) + // We don't want to wait behind large messages being sent, the `Write` call + // will block until it is able to send the packet. + atomic.StoreInt32(&c.pingOutstanding, 1) + if err := ping.Write(conn); err != nil { + ERROR.Println(PNG, err) + } + c.lastSent.Store(time.Now()) + pingSent = time.Now() + } + } + if atomic.LoadInt32(&c.pingOutstanding) > 0 && time.Since(pingSent) >= c.options.PingTimeout { + CRITICAL.Println(PNG, "pingresp not received, disconnecting") + c.internalConnLost(errors.New("pingresp not received, disconnecting")) // no harm in calling this if the connection is already down (or shutdown is in progress) + return + } + } + } +} diff --git a/mcp/dsx-exchange-mcp/vendor/github.com/eclipse/paho.mqtt.golang/router.go b/mcp/dsx-exchange-mcp/vendor/github.com/eclipse/paho.mqtt.golang/router.go new file mode 100644 index 0000000..5cfc5e6 --- /dev/null +++ b/mcp/dsx-exchange-mcp/vendor/github.com/eclipse/paho.mqtt.golang/router.go @@ -0,0 +1,214 @@ +/* + * Copyright (c) 2021 IBM Corp and others. + * + * All rights reserved. This program and the accompanying materials + * are made available under the terms of the Eclipse Public License v2.0 + * and Eclipse Distribution License v1.0 which accompany this distribution. + * + * The Eclipse Public License is available at + * https://www.eclipse.org/legal/epl-2.0/ + * and the Eclipse Distribution License is available at + * http://www.eclipse.org/org/documents/edl-v10.php. + * + * Contributors: + * Seth Hoenig + * Allan Stockdill-Mander + * Mike Robertson + */ + +package mqtt + +import ( + "container/list" + "strings" + "sync" + + "github.com/eclipse/paho.mqtt.golang/packets" +) + +// route is a type which associates MQTT Topic strings with a +// callback to be executed upon the arrival of a message associated +// with a subscription to that topic. +type route struct { + topic string + callback MessageHandler +} + +// match takes a slice of strings which represent the route being tested having been split on '/' +// separators, and a slice of strings representing the topic string in the published message, similarly +// split. +// The function determines if the topic string matches the route according to the MQTT topic rules +// and returns a boolean of the outcome +func match(route []string, topic []string) bool { + if len(route) == 0 { + return len(topic) == 0 + } + + if len(topic) == 0 { + return route[0] == "#" + } + + if route[0] == "#" { + return true + } + + if (route[0] == "+") || (route[0] == topic[0]) { + return match(route[1:], topic[1:]) + } + return false +} + +func routeIncludesTopic(route, topic string) bool { + return match(routeSplit(route), strings.Split(topic, "/")) +} + +// removes $share and sharename when splitting the route to allow +// shared subscription routes to correctly match the topic +func routeSplit(route string) []string { + var result []string + if strings.HasPrefix(route, "$share") { + result = strings.Split(route, "/")[2:] + } else { + result = strings.Split(route, "/") + } + return result +} + +// match takes the topic string of the published message and does a basic compare to the +// string of the current Route, if they match it returns true +func (r *route) match(topic string) bool { + return r.topic == topic || routeIncludesTopic(r.topic, topic) +} + +type router struct { + sync.RWMutex + routes *list.List + defaultHandler MessageHandler + messages chan *packets.PublishPacket +} + +// newRouter returns a new instance of a Router and channel which can be used to tell the Router +// to stop +func newRouter() *router { + router := &router{routes: list.New(), messages: make(chan *packets.PublishPacket)} + return router +} + +// addRoute takes a topic string and MessageHandler callback. It looks in the current list of +// routes to see if there is already a matching Route. If there is it replaces the current +// callback with the new one. If not it add a new entry to the list of Routes. +func (r *router) addRoute(topic string, callback MessageHandler) { + r.Lock() + defer r.Unlock() + for e := r.routes.Front(); e != nil; e = e.Next() { + if e.Value.(*route).topic == topic { + r := e.Value.(*route) + r.callback = callback + return + } + } + r.routes.PushBack(&route{topic: topic, callback: callback}) +} + +// deleteRoute takes a route string, looks for a matching Route in the list of Routes. If +// found it removes the Route from the list. +func (r *router) deleteRoute(topic string) { + r.Lock() + defer r.Unlock() + for e := r.routes.Front(); e != nil; e = e.Next() { + if e.Value.(*route).topic == topic { + r.routes.Remove(e) + return + } + } +} + +// setDefaultHandler assigns a default callback that will be called if no matching Route +// is found for an incoming Publish. +func (r *router) setDefaultHandler(handler MessageHandler) { + r.Lock() + defer r.Unlock() + r.defaultHandler = handler +} + +// matchAndDispatch takes a channel of Message pointers as input and starts a go routine that +// takes messages off the channel, matches them against the internal route list and calls the +// associated callback (or the defaultHandler, if one exists and no other route matched). If +// anything is sent down the stop channel the function will end. +func (r *router) matchAndDispatch(messages <-chan *packets.PublishPacket, order bool, client *client) <-chan *PacketAndToken { + ackChan := make(chan *PacketAndToken) // Channel returned to caller; closed when goroutine terminates + + // In some cases message acknowledgments may come through after shutdown (connection is down etc). Where this is the + // case we need to accept any such requests and then ignore them. Note that this is not a perfect solution, if we + // have reconnected, and the session is still live, then the Ack really should be sent (see Issus #726) + var ackMutex sync.RWMutex + sendAckChan := ackChan // This will be set to nil before ackChan is closed + sendAck := func(ack *PacketAndToken) { + ackMutex.RLock() + defer ackMutex.RUnlock() + if sendAckChan != nil { + sendAckChan <- ack + } else { + DEBUG.Println(ROU, "matchAndDispatch received acknowledgment after processing stopped (ACK dropped).") + } + } + + go func() { // Main go routine handling inbound messages + var handlers []MessageHandler + for message := range messages { + // DEBUG.Println(ROU, "matchAndDispatch received message") + sent := false + r.RLock() + m := messageFromPublish(message, ackFunc(sendAck, client.persist, message)) + for e := r.routes.Front(); e != nil; e = e.Next() { + if e.Value.(*route).match(message.TopicName) { + if order { + handlers = append(handlers, e.Value.(*route).callback) + } else { + hd := e.Value.(*route).callback + go func() { + hd(client, m) + if !client.options.AutoAckDisabled { + m.Ack() + } + }() + } + sent = true + } + } + if !sent { + if r.defaultHandler != nil { + if order { + handlers = append(handlers, r.defaultHandler) + } else { + go func() { + r.defaultHandler(client, m) + if !client.options.AutoAckDisabled { + m.Ack() + } + }() + } + } else { + DEBUG.Println(ROU, "matchAndDispatch received message and no handler was available. Message will NOT be acknowledged.") + } + } + r.RUnlock() + if order { + for _, handler := range handlers { + handler(client, m) + if !client.options.AutoAckDisabled { + m.Ack() + } + } + handlers = handlers[:0] + } + // DEBUG.Println(ROU, "matchAndDispatch handled message") + } + ackMutex.Lock() + sendAckChan = nil + ackMutex.Unlock() + close(ackChan) // as sendAckChan is now nil nothing further will be sent on this + DEBUG.Println(ROU, "matchAndDispatch exiting") + }() + return ackChan +} diff --git a/mcp/dsx-exchange-mcp/vendor/github.com/eclipse/paho.mqtt.golang/status.go b/mcp/dsx-exchange-mcp/vendor/github.com/eclipse/paho.mqtt.golang/status.go new file mode 100644 index 0000000..d25fbf5 --- /dev/null +++ b/mcp/dsx-exchange-mcp/vendor/github.com/eclipse/paho.mqtt.golang/status.go @@ -0,0 +1,296 @@ +/* + * Copyright (c) 2021 IBM Corp and others. + * + * All rights reserved. This program and the accompanying materials + * are made available under the terms of the Eclipse Public License v2.0 + * and Eclipse Distribution License v1.0 which accompany this distribution. + * + * The Eclipse Public License is available at + * https://www.eclipse.org/legal/epl-2.0/ + * and the Eclipse Distribution License is available at + * http://www.eclipse.org/org/documents/edl-v10.php. + * + * Contributors: + * Seth Hoenig + * Allan Stockdill-Mander + * Mike Robertson + * Matt Brittan + */ + +package mqtt + +import ( + "errors" + "sync" +) + +// Status - Manage the connection status + +// Multiple go routines will want to access/set this. Previously status was implemented as a `uint32` and updated +// with a mixture of atomic functions and a mutex (leading to some deadlock type issues that were very hard to debug). + +// In this new implementation `connectionStatus` takes over managing the state and provides functions that allow the +// client to request a move to a particular state (it may reject these requests!). In some cases the 'state' is +// transitory, for example `connecting`, in those cases a function will be returned that allows the client to move +// to a more static state (`disconnected` or `connected`). + +// This "belts-and-braces" may be a little over the top but issues with the status have caused a number of difficult +// to trace bugs in the past and the likelihood that introducing a new system would introduce bugs seemed high! +// I have written this in a way that should make it very difficult to misuse it (but it does make things a little +// complex with functions returning functions that return functions!). + +type status uint32 + +const ( + disconnected status = iota // default (nil) status is disconnected + disconnecting // Transitioning from one of the below states back to disconnected + connecting + reconnecting + connected +) + +// String simplify output of statuses +func (s status) String() string { + switch s { + case disconnected: + return "disconnected" + case disconnecting: + return "disconnecting" + case connecting: + return "connecting" + case reconnecting: + return "reconnecting" + case connected: + return "connected" + default: + return "invalid" + } +} + +type connCompletedFn func(success bool) error +type disconnectCompletedFn func() +type connectionLostHandledFn func(bool) (connCompletedFn, error) + +/* State transitions + +static states are `disconnected` and `connected`. For all other states a process will hold a function that will move +the state to one of those. That function effectively owns the state and any other changes must not proceed until it +completes. One exception to that is that the state can always be moved to `disconnecting` which provides a signal that +transitions to `connected` will be rejected (this is required because a Disconnect can be requested while in the +Connecting state). + +# Basic Operations + +The standard workflows are: + +disconnected -> `Connecting()` -> connecting -> `connCompletedFn(true)` -> connected +connected -> `Disconnecting()` -> disconnecting -> `disconnectCompletedFn()` -> disconnected +connected -> `ConnectionLost(false)` -> disconnecting -> `connectionLostHandledFn(true/false)` -> disconnected +connected -> `ConnectionLost(true)` -> disconnecting -> `connectionLostHandledFn(true)` -> connected + +Unfortunately the above workflows are complicated by the fact that `Disconnecting()` or `ConnectionLost()` may, +potentially, be called at any time (i.e. whilst in the middle of transitioning between states). If this happens: + +* The state will be set to disconnecting (which will prevent any request to move the status to connected) +* The call to `Disconnecting()`/`ConnectionLost()` will block until the previously active call completes and then + handle the disconnection. + +Reading the tests (unit_status_test.go) might help understand these rules. +*/ + +var ( + errAbortConnection = errors.New("disconnect called whist connection attempt in progress") + errAlreadyConnectedOrReconnecting = errors.New("status is already connected or reconnecting") + errStatusMustBeDisconnected = errors.New("status can only transition to connecting from disconnected") + errAlreadyDisconnected = errors.New("status is already disconnected") + errDisconnectionRequested = errors.New("disconnection was requested whilst the action was in progress") + errDisconnectionInProgress = errors.New("disconnection already in progress") + errAlreadyHandlingConnectionLoss = errors.New("status is already Connection Lost") + errConnLossWhileDisconnecting = errors.New("connection status is disconnecting so loss of connection is expected") +) + +// connectionStatus encapsulates, and protects, the connection status. +type connectionStatus struct { + sync.RWMutex // Protects the variables below + status status + willReconnect bool // only used when status == disconnecting. Indicates that an attempt will be made to reconnect (allows us to abort that) + + // Some statuses are transitional (e.g. connecting, connectionLost, reconnecting, disconnecting), that is, whatever + // process moves us into that status will move us out of it when an action is complete. Sometimes other users + // will need to know when the action is complete (e.g. the user calls `Disconnect()` whilst the status is + // `connecting`). `actionCompleted` will be set whenever we move into one of the above statues and the channel + // returned to anything else requesting a status change. The channel will be closed when the operation is complete. + actionCompleted chan struct{} // Only valid whilst status is Connecting or Reconnecting; will be closed when connection completed (success or failure) +} + +// ConnectionStatus returns the connection status. +// WARNING: the status may change at any time so users should not assume they are the only goroutine touching this +func (c *connectionStatus) ConnectionStatus() status { + c.RLock() + defer c.RUnlock() + return c.status +} + +// ConnectionStatusRetry returns the connection status and retry flag (indicates that we expect to reconnect). +// WARNING: the status may change at any time so users should not assume they are the only goroutine touching this +func (c *connectionStatus) ConnectionStatusRetry() (status, bool) { + c.RLock() + defer c.RUnlock() + return c.status, c.willReconnect +} + +// Connecting - Changes the status to connecting if that is a permitted operation +// Will do nothing unless the current status is disconnected +// Returns a function that MUST be called when the operation is complete (pass in true if successful) +func (c *connectionStatus) Connecting() (connCompletedFn, error) { + c.Lock() + defer c.Unlock() + // Calling Connect when already connecting (or if reconnecting) may not always be considered an error + if c.status == connected || c.status == reconnecting { + return nil, errAlreadyConnectedOrReconnecting + } + if c.status != disconnected { + return nil, errStatusMustBeDisconnected + } + c.status = connecting + c.actionCompleted = make(chan struct{}) + return c.connected, nil +} + +// connected is an internal function (it is returned by functions that set the status to connecting or reconnecting, +// calling it completes the operation). `success` is used to indicate whether the operation was successfully completed. +func (c *connectionStatus) connected(success bool) error { + c.Lock() + defer func() { + close(c.actionCompleted) // Alert anything waiting on the connection process to complete + c.actionCompleted = nil // Be tidy + c.Unlock() + }() + + // Status may have moved to disconnecting in the interim (i.e. at users request) + if c.status == disconnecting { + return errAbortConnection + } + if success { + c.status = connected + } else { + c.status = disconnected + } + return nil +} + +// Disconnecting - should be called when beginning the disconnection process (cleanup etc.). +// Can be called from ANY status and the end result will always be a status of disconnected +// Note that if a connection/reconnection attempt is in progress this function will set the status to `disconnecting` +// then block until the connection process completes (or aborts). +// Returns a function that MUST be called when the operation is complete (assumed to always be successful!) +func (c *connectionStatus) Disconnecting() (disconnectCompletedFn, error) { + c.Lock() + if c.status == disconnected { + c.Unlock() + return nil, errAlreadyDisconnected // May not always be treated as an error + } + if c.status == disconnecting { // Need to wait for existing process to complete + c.willReconnect = false // Ensure that the existing disconnect process will not reconnect + disConnectDone := c.actionCompleted + c.Unlock() + <-disConnectDone // Wait for existing operation to complete + return nil, errAlreadyDisconnected // Well we are now! + } + + prevStatus := c.status + c.status = disconnecting + + // We may need to wait for connection/reconnection process to complete (they should regularly check the status) + if prevStatus == connecting || prevStatus == reconnecting { + connectDone := c.actionCompleted + c.Unlock() // Safe because the only way to leave the disconnecting status is via this function + <-connectDone + + if prevStatus == reconnecting && !c.willReconnect { + return nil, errAlreadyDisconnected // Following connectionLost process we will be disconnected + } + c.Lock() + } + c.actionCompleted = make(chan struct{}) + c.Unlock() + return c.disconnectionCompleted, nil +} + +// disconnectionCompleted is an internal function (it is returned by functions that set the status to disconnecting) +func (c *connectionStatus) disconnectionCompleted() { + c.Lock() + defer c.Unlock() + c.status = disconnected + close(c.actionCompleted) // Alert anything waiting on the connection process to complete + c.actionCompleted = nil +} + +// ConnectionLost - should be called when the connection is lost. +// This really only differs from Disconnecting in that we may transition into a reconnection (but that could be +// cancelled something else calls Disconnecting in the meantime). +// The returned function should be called when cleanup is completed. It will return a function to be called when +// reconnect completes (or nil if no reconnect requested/disconnect called in the interim). +// Note: This function may block if a connection is in progress (the move to connected will be rejected) +func (c *connectionStatus) ConnectionLost(willReconnect bool) (connectionLostHandledFn, error) { + c.Lock() + defer c.Unlock() + if c.status == disconnected { + return nil, errAlreadyDisconnected + } + if c.status == disconnecting { // its expected that connection lost will be called during the disconnection process + return nil, errDisconnectionInProgress + } + + c.willReconnect = willReconnect + prevStatus := c.status + c.status = disconnecting + + // There is a slight possibility that a connection attempt is in progress (connection up and goroutines started but + // status not yet changed). By changing the status we ensure that process will exit cleanly + if prevStatus == connecting || prevStatus == reconnecting { + connectDone := c.actionCompleted + c.Unlock() // Safe because the only way to leave the disconnecting status is via this function + <-connectDone + c.Lock() + if !willReconnect { + // In this case the connection will always be aborted so there is nothing more for us to do + return nil, errAlreadyDisconnected + } + } + c.actionCompleted = make(chan struct{}) + + return c.getConnectionLostHandler(willReconnect), nil +} + +// getConnectionLostHandler is an internal function. It returns the function to be returned by ConnectionLost +func (c *connectionStatus) getConnectionLostHandler(reconnectRequested bool) connectionLostHandledFn { + return func(proceed bool) (connCompletedFn, error) { + // Note that connCompletedFn will only be provided if both reconnectRequested and proceed are true + c.Lock() + defer c.Unlock() + + // `Disconnecting()` may have been called while the disconnection was being processed (this makes it permanent!) + if !c.willReconnect || !proceed { + c.status = disconnected + close(c.actionCompleted) // Alert anything waiting on the connection process to complete + c.actionCompleted = nil + if !reconnectRequested || !proceed { + return nil, nil + } + return nil, errDisconnectionRequested + } + + c.status = reconnecting + return c.connected, nil // Note that c.actionCompleted is still live and will be closed in connected + } +} + +// forceConnectionStatus - forces the connection status to the specified value. +// This should only be used when there is no alternative (i.e. only in tests and to recover from situations that +// are unexpected) +func (c *connectionStatus) forceConnectionStatus(s status) { + c.Lock() + defer c.Unlock() + c.status = s +} diff --git a/mcp/dsx-exchange-mcp/vendor/github.com/eclipse/paho.mqtt.golang/store.go b/mcp/dsx-exchange-mcp/vendor/github.com/eclipse/paho.mqtt.golang/store.go new file mode 100644 index 0000000..f50873c --- /dev/null +++ b/mcp/dsx-exchange-mcp/vendor/github.com/eclipse/paho.mqtt.golang/store.go @@ -0,0 +1,140 @@ +/* + * Copyright (c) 2021 IBM Corp and others. + * + * All rights reserved. This program and the accompanying materials + * are made available under the terms of the Eclipse Public License v2.0 + * and Eclipse Distribution License v1.0 which accompany this distribution. + * + * The Eclipse Public License is available at + * https://www.eclipse.org/legal/epl-2.0/ + * and the Eclipse Distribution License is available at + * http://www.eclipse.org/org/documents/edl-v10.php. + * + * Contributors: + * Seth Hoenig + * Allan Stockdill-Mander + * Mike Robertson + */ + +package mqtt + +import ( + "fmt" + "strconv" + + "github.com/eclipse/paho.mqtt.golang/packets" +) + +const ( + inboundPrefix = "i." + outboundPrefix = "o." +) + +// Store is an interface which can be used to provide implementations +// for message persistence. +// Because we may have to store distinct messages with the same +// message ID, we need a unique key for each message. This is +// possible by prepending "i." or "o." to each message id +type Store interface { + Open() + Put(key string, message packets.ControlPacket) + Get(key string) packets.ControlPacket + All() []string + Del(key string) + Close() + Reset() +} + +// A key MUST have the form "X.[messageid]" +// where X is 'i' or 'o' +func mIDFromKey(key string) uint16 { + s := key[2:] + i, err := strconv.ParseUint(s, 10, 16) + chkerr(err) + return uint16(i) +} + +// Return true if key prefix is outbound +func isKeyOutbound(key string) bool { + return key[:2] == outboundPrefix +} + +// Return true if key prefix is inbound +func isKeyInbound(key string) bool { + return key[:2] == inboundPrefix +} + +// Return a string of the form "i.[id]" +func inboundKeyFromMID(id uint16) string { + return fmt.Sprintf("%s%d", inboundPrefix, id) +} + +// Return a string of the form "o.[id]" +func outboundKeyFromMID(id uint16) string { + return fmt.Sprintf("%s%d", outboundPrefix, id) +} + +// govern which outgoing messages are persisted +func persistOutbound(s Store, m packets.ControlPacket) { + switch m.Details().Qos { + case 0: + switch m.(type) { + case *packets.PubackPacket, *packets.PubcompPacket: + // Sending puback. delete matching publish + // from ibound + s.Del(inboundKeyFromMID(m.Details().MessageID)) + } + case 1: + switch m.(type) { + case *packets.PublishPacket, *packets.PubrelPacket, *packets.SubscribePacket, *packets.UnsubscribePacket: + // Sending publish. store in obound + // until puback received + s.Put(outboundKeyFromMID(m.Details().MessageID), m) + default: + ERROR.Println(STR, "Asked to persist an invalid message type") + } + case 2: + switch m.(type) { + case *packets.PublishPacket: + // Sending publish. store in obound + // until pubrel received + s.Put(outboundKeyFromMID(m.Details().MessageID), m) + default: + ERROR.Println(STR, "Asked to persist an invalid message type") + } + } +} + +// govern which incoming messages are persisted +func persistInbound(s Store, m packets.ControlPacket) { + switch m.Details().Qos { + case 0: + switch m.(type) { + case *packets.PubackPacket, *packets.SubackPacket, *packets.UnsubackPacket, *packets.PubcompPacket: + // Received a puback. delete matching publish + // from obound + s.Del(outboundKeyFromMID(m.Details().MessageID)) + case *packets.PublishPacket, *packets.PubrecPacket, *packets.PingrespPacket, *packets.ConnackPacket: + default: + ERROR.Println(STR, "Asked to persist an invalid messages type") + } + case 1: + switch m.(type) { + case *packets.PublishPacket, *packets.PubrelPacket: + // Received a publish. store it in ibound + // until puback sent + s.Put(inboundKeyFromMID(m.Details().MessageID), m) + default: + ERROR.Println(STR, "Asked to persist an invalid messages type") + } + case 2: + switch m.(type) { + case *packets.PublishPacket: + // Received a publish. store it in ibound + // until pubrel received + s.Put(inboundKeyFromMID(m.Details().MessageID), m) + default: + ERROR.Println(STR, "Asked to persist an invalid messages type") + } + } +} diff --git a/mcp/dsx-exchange-mcp/vendor/github.com/eclipse/paho.mqtt.golang/token.go b/mcp/dsx-exchange-mcp/vendor/github.com/eclipse/paho.mqtt.golang/token.go new file mode 100644 index 0000000..9eb122e --- /dev/null +++ b/mcp/dsx-exchange-mcp/vendor/github.com/eclipse/paho.mqtt.golang/token.go @@ -0,0 +1,222 @@ +/* + * Copyright (c) 2021 IBM Corp and others. + * + * All rights reserved. This program and the accompanying materials + * are made available under the terms of the Eclipse Public License v2.0 + * and Eclipse Distribution License v1.0 which accompany this distribution. + * + * The Eclipse Public License is available at + * https://www.eclipse.org/legal/epl-2.0/ + * and the Eclipse Distribution License is available at + * http://www.eclipse.org/org/documents/edl-v10.php. + * + * Contributors: + * Allan Stockdill-Mander + */ + +package mqtt + +import ( + "errors" + "sync" + "time" + + "github.com/eclipse/paho.mqtt.golang/packets" +) + +// PacketAndToken is a struct that contains both a ControlPacket and a +// Token. This struct is passed via channels between the client interface +// code and the underlying code responsible for sending and receiving +// MQTT messages. +type PacketAndToken struct { + p packets.ControlPacket + t tokenCompletor +} + +// Token defines the interface for the tokens used to indicate when +// actions have completed. +type Token interface { + // Wait will wait indefinitely for the Token to complete, ie the Publish + // to be sent and confirmed receipt from the broker. + Wait() bool + + // WaitTimeout takes a time.Duration to wait for the flow associated with the + // Token to complete, returns true if it returned before the timeout or + // returns false if the timeout occurred. In the case of a timeout the Token + // does not have an error set in case the caller wishes to wait again. + WaitTimeout(time.Duration) bool + + // Done returns a channel that is closed when the flow associated + // with the Token completes. Clients should call Error after the + // channel is closed to check if the flow completed successfully. + // + // Done is provided for use in select statements. Simple use cases may + // use Wait or WaitTimeout. + Done() <-chan struct{} + + Error() error +} + +type TokenErrorSetter interface { + setError(error) +} + +type tokenCompletor interface { + Token + TokenErrorSetter + flowComplete() +} + +type baseToken struct { + m sync.RWMutex + complete chan struct{} + err error +} + +// Wait implements the Token Wait method. +func (b *baseToken) Wait() bool { + <-b.complete + return true +} + +// WaitTimeout implements the Token WaitTimeout method. +func (b *baseToken) WaitTimeout(d time.Duration) bool { + timer := time.NewTimer(d) + select { + case <-b.complete: + if !timer.Stop() { + <-timer.C + } + return true + case <-timer.C: + } + + return false +} + +// Done implements the Token Done method. +func (b *baseToken) Done() <-chan struct{} { + return b.complete +} + +func (b *baseToken) flowComplete() { + select { + case <-b.complete: + default: + close(b.complete) + } +} + +func (b *baseToken) Error() error { + b.m.RLock() + defer b.m.RUnlock() + return b.err +} + +func (b *baseToken) setError(e error) { + b.m.Lock() + b.err = e + b.flowComplete() + b.m.Unlock() +} + +func newToken(tType byte) tokenCompletor { + switch tType { + case packets.Connect: + return &ConnectToken{baseToken: baseToken{complete: make(chan struct{})}} + case packets.Subscribe: + return &SubscribeToken{baseToken: baseToken{complete: make(chan struct{})}, subResult: make(map[string]byte)} + case packets.Publish: + return &PublishToken{baseToken: baseToken{complete: make(chan struct{})}} + case packets.Unsubscribe: + return &UnsubscribeToken{baseToken: baseToken{complete: make(chan struct{})}} + case packets.Disconnect: + return &DisconnectToken{baseToken: baseToken{complete: make(chan struct{})}} + } + return nil +} + +// ConnectToken is an extension of Token containing the extra fields +// required to provide information about calls to Connect() +type ConnectToken struct { + baseToken + returnCode byte + sessionPresent bool +} + +// ReturnCode returns the acknowledgement code in the connack sent +// in response to a Connect() +func (c *ConnectToken) ReturnCode() byte { + c.m.RLock() + defer c.m.RUnlock() + return c.returnCode +} + +// SessionPresent returns a bool representing the value of the +// session present field in the connack sent in response to a Connect() +func (c *ConnectToken) SessionPresent() bool { + c.m.RLock() + defer c.m.RUnlock() + return c.sessionPresent +} + +// PublishToken is an extension of Token containing the extra fields +// required to provide information about calls to Publish() +type PublishToken struct { + baseToken + messageID uint16 +} + +// MessageID returns the MQTT message ID that was assigned to the +// Publish packet when it was sent to the broker +func (p *PublishToken) MessageID() uint16 { + return p.messageID +} + +// SubscribeToken is an extension of Token containing the extra fields +// required to provide information about calls to Subscribe() +type SubscribeToken struct { + baseToken + subs []string + subResult map[string]byte + messageID uint16 +} + +// Result returns a map of topics that were subscribed to along with +// the matching return code from the broker. This is either the Qos +// value of the subscription or an error code. +func (s *SubscribeToken) Result() map[string]byte { + s.m.RLock() + defer s.m.RUnlock() + return s.subResult +} + +// UnsubscribeToken is an extension of Token containing the extra fields +// required to provide information about calls to Unsubscribe() +type UnsubscribeToken struct { + baseToken + messageID uint16 +} + +// DisconnectToken is an extension of Token containing the extra fields +// required to provide information about calls to Disconnect() +type DisconnectToken struct { + baseToken +} + +// TimedOut is the error returned by WaitTimeout when the timeout expires +var TimedOut = errors.New("context canceled") + +// WaitTokenTimeout is a utility function used to simplify the use of token.WaitTimeout +// token.WaitTimeout may return `false` due to time out but t.Error() still results +// in nil. +// `if t := client.X(); t.WaitTimeout(time.Second) && t.Error() != nil {` may evaluate +// to false even if the operation fails. +// It is important to note that if TimedOut is returned, then the operation may still be running +// and could eventually complete successfully. +func WaitTokenTimeout(t Token, d time.Duration) error { + if !t.WaitTimeout(d) { + return TimedOut + } + return t.Error() +} diff --git a/mcp/dsx-exchange-mcp/vendor/github.com/eclipse/paho.mqtt.golang/topic.go b/mcp/dsx-exchange-mcp/vendor/github.com/eclipse/paho.mqtt.golang/topic.go new file mode 100644 index 0000000..966540a --- /dev/null +++ b/mcp/dsx-exchange-mcp/vendor/github.com/eclipse/paho.mqtt.golang/topic.go @@ -0,0 +1,90 @@ +/* + * Copyright (c) 2021 IBM Corp and others. + * + * All rights reserved. This program and the accompanying materials + * are made available under the terms of the Eclipse Public License v2.0 + * and Eclipse Distribution License v1.0 which accompany this distribution. + * + * The Eclipse Public License is available at + * https://www.eclipse.org/legal/epl-2.0/ + * and the Eclipse Distribution License is available at + * http://www.eclipse.org/org/documents/edl-v10.php. + * + * Contributors: + * Seth Hoenig + * Allan Stockdill-Mander + * Mike Robertson + */ + +package mqtt + +import ( + "errors" + "strings" +) + +// ErrInvalidQos is the error returned when an packet is to be sent +// with an invalid Qos value +var ErrInvalidQos = errors.New("invalid QoS") + +// ErrInvalidTopicEmptyString is the error returned when a topic string +// is passed in that is 0 length +var ErrInvalidTopicEmptyString = errors.New("invalid Topic; empty string") + +// ErrInvalidTopicMultilevel is the error returned when a topic string +// is passed in that has the multi level wildcard in any position but +// the last +var ErrInvalidTopicMultilevel = errors.New("invalid Topic; multi-level wildcard must be last level") + +// Topic Names and Topic Filters +// The MQTT v3.1.1 spec clarifies a number of ambiguities with regard +// to the validity of Topic strings. +// - A Topic must be between 1 and 65535 bytes. +// - A Topic is case sensitive. +// - A Topic may contain whitespace. +// - A Topic containing a leading forward slash is different than a Topic without. +// - A Topic may be "/" (two levels, both empty string). +// - A Topic must be UTF-8 encoded. +// - A Topic may contain any number of levels. +// - A Topic may contain an empty level (two forward slashes in a row). +// - A TopicName may not contain a wildcard. +// - A TopicFilter may only have a # (multi-level) wildcard as the last level. +// - A TopicFilter may contain any number of + (single-level) wildcards. +// - A TopicFilter with a # will match the absence of a level +// Example: a subscription to "foo/#" will match messages published to "foo". + +func validateSubscribeMap(subs map[string]byte) ([]string, []byte, error) { + if len(subs) == 0 { + return nil, nil, errors.New("invalid subscription; subscribe map must not be empty") + } + + var topics []string + var qoss []byte + for topic, qos := range subs { + if err := validateTopicAndQos(topic, qos); err != nil { + return nil, nil, err + } + topics = append(topics, topic) + qoss = append(qoss, qos) + } + + return topics, qoss, nil +} + +func validateTopicAndQos(topic string, qos byte) error { + if len(topic) == 0 { + return ErrInvalidTopicEmptyString + } + + levels := strings.Split(topic, "/") + for i, level := range levels { + if level == "#" && i != len(levels)-1 { + return ErrInvalidTopicMultilevel + } + } + + if qos > 2 { + return ErrInvalidQos + } + return nil +} diff --git a/mcp/dsx-exchange-mcp/vendor/github.com/eclipse/paho.mqtt.golang/trace.go b/mcp/dsx-exchange-mcp/vendor/github.com/eclipse/paho.mqtt.golang/trace.go new file mode 100644 index 0000000..b07b604 --- /dev/null +++ b/mcp/dsx-exchange-mcp/vendor/github.com/eclipse/paho.mqtt.golang/trace.go @@ -0,0 +1,44 @@ +/* + * Copyright (c) 2021 IBM Corp and others. + * + * All rights reserved. This program and the accompanying materials + * are made available under the terms of the Eclipse Public License v2.0 + * and Eclipse Distribution License v1.0 which accompany this distribution. + * + * The Eclipse Public License is available at + * https://www.eclipse.org/legal/epl-2.0/ + * and the Eclipse Distribution License is available at + * http://www.eclipse.org/org/documents/edl-v10.php. + * + * Contributors: + * Seth Hoenig + * Allan Stockdill-Mander + * Mike Robertson + */ + +package mqtt + +type ( + // Logger interface allows implementations to provide to this package any + // object that implements the methods defined in it. + Logger interface { + Println(v ...interface{}) + Printf(format string, v ...interface{}) + } + + // NOOPLogger implements the logger that does not perform any operation + // by default. This allows us to efficiently discard the unwanted messages. + NOOPLogger struct{} +) + +func (NOOPLogger) Println(v ...interface{}) {} +func (NOOPLogger) Printf(format string, v ...interface{}) {} + +// Internal levels of library output that are initialised to not print +// anything but can be overridden by programmer +var ( + ERROR Logger = NOOPLogger{} + CRITICAL Logger = NOOPLogger{} + WARN Logger = NOOPLogger{} + DEBUG Logger = NOOPLogger{} +) diff --git a/mcp/dsx-exchange-mcp/vendor/github.com/eclipse/paho.mqtt.golang/websocket.go b/mcp/dsx-exchange-mcp/vendor/github.com/eclipse/paho.mqtt.golang/websocket.go new file mode 100644 index 0000000..e0f2583 --- /dev/null +++ b/mcp/dsx-exchange-mcp/vendor/github.com/eclipse/paho.mqtt.golang/websocket.go @@ -0,0 +1,132 @@ +/* + * This program and the accompanying materials + * are made available under the terms of the Eclipse Public License v2.0 + * and Eclipse Distribution License v1.0 which accompany this distribution. + * + * The Eclipse Public License is available at + * https://www.eclipse.org/legal/epl-2.0/ + * and the Eclipse Distribution License is available at + * http://www.eclipse.org/org/documents/edl-v10.php. + * + * Contributors: + */ + +package mqtt + +import ( + "crypto/tls" + "fmt" + "io" + "net" + "net/http" + "net/url" + "sync" + "time" + + "github.com/gorilla/websocket" +) + +// WebsocketOptions are config options for a websocket dialer +type WebsocketOptions struct { + ReadBufferSize int + WriteBufferSize int + Proxy ProxyFunction +} + +type ProxyFunction func(req *http.Request) (*url.URL, error) + +// NewWebsocket returns a new websocket and returns a net.Conn compatible interface using the gorilla/websocket package +func NewWebsocket(host string, tlsc *tls.Config, timeout time.Duration, requestHeader http.Header, options *WebsocketOptions) (net.Conn, error) { + if timeout == 0 { + timeout = 10 * time.Second + } + + if options == nil { + // Apply default options + options = &WebsocketOptions{} + } + if options.Proxy == nil { + options.Proxy = http.ProxyFromEnvironment + } + dialer := &websocket.Dialer{ + Proxy: options.Proxy, + HandshakeTimeout: timeout, + EnableCompression: false, + TLSClientConfig: tlsc, + Subprotocols: []string{"mqtt"}, + ReadBufferSize: options.ReadBufferSize, + WriteBufferSize: options.WriteBufferSize, + } + + ws, resp, err := dialer.Dial(host, requestHeader) + + if err != nil { + if resp != nil { + WARN.Println(CLI, fmt.Sprintf("Websocket handshake failure. StatusCode: %d. Body: %s", resp.StatusCode, resp.Body)) + } + return nil, err + } + + wrapper := &websocketConnector{ + Conn: ws, + } + return wrapper, err +} + +// websocketConnector is a websocket wrapper so it satisfies the net.Conn interface so it is a +// drop in replacement of the golang.org/x/net/websocket package. +// Implementation guide taken from https://github.com/gorilla/websocket/issues/282 +type websocketConnector struct { + *websocket.Conn + r io.Reader + rio sync.Mutex + wio sync.Mutex +} + +// SetDeadline sets both the read and write deadlines +func (c *websocketConnector) SetDeadline(t time.Time) error { + if err := c.SetReadDeadline(t); err != nil { + return err + } + err := c.SetWriteDeadline(t) + return err +} + +// Write writes data to the websocket +func (c *websocketConnector) Write(p []byte) (int, error) { + c.wio.Lock() + defer c.wio.Unlock() + + err := c.WriteMessage(websocket.BinaryMessage, p) + if err != nil { + return 0, err + } + return len(p), nil +} + +// Read reads the current websocket frame +func (c *websocketConnector) Read(p []byte) (int, error) { + c.rio.Lock() + defer c.rio.Unlock() + for { + if c.r == nil { + // Advance to next message. + var err error + _, c.r, err = c.NextReader() + if err != nil { + return 0, err + } + } + n, err := c.r.Read(p) + if err == io.EOF { + // At end of message. + c.r = nil + if n > 0 { + return n, nil + } + // No data read, continue to next message. + continue + } + return n, err + } +} diff --git a/mcp/dsx-exchange-mcp/vendor/github.com/google/jsonschema-go/LICENSE b/mcp/dsx-exchange-mcp/vendor/github.com/google/jsonschema-go/LICENSE new file mode 100644 index 0000000..1cb53e9 --- /dev/null +++ b/mcp/dsx-exchange-mcp/vendor/github.com/google/jsonschema-go/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2025 JSON Schema Go Project Authors + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/mcp/dsx-exchange-mcp/vendor/github.com/google/jsonschema-go/jsonschema/annotations.go b/mcp/dsx-exchange-mcp/vendor/github.com/google/jsonschema-go/jsonschema/annotations.go new file mode 100644 index 0000000..d4dd643 --- /dev/null +++ b/mcp/dsx-exchange-mcp/vendor/github.com/google/jsonschema-go/jsonschema/annotations.go @@ -0,0 +1,76 @@ +// Copyright 2025 The JSON Schema Go Project Authors. All rights reserved. +// Use of this source code is governed by an MIT-style +// license that can be found in the LICENSE file. + +package jsonschema + +import "maps" + +// An annotations tracks certain properties computed by keywords that are used by validation. +// ("Annotation" is the spec's term.) +// In particular, the unevaluatedItems and unevaluatedProperties keywords need to know which +// items and properties were evaluated (validated successfully). +type annotations struct { + allItems bool // all items were evaluated + endIndex int // 1+largest index evaluated by prefixItems + evaluatedIndexes map[int]bool // set of indexes evaluated by contains + allProperties bool // all properties were evaluated + evaluatedProperties map[string]bool // set of properties evaluated by various keywords +} + +// noteIndex marks i as evaluated. +func (a *annotations) noteIndex(i int) { + if a.evaluatedIndexes == nil { + a.evaluatedIndexes = map[int]bool{} + } + a.evaluatedIndexes[i] = true +} + +// noteEndIndex marks items with index less than end as evaluated. +func (a *annotations) noteEndIndex(end int) { + if end > a.endIndex { + a.endIndex = end + } +} + +// noteProperty marks prop as evaluated. +func (a *annotations) noteProperty(prop string) { + if a.evaluatedProperties == nil { + a.evaluatedProperties = map[string]bool{} + } + a.evaluatedProperties[prop] = true +} + +// noteProperties marks all the properties in props as evaluated. +func (a *annotations) noteProperties(props map[string]bool) { + a.evaluatedProperties = merge(a.evaluatedProperties, props) +} + +// merge adds b's annotations to a. +// a must not be nil. +func (a *annotations) merge(b *annotations) { + if b == nil { + return + } + if b.allItems { + a.allItems = true + } + if b.endIndex > a.endIndex { + a.endIndex = b.endIndex + } + a.evaluatedIndexes = merge(a.evaluatedIndexes, b.evaluatedIndexes) + if b.allProperties { + a.allProperties = true + } + a.evaluatedProperties = merge(a.evaluatedProperties, b.evaluatedProperties) +} + +// merge adds t's keys to s and returns s. +// If s is nil, it returns a copy of t. +func merge[K comparable](s, t map[K]bool) map[K]bool { + if s == nil { + return maps.Clone(t) + } + maps.Copy(s, t) + return s +} diff --git a/mcp/dsx-exchange-mcp/vendor/github.com/google/jsonschema-go/jsonschema/doc.go b/mcp/dsx-exchange-mcp/vendor/github.com/google/jsonschema-go/jsonschema/doc.go new file mode 100644 index 0000000..eade338 --- /dev/null +++ b/mcp/dsx-exchange-mcp/vendor/github.com/google/jsonschema-go/jsonschema/doc.go @@ -0,0 +1,115 @@ +// Copyright 2025 The JSON Schema Go Project Authors. All rights reserved. +// Use of this source code is governed by an MIT-style +// license that can be found in the LICENSE file. + +/* +Package jsonschema is an implementation of the [JSON Schema specification], +a JSON-based format for describing the structure of JSON data. +The package can be used to read schemas for code generation, and to validate +data using the draft 2020-12 and draft-07 specifications. Validation with +other drafts or custom meta-schemas is not supported. + +Construct a [Schema] as you would any Go struct (for example, by writing +a struct literal), or unmarshal a JSON schema into a [Schema] in the usual +way (with [encoding/json], for instance). It can then be used for code +generation or other purposes without further processing. +You can also infer a schema from a Go struct. + +# Resolution + +A Schema can refer to other schemas, both inside and outside itself. These +references must be resolved before a schema can be used for validation. +Call [Schema.Resolve] to obtain a resolved schema (called a [Resolved]). +If the schema has external references, pass a [ResolveOptions] with a [Loader] +to load them. To validate default values in a schema, set +[ResolveOptions.ValidateDefaults] to true. + +# Validation + +Call [Resolved.Validate] to validate a JSON value. The value must be a +Go value that looks like the result of unmarshaling a JSON value into an +[any] or a struct. For example, the JSON value + + {"name": "Al", "scores": [90, 80, 100]} + +could be represented as the Go value + + map[string]any{ + "name": "Al", + "scores": []any{90, 80, 100}, + } + +or as a value of this type: + + type Player struct { + Name string `json:"name"` + Scores []int `json:"scores"` + } + +# Inference + +The [For] function returns a [Schema] describing the given Go type. +Each field in the struct becomes a property of the schema. +The values of "json" tags are respected: the field's property name is taken +from the tag, and fields omitted from the JSON are omitted from the schema as +well. +For example, `jsonschema.For[Player]()` returns this schema: + + { + "properties": { + "name": { + "type": "string" + }, + "scores": { + "type": "array", + "items": {"type": "integer"} + } + "required": ["name", "scores"], + "additionalProperties": {"not": {}} + } + } + +Use the "jsonschema" struct tag to provide a description for the property: + + type Player struct { + Name string `json:"name" jsonschema:"player name"` + Scores []int `json:"scores" jsonschema:"scores of player's games"` + } + +# Deviations from the specification + +Regular expressions are processed with Go's regexp package, which differs +from ECMA 262, most significantly in not supporting back-references. +See [this table of differences] for more. + +The "format" keyword described in [section 7 of the validation spec] is recorded +in the Schema, but is ignored during validation. +It does not even produce [annotations]. +Use the "pattern" keyword instead: it will work more reliably across JSON Schema +implementations. See [learnjsonschema.com] for more recommendations about "format". + +The content keywords described in [section 8 of the validation spec] +are recorded in the schema, but ignored during validation. + +# Controlling behavior changes + +Minor and patch releases of this package may introduce behavior changes as part +of bug fixes or correctness improvements. To help manage the impact of such +changes, the package allows you to access previous behaviors using the +`JSONSCHEMAGODEBUG` environment variable. The available settings are listed +below; additional options may be introduced in future releases. + +- **typeschemasnull**: When set to `"1"`, the inferred schema for slices will +*not* include the `null` type alongside the array type. It will also avoid +adding `null` to non-native pointer types (such as `time.Time`). This restores +the behavior from versions prior to v0.3.0. The default behavior is to include +`null` in these cases. + +[JSON Schema specification]: https://json-schema.org +[section 7 of the validation spec]: https://json-schema.org/draft/2020-12/draft-bhutton-json-schema-validation-00#rfc.section.7 +[section 8 of the validation spec]: https://json-schema.org/draft/2020-12/draft-bhutton-json-schema-validation-00#rfc.section.8 +[learnjsonschema.com]: https://www.learnjsonschema.com/2020-12/format-annotation/format/ +[this table of differences]: https://github.com/dlclark/regexp2?tab=readme-ov-file#compare-regexp-and-regexp2 +[annotations]: https://json-schema.org/draft/2020-12/json-schema-core#name-annotations +*/ +package jsonschema diff --git a/mcp/dsx-exchange-mcp/vendor/github.com/google/jsonschema-go/jsonschema/infer.go b/mcp/dsx-exchange-mcp/vendor/github.com/google/jsonschema-go/jsonschema/infer.go new file mode 100644 index 0000000..9c195f5 --- /dev/null +++ b/mcp/dsx-exchange-mcp/vendor/github.com/google/jsonschema-go/jsonschema/infer.go @@ -0,0 +1,400 @@ +// Copyright 2025 The JSON Schema Go Project Authors. All rights reserved. +// Use of this source code is governed by an MIT-style +// license that can be found in the LICENSE file. + +// This file contains functions that infer a schema from a Go type. + +package jsonschema + +import ( + "fmt" + "log/slog" + "maps" + "math" + "math/big" + "os" + "reflect" + "regexp" + "slices" + "time" +) + +const debugEnv = "JSONSCHEMAGODEBUG" + +// ForOptions are options for the [For] and [ForType] functions. +type ForOptions struct { + // If IgnoreInvalidTypes is true, fields that can't be represented as a JSON + // Schema are ignored instead of causing an error. + // This allows callers to adjust the resulting schema using custom knowledge. + // For example, an interface type where all the possible implementations are + // known can be described with "oneof". + IgnoreInvalidTypes bool + + // TypeSchemas maps types to their schemas. + // If [For] encounters a type that is a key in this map, the + // corresponding value is used as the resulting schema (after cloning to + // ensure uniqueness). + // Types in this map override the default translations, as described + // in [For]'s documentation. + // PropertyOrder defined in these schemas will not be used in [For] or [ForType]. + TypeSchemas map[reflect.Type]*Schema +} + +// For constructs a JSON schema object for the given type argument. +// If non-nil, the provided options configure certain aspects of this contruction, +// described below. + +// It translates Go types into compatible JSON schema types, as follows. +// These defaults can be overridden by [ForOptions.TypeSchemas]. +// +// - Strings have schema type "string". +// - Bools have schema type "boolean". +// - Signed and unsigned integer types have schema type "integer". +// - Floating point types have schema type "number". +// - Slices and arrays have schema type "array", and a corresponding schema +// for items. +// - Maps with string key have schema type "object", and corresponding +// schema for additionalProperties. +// - Structs have schema type "object", and disallow additionalProperties. +// Their properties are derived from exported struct fields, using the +// struct field JSON name. Fields that are marked "omitempty" or "omitzero" are +// considered optional; all other fields become required properties. +// For structs, the PropertyOrder will be set to the field order. +// - Some types in the standard library that implement json.Marshaler +// translate to schemas that match the values to which they marshal. +// For example, [time.Time] translates to the schema for strings. +// +// For will return an error if there is a cycle in the types. +// +// By default, For returns an error if t contains (possibly recursively) any of the +// following Go types, as they are incompatible with the JSON schema spec. +// If [ForOptions.IgnoreInvalidTypes] is true, then these types are ignored instead. +// - maps with key other than 'string' +// - function types +// - channel types +// - complex numbers +// - unsafe pointers +// +// This function recognizes struct field tags named "jsonschema". +// A jsonschema tag on a field is used as the description for the corresponding property. +// For future compatibility, descriptions must not start with "WORD=", where WORD is a +// sequence of non-whitespace characters. +func For[T any](opts *ForOptions) (*Schema, error) { + if opts == nil { + opts = &ForOptions{} + } + schemas := maps.Clone(initialSchemaMap) + // Add types from the options. They override the default ones. + maps.Copy(schemas, opts.TypeSchemas) + s, err := forType(reflect.TypeFor[T](), map[reflect.Type]bool{}, opts.IgnoreInvalidTypes, schemas) + if err != nil { + var z T + return nil, fmt.Errorf("For[%T](): %w", z, err) + } + return s, nil +} + +// ForType is like [For], but takes a [reflect.Type] +func ForType(t reflect.Type, opts *ForOptions) (*Schema, error) { + if opts == nil { + opts = &ForOptions{} + } + schemas := maps.Clone(initialSchemaMap) + // Add types from the options. They override the default ones. + maps.Copy(schemas, opts.TypeSchemas) + s, err := forType(t, map[reflect.Type]bool{}, opts.IgnoreInvalidTypes, schemas) + if err != nil { + return nil, fmt.Errorf("ForType(%s): %w", t, err) + } + return s, nil +} + +// Helper to create a *float64 pointer from a value +func f64Ptr(f float64) *float64 { + return &f +} + +func forType(t reflect.Type, seen map[reflect.Type]bool, ignore bool, schemas map[reflect.Type]*Schema) (*Schema, error) { + // Follow pointers: the schema for *T is almost the same as for T, except that + // an explicit JSON "null" is allowed for the pointer. + allowNull := false + for t.Kind() == reflect.Pointer { + allowNull = true + t = t.Elem() + } + + // Check for cycles + // User defined types have a name, so we can skip those that are natively defined + if t.Name() != "" { + if seen[t] { + return nil, fmt.Errorf("cycle detected for type %v", t) + } + seen[t] = true + defer delete(seen, t) + } + + if s := schemas[t]; s != nil { + cloned := s.CloneSchemas() + if os.Getenv(debugEnv) != "typeschemasnull=1" && allowNull { + if cloned.Type != "" { + cloned.Types = []string{"null", cloned.Type} + cloned.Type = "" + } else if !slices.Contains(cloned.Types, "null") { + cloned.Types = append([]string{"null"}, cloned.Types...) + } + } + return cloned, nil + } + + var ( + s = new(Schema) + err error + ) + + switch t.Kind() { + case reflect.Bool: + s.Type = "boolean" + + case reflect.Int, reflect.Int64: + s.Type = "integer" + + case reflect.Uint, reflect.Uint64, reflect.Uintptr: + s.Type = "integer" + s.Minimum = f64Ptr(0) + + case reflect.Int8: + s.Type = "integer" + s.Minimum = f64Ptr(math.MinInt8) + s.Maximum = f64Ptr(math.MaxInt8) + + case reflect.Uint8: + s.Type = "integer" + s.Minimum = f64Ptr(0) + s.Maximum = f64Ptr(math.MaxUint8) + + case reflect.Int16: + s.Type = "integer" + s.Minimum = f64Ptr(math.MinInt16) + s.Maximum = f64Ptr(math.MaxInt16) + + case reflect.Uint16: + s.Type = "integer" + s.Minimum = f64Ptr(0) + s.Maximum = f64Ptr(math.MaxUint16) + + case reflect.Int32: + s.Type = "integer" + s.Minimum = f64Ptr(math.MinInt32) + s.Maximum = f64Ptr(math.MaxInt32) + + case reflect.Uint32: + s.Type = "integer" + s.Minimum = f64Ptr(0) + s.Maximum = f64Ptr(math.MaxUint32) + + case reflect.Float32, reflect.Float64: + s.Type = "number" + + case reflect.Interface: + // Unrestricted + + case reflect.Map: + if t.Key().Kind() != reflect.String { + if ignore { + return nil, nil // ignore + } + return nil, fmt.Errorf("unsupported map key type %v", t.Key().Kind()) + } + if t.Key().Kind() != reflect.String { + } + s.Type = "object" + s.AdditionalProperties, err = forType(t.Elem(), seen, ignore, schemas) + if err != nil { + return nil, fmt.Errorf("computing map value schema: %v", err) + } + if ignore && s.AdditionalProperties == nil { + // Ignore if the element type is invalid. + return nil, nil + } + + case reflect.Slice, reflect.Array: + if os.Getenv(debugEnv) != "typeschemasnull=1" && t.Kind() == reflect.Slice { + s.Types = []string{"null", "array"} + } else { + s.Type = "array" + } + itemsSchema, err := forType(t.Elem(), seen, ignore, schemas) + if err != nil { + return nil, fmt.Errorf("computing element schema: %v", err) + } + if itemsSchema == nil { + return nil, nil + } + s.Items = itemsSchema + if ignore && s.Items == nil { + // Ignore if the element type is invalid. + return nil, nil + } + if t.Kind() == reflect.Array { + s.MinItems = Ptr(t.Len()) + s.MaxItems = Ptr(t.Len()) + } + + case reflect.String: + s.Type = "string" + + case reflect.Struct: + s.Type = "object" + // no additional properties are allowed + s.AdditionalProperties = falseSchema() + + // If skipPath is non-nil, it is path to an anonymous field whose + // schema has been replaced by a known schema. + var skipPath []int + for _, field := range reflect.VisibleFields(t) { + if s.Properties == nil { + s.Properties = make(map[string]*Schema) + } + if field.Anonymous { + override := schemas[field.Type] + if override != nil { + // Type must be object, and only properties can be set. + if override.Type != "object" { + return nil, fmt.Errorf(`custom schema for embedded struct must have type "object", got %q`, + override.Type) + } + // Check that all keywords relevant for objects are absent, except properties. + ov := reflect.ValueOf(override).Elem() + for _, sfi := range schemaFieldInfos { + if sfi.sf.Name == "Type" || sfi.sf.Name == "Properties" { + continue + } + fv := ov.FieldByIndex(sfi.sf.Index) + if !fv.IsZero() { + return nil, fmt.Errorf(`overrides for embedded fields can have only "Type" and "Properties"; this has %q`, sfi.sf.Name) + } + } + + skipPath = field.Index + keys := make([]string, 0, len(override.Properties)) + for k := range override.Properties { + keys = append(keys, k) + } + slices.Sort(keys) + for _, name := range keys { + if _, ok := s.Properties[name]; !ok { + s.Properties[name] = override.Properties[name].CloneSchemas() + s.PropertyOrder = append(s.PropertyOrder, name) + } + } + } + continue + } + + // Check to see if this field has been promoted from a replaced anonymous + // type. + if skipPath != nil { + skip := false + if len(field.Index) >= len(skipPath) { + skip = true + for i, index := range skipPath { + if field.Index[i] != index { + // If we're no longer in a subfield. + skip = false + break + } + } + } + if skip { + continue + } else { + // Anonymous fields are followed immediately by their promoted fields. + // Once we encounter a field that *isn't* promoted, we can stop + // checking. + skipPath = nil + } + } + + info := fieldJSONInfo(field) + if info.omit { + continue + } + fs, err := forType(field.Type, seen, ignore, schemas) + if err != nil { + return nil, err + } + if ignore && fs == nil { + // Skip fields of invalid type. + continue + } + if tag, ok := field.Tag.Lookup("jsonschema"); ok { + if tag == "" { + return nil, fmt.Errorf("empty jsonschema tag on struct field %s.%s", t, field.Name) + } + if disallowedPrefixRegexp.MatchString(tag) { + return nil, fmt.Errorf("tag must not begin with 'WORD=': %q", tag) + } + fs.Description = tag + } + s.Properties[info.name] = fs + + s.PropertyOrder = append(s.PropertyOrder, info.name) + + if !info.settings["omitempty"] && !info.settings["omitzero"] { + s.Required = append(s.Required, info.name) + } + } + + // Remove PropertyOrder duplicates, keeping the last occurrence + if len(s.PropertyOrder) > 1 { + seen := make(map[string]bool) + // Create a slice to hold the cleaned order (capacity = current length) + cleaned := make([]string, 0, len(s.PropertyOrder)) + + // Iterate backwards + for i := len(s.PropertyOrder) - 1; i >= 0; i-- { + name := s.PropertyOrder[i] + if !seen[name] { + cleaned = append(cleaned, name) + seen[name] = true + } + } + + // Since we collected them backwards, we need to reverse the result + // to restore the correct order. + slices.Reverse(cleaned) + s.PropertyOrder = cleaned + } + + default: + if ignore { + // Ignore. + return nil, nil + } + return nil, fmt.Errorf("type %v is unsupported by jsonschema", t) + } + if allowNull && s.Type != "" { + s.Types = []string{"null", s.Type} + s.Type = "" + } + return s, nil +} + +// initialSchemaMap holds types from the standard library that have MarshalJSON methods. +var initialSchemaMap = make(map[reflect.Type]*Schema) + +func init() { + ss := &Schema{Type: "string"} + initialSchemaMap[reflect.TypeFor[time.Time]()] = ss + initialSchemaMap[reflect.TypeFor[slog.Level]()] = ss + if os.Getenv(debugEnv) == "typeschemasnull=1" { + initialSchemaMap[reflect.TypeFor[big.Int]()] = &Schema{Types: []string{"null", "string"}} + } else { + initialSchemaMap[reflect.TypeFor[big.Int]()] = ss + } + initialSchemaMap[reflect.TypeFor[big.Rat]()] = ss + initialSchemaMap[reflect.TypeFor[big.Float]()] = ss +} + +// Disallow jsonschema tag values beginning "WORD=", for future expansion. +var disallowedPrefixRegexp = regexp.MustCompile("^[^ \t\n]*=") diff --git a/mcp/dsx-exchange-mcp/vendor/github.com/google/jsonschema-go/jsonschema/json_pointer.go b/mcp/dsx-exchange-mcp/vendor/github.com/google/jsonschema-go/jsonschema/json_pointer.go new file mode 100644 index 0000000..4a9db2e --- /dev/null +++ b/mcp/dsx-exchange-mcp/vendor/github.com/google/jsonschema-go/jsonschema/json_pointer.go @@ -0,0 +1,160 @@ +// Copyright 2025 The JSON Schema Go Project Authors. All rights reserved. +// Use of this source code is governed by an MIT-style +// license that can be found in the LICENSE file. + +// This file implements JSON Pointers. +// A JSON Pointer is a path that refers to one JSON value within another. +// If the path is empty, it refers to the root value. +// Otherwise, it is a sequence of slash-prefixed strings, like "/points/1/x", +// selecting successive properties (for JSON objects) or items (for JSON arrays). +// For example, when applied to this JSON value: +// { +// "points": [ +// {"x": 1, "y": 2}, +// {"x": 3, "y": 4} +// ] +// } +// +// the JSON Pointer "/points/1/x" refers to the number 3. +// See the spec at https://datatracker.ietf.org/doc/html/rfc6901. + +package jsonschema + +import ( + "errors" + "fmt" + "reflect" + "strconv" + "strings" +) + +var ( + jsonPointerEscaper = strings.NewReplacer("~", "~0", "/", "~1") + jsonPointerUnescaper = strings.NewReplacer("~0", "~", "~1", "/") +) + +func escapeJSONPointerSegment(s string) string { + return jsonPointerEscaper.Replace(s) +} + +func unescapeJSONPointerSegment(s string) string { + return jsonPointerUnescaper.Replace(s) +} + +// parseJSONPointer splits a JSON Pointer into a sequence of segments. It doesn't +// convert strings to numbers, because that depends on the traversal: a segment +// is treated as a number when applied to an array, but a string when applied to +// an object. See section 4 of the spec. +func parseJSONPointer(ptr string) (segments []string, err error) { + if ptr == "" { + return nil, nil + } + if ptr[0] != '/' { + return nil, fmt.Errorf("JSON Pointer %q does not begin with '/'", ptr) + } + // Unlike file paths, consecutive slashes are not coalesced. + // Split is nicer than Cut here, because it gets a final "/" right. + segments = strings.Split(ptr[1:], "/") + if strings.Contains(ptr, "~") { + // Undo the simple escaping rules that allow one to include a slash in a segment. + for i := range segments { + segments[i] = unescapeJSONPointerSegment(segments[i]) + } + } + return segments, nil +} + +// dereferenceJSONPointer returns the Schema that sptr points to within s, +// or an error if none. +// This implementation suffices for JSON Schema: pointers are applied only to Schemas, +// and refer only to Schemas. +func dereferenceJSONPointer(s *Schema, sptr string) (_ *Schema, err error) { + defer wrapf(&err, "JSON Pointer %q", sptr) + + segments, err := parseJSONPointer(sptr) + if err != nil { + return nil, err + } + v := reflect.ValueOf(s) + for _, seg := range segments { + switch v.Kind() { + case reflect.Pointer: + v = v.Elem() + if !v.IsValid() { + return nil, errors.New("navigated to nil reference") + } + fallthrough // if valid, can only be a pointer to a Schema + + case reflect.Struct: + // The segment must refer to a field in a Schema. + if v.Type() != reflect.TypeFor[Schema]() { + return nil, fmt.Errorf("navigated to non-Schema %s", v.Type()) + } + v = lookupSchemaField(v, seg) + if !v.IsValid() { + return nil, fmt.Errorf("no schema field %q", seg) + } + case reflect.Slice, reflect.Array: + // The segment must be an integer without leading zeroes that refers to an item in the + // slice or array. + if seg == "-" { + return nil, errors.New("the JSON Pointer array segment '-' is not supported") + } + if len(seg) > 1 && seg[0] == '0' { + return nil, fmt.Errorf("segment %q has leading zeroes", seg) + } + n, err := strconv.Atoi(seg) + if err != nil { + return nil, fmt.Errorf("invalid int: %q", seg) + } + if n < 0 || n >= v.Len() { + return nil, fmt.Errorf("index %d is out of bounds for array of length %d", n, v.Len()) + } + v = v.Index(n) + // Cannot be invalid. + case reflect.Map: + // The segment must be a key in the map. + v = v.MapIndex(reflect.ValueOf(seg)) + if !v.IsValid() { + return nil, fmt.Errorf("no key %q in map", seg) + } + default: + return nil, fmt.Errorf("value %s (%s) is not a schema, slice or map", v, v.Type()) + } + } + if s, ok := v.Interface().(*Schema); ok { + return s, nil + } + return nil, fmt.Errorf("does not refer to a schema, but to a %s", v.Type()) +} + +// lookupSchemaField returns the value of the field with the given name in v, +// or the zero value if there is no such field or it is not of type Schema or *Schema. +func lookupSchemaField(v reflect.Value, name string) reflect.Value { + if name == "type" { + // The "type" keyword may refer to Type or Types. + // At most one will be non-zero. + if t := v.FieldByName("Type"); !t.IsZero() { + return t + } + return v.FieldByName("Types") + } + if name == "items" { + // The "items" keyword refers to the "union type" that is either a schema or a schema array. + // Implemented using the Items representing the schema and ItemsArray for the schema array. + if items := v.FieldByName("Items"); items.IsValid() && !items.IsNil() { + return items + } + return v.FieldByName("ItemsArray") + } + if name == "dependencies" { + // The "dependencies" keyword refers to both DependencyStrings and DependencySchemas maps. + // The value on schemaFieldMap is not garanteed to be DependencySchemas which we want + // for pointer dereference. So we use FieldByName to get the DependencySchemas map. + return v.FieldByName("DependencySchemas") + } + if sf, ok := schemaFieldMap[name]; ok { + return v.FieldByIndex(sf.Index) + } + return reflect.Value{} +} diff --git a/mcp/dsx-exchange-mcp/vendor/github.com/google/jsonschema-go/jsonschema/resolve.go b/mcp/dsx-exchange-mcp/vendor/github.com/google/jsonschema-go/jsonschema/resolve.go new file mode 100644 index 0000000..d63115b --- /dev/null +++ b/mcp/dsx-exchange-mcp/vendor/github.com/google/jsonschema-go/jsonschema/resolve.go @@ -0,0 +1,589 @@ +// Copyright 2025 The JSON Schema Go Project Authors. All rights reserved. +// Use of this source code is governed by an MIT-style +// license that can be found in the LICENSE file. + +// This file deals with preparing a schema for validation, including various checks, +// optimizations, and the resolution of cross-schema references. + +package jsonschema + +import ( + "errors" + "fmt" + "net/url" + "reflect" + "regexp" + "strings" +) + +// A Resolved consists of a [Schema] along with associated information needed to +// validate documents against it. +// A Resolved has been validated against its meta-schema, and all its references +// (the $ref and $dynamicRef keywords) have been resolved to their referenced Schemas. +// Call [Schema.Resolve] to obtain a Resolved from a Schema. +type Resolved struct { + root *Schema + draft draft + // map from $ids to their schemas + resolvedURIs map[string]*Schema + // map from schemas to additional info computed during resolution + resolvedInfos map[*Schema]*resolvedInfo +} + +type draft int + +const ( + draft7 = iota + draft2020 +) + +func newResolved(s *Schema) *Resolved { + return &Resolved{ + root: s, + draft: detectDraft(s), + resolvedURIs: map[string]*Schema{}, + resolvedInfos: map[*Schema]*resolvedInfo{}, + } +} + +// detectDraft inspects the raw JSON to determine the schema version. +func detectDraft(s *Schema) draft { + // Check explicit $schema declaration + switch s.Schema { + case draft7SchemaVersion, draft7SecSchemaVersion: + return draft7 + case draft202012SchemaVersion: + return draft2020 + default: + // If nothing matches default to the latest supported version. + return draft2020 + } +} + +// resolvedInfo holds information specific to a schema that is computed by [Schema.Resolve]. +type resolvedInfo struct { + s *Schema + // The JSON Pointer path from the root schema to here. + // Used in errors. + path string + // The schema's base schema. + // If the schema is the root or has an ID, its base is itself. + // Otherwise, its base is the innermost enclosing schema whose base + // is itself. + // Intuitively, a base schema is one that can be referred to with a + // fragmentless URI. + base *Schema + // The URI for the schema, if it is the root or has an ID. + // Otherwise nil. + // Invariants: + // s.base.uri != nil. + // s.base == s <=> s.uri != nil + uri *url.URL + // The schema to which Ref refers. + resolvedRef *Schema + + // If the schema has a dynamic ref, exactly one of the next two fields + // will be non-zero after successful resolution. + // The schema to which the dynamic ref refers when it acts lexically. + resolvedDynamicRef *Schema + // The anchor to look up on the stack when the dynamic ref acts dynamically. + dynamicRefAnchor string + + // The following fields are independent of arguments to Schema.Resolved, + // so they could live on the Schema. We put them here for simplicity. + + // The set of required properties. + isRequired map[string]bool + + // Compiled regexps. + pattern *regexp.Regexp + patternProperties map[*regexp.Regexp]*Schema + + // Map from anchors to subschemas. + anchors map[string]anchorInfo +} + +// Schema returns the schema that was resolved. +// It must not be modified. +func (r *Resolved) Schema() *Schema { return r.root } + +// schemaString returns a short string describing the schema. +func (r *Resolved) schemaString(s *Schema) string { + if s.ID != "" { + return s.ID + } + info := r.resolvedInfos[s] + if info.path != "" { + return info.path + } + return "" +} + +// A Loader reads and unmarshals the schema at uri, if any. +type Loader func(uri *url.URL) (*Schema, error) + +// ResolveOptions are options for [Schema.Resolve]. +type ResolveOptions struct { + // BaseURI is the URI relative to which the root schema should be resolved. + // If non-empty, must be an absolute URI (one that starts with a scheme). + // It is resolved (in the URI sense; see [url.ResolveReference]) with root's + // $id property. + // If the resulting URI is not absolute, then the schema cannot contain + // relative URI references. + BaseURI string + // Loader loads schemas that are referred to by a $ref but are not under the + // root schema (remote references). + // If nil, resolving a remote reference will return an error. + Loader Loader + // ValidateDefaults determines whether to validate values of "default" keywords + // against their schemas. + // The [JSON Schema specification] does not require this, but it is recommended + // if defaults will be used. + // + // [JSON Schema specification]: https://json-schema.org/understanding-json-schema/reference/annotations + ValidateDefaults bool +} + +// Resolve resolves all references within the schema and performs other tasks that +// prepare the schema for validation. +// If opts is nil, the default values are used. +// The schema must not be changed after Resolve is called. +// The same schema may be resolved multiple times. +func (root *Schema) Resolve(opts *ResolveOptions) (*Resolved, error) { + // There are up to five steps required to prepare a schema to validate. + // 1. Load: read the schema from somewhere and unmarshal it. + // This schema (root) may have been loaded or created in memory, but other schemas that + // come into the picture in step 4 will be loaded by the given loader. + // 2. Check: validate the schema against a meta-schema, and perform other well-formedness checks. + // Precompute some values along the way. + // 3. Resolve URIs: determine the base URI of the root and all its subschemas, and + // resolve (in the URI sense) all identifiers and anchors with their bases. This step results + // in a map from URIs to schemas within root. + // 4. Resolve references: all refs in the schemas are replaced with the schema they refer to. + // 5. (Optional.) If opts.ValidateDefaults is true, validate the defaults. + r := &resolver{loaded: map[string]*Resolved{}} + if opts != nil { + r.opts = *opts + } + var base *url.URL + if r.opts.BaseURI == "" { + base = &url.URL{} // so we can call ResolveReference on it + } else { + var err error + base, err = url.Parse(r.opts.BaseURI) + if err != nil { + return nil, fmt.Errorf("parsing base URI: %w", err) + } + } + + if r.opts.Loader == nil { + r.opts.Loader = func(uri *url.URL) (*Schema, error) { + return nil, errors.New("cannot resolve remote schemas: no loader passed to Schema.Resolve") + } + } + + resolved, err := r.resolve(root, base) + if err != nil { + return nil, err + } + if r.opts.ValidateDefaults { + if err := resolved.validateDefaults(); err != nil { + return nil, err + } + } + // TODO: before we return, throw away anything we don't need for validation. + return resolved, nil +} + +// A resolver holds the state for resolution. +type resolver struct { + opts ResolveOptions + // A cache of loaded and partly resolved schemas. (They may not have had their + // refs resolved.) The cache ensures that the loader will never be called more + // than once with the same URI, and that reference cycles are handled properly. + loaded map[string]*Resolved +} + +func (r *resolver) resolve(s *Schema, baseURI *url.URL) (*Resolved, error) { + if baseURI.Fragment != "" { + return nil, fmt.Errorf("base URI %s must not have a fragment", baseURI) + } + rs := newResolved(s) + + if err := s.check(rs.resolvedInfos); err != nil { + return nil, err + } + + if err := resolveURIs(rs, baseURI); err != nil { + return nil, err + } + + // Remember the schema by both the URI we loaded it from and its canonical name, + // which may differ if the schema has an $id. + // We must set the map before calling resolveRefs, or ref cycles will cause unbounded recursion. + r.loaded[baseURI.String()] = rs + r.loaded[rs.resolvedInfos[s].uri.String()] = rs + + if err := r.resolveRefs(rs); err != nil { + return nil, err + } + return rs, nil +} + +func (root *Schema) check(infos map[*Schema]*resolvedInfo) error { + // Check for structural validity. Do this first and fail fast: + // bad structure will cause other code to panic. + if err := root.checkStructure(infos); err != nil { + return err + } + + var errs []error + report := func(err error) { errs = append(errs, err) } + + for ss := range root.all() { + ss.checkLocal(report, infos) + } + return errors.Join(errs...) +} + +// checkStructure verifies that root and its subschemas form a tree. +// It also assigns each schema a unique path, to improve error messages. +func (root *Schema) checkStructure(infos map[*Schema]*resolvedInfo) error { + assert(len(infos) == 0, "non-empty infos") + + var check func(reflect.Value, []byte) error + check = func(v reflect.Value, path []byte) error { + // For the purpose of error messages, the root schema has path "root" + // and other schemas' paths are their JSON Pointer from the root. + p := "root" + if len(path) > 0 { + p = string(path) + } + s := v.Interface().(*Schema) + if s == nil { + return fmt.Errorf("jsonschema: schema at %s is nil", p) + } + if info, ok := infos[s]; ok { + // We've seen s before. + // The schema graph at root is not a tree, but it needs to + // be because a schema's base must be unique. + // A cycle would also put Schema.all into an infinite recursion. + return fmt.Errorf("jsonschema: schemas at %s do not form a tree; %s appears more than once (also at %s)", + root, info.path, p) + } + infos[s] = &resolvedInfo{s: s, path: p} + + for _, info := range schemaFieldInfos { + fv := v.Elem().FieldByIndex(info.sf.Index) + switch info.sf.Type { + case schemaType: + // A field that contains an individual schema. + // A nil is valid: it just means the field isn't present. + if !fv.IsNil() { + if err := check(fv, fmt.Appendf(path, "/%s", info.jsonName)); err != nil { + return err + } + } + + case schemaSliceType: + for i := range fv.Len() { + if err := check(fv.Index(i), fmt.Appendf(path, "/%s/%d", info.jsonName, i)); err != nil { + return err + } + } + + case schemaMapType: + iter := fv.MapRange() + for iter.Next() { + key := escapeJSONPointerSegment(iter.Key().String()) + if err := check(iter.Value(), fmt.Appendf(path, "/%s/%s", info.jsonName, key)); err != nil { + return err + } + } + } + + } + return nil + } + + return check(reflect.ValueOf(root), make([]byte, 0, 256)) +} + +// checkLocal checks s for validity, independently of other schemas it may refer to. +// Since checking a regexp involves compiling it, checkLocal saves those compiled regexps +// in the schema for later use. +// It appends the errors it finds to errs. +func (s *Schema) checkLocal(report func(error), infos map[*Schema]*resolvedInfo) { + addf := func(format string, args ...any) { + msg := fmt.Sprintf(format, args...) + report(fmt.Errorf("jsonschema.Schema: %s: %s", s, msg)) + } + + if s == nil { + addf("nil subschema") + return + } + if err := s.basicChecks(); err != nil { + report(err) + return + } + + // TODO: validate the schema's properties, + // ideally by jsonschema-validating it against the meta-schema. + + // Some properties are present so that Schemas can round-trip, but we do not + // validate them. + // Currently, it's just the $vocabulary property. + // As a special case, we can validate the 2020-12 meta-schema. + if s.Vocabulary != nil && s.Schema != draft202012SchemaVersion { + addf("cannot validate a schema with $vocabulary") + } + + info := infos[s] + + // Check and compile regexps. + if s.Pattern != "" { + re, err := regexp.Compile(s.Pattern) + if err != nil { + addf("pattern: %v", err) + } else { + info.pattern = re + } + } + if len(s.PatternProperties) > 0 { + info.patternProperties = map[*regexp.Regexp]*Schema{} + for reString, subschema := range s.PatternProperties { + re, err := regexp.Compile(reString) + if err != nil { + addf("patternProperties[%q]: %v", reString, err) + continue + } + info.patternProperties[re] = subschema + } + } + + // Build a set of required properties, to avoid quadratic behavior when validating + // a struct. + if len(s.Required) > 0 { + info.isRequired = map[string]bool{} + for _, r := range s.Required { + info.isRequired[r] = true + } + } +} + +// resolveURIs resolves the ids and anchors in all the schemas of root, relative +// to baseURI. +// See https://json-schema.org/draft/2020-12/json-schema-core#section-8.2, section +// 8.2.1. +// +// Every schema has a base URI and a parent base URI. +// +// The parent base URI is the base URI of the lexically enclosing schema, or for +// a root schema, the URI it was loaded from or the one supplied to [Schema.Resolve]. +// +// If the schema has no $id property, the base URI of a schema is that of its parent. +// If the schema does have an $id, it must be a URI, possibly relative. The schema's +// base URI is the $id resolved (in the sense of [url.URL.ResolveReference]) against +// the parent base. +// +// As an example, consider this schema loaded from http://a.com/root.json (quotes omitted): +// +// { +// allOf: [ +// {$id: "sub1.json", minLength: 5}, +// {$id: "http://b.com", minimum: 10}, +// {not: {maximum: 20}} +// ] +// } +// +// The base URIs are as follows. Schema locations are expressed in the JSON Pointer notation. +// +// schema base URI +// root http://a.com/root.json +// allOf/0 http://a.com/sub1.json +// allOf/1 http://b.com (absolute $id; doesn't matter that it's not under the loaded URI) +// allOf/2 http://a.com/root.json (inherited from parent) +// allOf/2/not http://a.com/root.json (inherited from parent) +func resolveURIs(rs *Resolved, baseURI *url.URL) error { + // Anchors and dynamic anchors are URI fragments that are scoped to their base. + // We treat them as keys in a map stored within the schema. + setAnchor := func(s *Schema, baseInfo *resolvedInfo, anchor string, dynamic bool) error { + if anchor != "" { + if _, ok := baseInfo.anchors[anchor]; ok { + return fmt.Errorf("duplicate anchor %q in %s", anchor, baseInfo.uri) + } + if baseInfo.anchors == nil { + baseInfo.anchors = map[string]anchorInfo{} + } + baseInfo.anchors[anchor] = anchorInfo{s, dynamic} + } + return nil + } + + var resolve func(s, base *Schema) error + resolve = func(s, base *Schema) error { + info := rs.resolvedInfos[s] + baseInfo := rs.resolvedInfos[base] + + // ids are scoped to the root. + if s.ID != "" { + // draft-7 specific + // https://json-schema.org/draft-07/draft-handrews-json-schema-01#rfc.section.8.3 + // "All other properties in a "$ref" object MUST be ignored." + ignore := rs.draft == draft7 && s.Ref != "" + if !ignore { + // A non-empty ID establishes a new base. + idURI, err := url.Parse(s.ID) + if err != nil { + return err + } + if rs.draft == draft2020 && idURI.Fragment != "" { + return fmt.Errorf("$id %s must not have a fragment", s.ID) + } + if rs.draft == draft7 && idURI.Fragment != "" { + // anchor did not exist in draft 7, id was used for base uri and document navigation + // https://json-schema.org/draft-07/draft-handrews-json-schema-01#id-keyword + anchorName := strings.TrimPrefix(s.ID, "#") + setAnchor(s, baseInfo, anchorName, false) + } else { + // The base URI for this schema is its $id resolved against the parent base. + info.uri = baseInfo.uri.ResolveReference(idURI) + if !info.uri.IsAbs() { + return fmt.Errorf("$id %s does not resolve to an absolute URI (base is %q)", s.ID, baseInfo.uri) + } + rs.resolvedURIs[info.uri.String()] = s + base = s // needed for anchors + baseInfo = rs.resolvedInfos[base] + } + } + } + info.base = base + if rs.draft == draft2020 { + setAnchor(s, baseInfo, s.Anchor, false) + setAnchor(s, baseInfo, s.DynamicAnchor, true) + } + + for c := range s.children() { + if err := resolve(c, base); err != nil { + return err + } + } + return nil + } + + // Set the root URI to the base for now. If the root has an $id, this will change. + rs.resolvedInfos[rs.root].uri = baseURI + // The original base, even if changed, is still a valid way to refer to the root. + rs.resolvedURIs[baseURI.String()] = rs.root + + return resolve(rs.root, rs.root) +} + +// resolveRefs replaces every ref in the schemas with the schema it refers to. +// A reference that doesn't resolve within the schema may refer to some other schema +// that needs to be loaded. +func (r *resolver) resolveRefs(rs *Resolved) error { + for s := range rs.root.all() { + info := rs.resolvedInfos[s] + if s.Ref != "" { + refSchema, _, err := r.resolveRef(rs, s, s.Ref) + if err != nil { + return err + } + // Whether or not the anchor referred to by $ref fragment is dynamic, + // the ref still treats it lexically. + info.resolvedRef = refSchema + } + if s.DynamicRef != "" { + refSchema, frag, err := r.resolveRef(rs, s, s.DynamicRef) + if err != nil { + return err + } + if frag != "" { + // The dynamic ref's fragment points to a dynamic anchor. + // We must resolve the fragment at validation time. + info.dynamicRefAnchor = frag + } else { + // There is no dynamic anchor in the lexically referenced schema, + // so the dynamic ref behaves like a lexical ref. + info.resolvedDynamicRef = refSchema + } + } + } + return nil +} + +// resolveRef resolves the reference ref, which is either s.Ref or s.DynamicRef. +func (r *resolver) resolveRef(rs *Resolved, s *Schema, ref string) (_ *Schema, dynamicFragment string, err error) { + refURI, err := url.Parse(ref) + if err != nil { + return nil, "", err + } + // URI-resolve the ref against the current base URI to get a complete URI. + base := rs.resolvedInfos[s].base + refURI = rs.resolvedInfos[base].uri.ResolveReference(refURI) + // The non-fragment part of a ref URI refers to the base URI of some schema. + // This part is the same for dynamic refs too: their non-fragment part resolves + // lexically. + u := *refURI + u.Fragment = "" + fraglessRefURI := &u + // Look it up locally. + referencedSchema := rs.resolvedURIs[fraglessRefURI.String()] + if referencedSchema == nil { + // The schema is remote. Maybe we've already loaded it. + // We assume that the non-fragment part of refURI refers to a top-level schema + // document. That is, we don't support the case exemplified by + // http://foo.com/bar.json/baz, where the document is in bar.json and + // the reference points to a subschema within it. + // TODO: support that case. + if lrs := r.loaded[fraglessRefURI.String()]; lrs != nil { + referencedSchema = lrs.root + } else { + // Try to load the schema. + ls, err := r.opts.Loader(fraglessRefURI) + if err != nil { + return nil, "", fmt.Errorf("loading %s: %w", fraglessRefURI, err) + } + // Check if referenced schema has $schema defined. If not it should inherit the resolved + if ls.Schema == "" { + ls.Schema = s.Schema + } + lrs, err := r.resolve(ls, fraglessRefURI) + if err != nil { + return nil, "", err + } + referencedSchema = lrs.root + assert(referencedSchema != nil, "nil referenced schema") + // Copy the resolvedInfos from lrs into rs, without overwriting + // (hence we can't use maps.Insert). + for s, i := range lrs.resolvedInfos { + if rs.resolvedInfos[s] == nil { + rs.resolvedInfos[s] = i + } + } + } + } + + frag := refURI.Fragment + // Look up frag in refSchema. + // frag is either a JSON Pointer or the name of an anchor. + // A JSON Pointer is either the empty string or begins with a '/', + // whereas anchors are always non-empty strings that don't contain slashes. + if frag != "" && !strings.HasPrefix(frag, "/") { + resInfo := rs.resolvedInfos[referencedSchema] + info, found := resInfo.anchors[frag] + + if !found { + return nil, "", fmt.Errorf("no anchor %q in %s", frag, s) + } + if info.dynamic { + dynamicFragment = frag + } + return info.schema, dynamicFragment, nil + } + // frag is a JSON Pointer. + s, err = dereferenceJSONPointer(referencedSchema, frag) + return s, "", err +} diff --git a/mcp/dsx-exchange-mcp/vendor/github.com/google/jsonschema-go/jsonschema/schema.go b/mcp/dsx-exchange-mcp/vendor/github.com/google/jsonschema-go/jsonschema/schema.go new file mode 100644 index 0000000..243048a --- /dev/null +++ b/mcp/dsx-exchange-mcp/vendor/github.com/google/jsonschema-go/jsonschema/schema.go @@ -0,0 +1,642 @@ +// Copyright 2025 The JSON Schema Go Project Authors. All rights reserved. +// Use of this source code is governed by an MIT-style +// license that can be found in the LICENSE file. + +package jsonschema + +import ( + "bytes" + "cmp" + "encoding/json" + "errors" + "fmt" + "iter" + "maps" + "math" + "reflect" + "slices" +) + +// A Schema is a JSON schema object. +// It supports both draft-07 and the 2020-12 draft specifications: +// - Draft-07: https://json-schema.org/draft-07/draft-handrews-json-schema-01 +// and https://json-schema.org/draft-07/draft-handrews-json-schema-validation-01 +// - Draft 2020-12: https://json-schema.org/draft/2020-12/draft-bhutton-json-schema-01 +// and https://json-schema.org/draft/2020-12/draft-bhutton-json-schema-validation-01 +// +// A Schema value may have non-zero values for more than one field: +// all relevant non-zero fields are used for validation. +// There is one exception to provide more Go type-safety: the Type and Types fields +// are mutually exclusive. +// +// Since this struct is a Go representation of a JSON value, it inherits JSON's +// distinction between nil and empty. Nil slices and maps are considered absent, +// but empty ones are present and affect validation. For example, +// +// Schema{Enum: nil} +// +// is equivalent to an empty schema, so it validates every instance. But +// +// Schema{Enum: []any{}} +// +// requires equality to some slice element, so it vacuously rejects every instance. +type Schema struct { + // core + ID string `json:"$id,omitempty"` + Schema string `json:"$schema,omitempty"` + Ref string `json:"$ref,omitempty"` + Comment string `json:"$comment,omitempty"` + Defs map[string]*Schema `json:"$defs,omitempty"` + Definitions map[string]*Schema `json:"definitions,omitempty"` + + // split draft 7 Dependencies into DependencySchemas and DependencyStrings + DependencySchemas map[string]*Schema `json:"-"` + DependencyStrings map[string][]string `json:"-"` + + Anchor string `json:"$anchor,omitempty"` + DynamicAnchor string `json:"$dynamicAnchor,omitempty"` + DynamicRef string `json:"$dynamicRef,omitempty"` + Vocabulary map[string]bool `json:"$vocabulary,omitempty"` + + // metadata + Title string `json:"title,omitempty"` + Description string `json:"description,omitempty"` + Default json.RawMessage `json:"default,omitempty"` + Deprecated bool `json:"deprecated,omitempty"` + ReadOnly bool `json:"readOnly,omitempty"` + WriteOnly bool `json:"writeOnly,omitempty"` + Examples []any `json:"examples,omitempty"` + + // validation + // Use Type for a single type, or Types for multiple types; never both. + Type string `json:"-"` + Types []string `json:"-"` + Enum []any `json:"enum,omitempty"` + // Const is *any because a JSON null (Go nil) is a valid value. + Const *any `json:"const,omitempty"` + MultipleOf *float64 `json:"multipleOf,omitempty"` + Minimum *float64 `json:"minimum,omitempty"` + Maximum *float64 `json:"maximum,omitempty"` + ExclusiveMinimum *float64 `json:"exclusiveMinimum,omitempty"` + ExclusiveMaximum *float64 `json:"exclusiveMaximum,omitempty"` + MinLength *int `json:"minLength,omitempty"` + MaxLength *int `json:"maxLength,omitempty"` + Pattern string `json:"pattern,omitempty"` + + // arrays + PrefixItems []*Schema `json:"prefixItems,omitempty"` + Items *Schema `json:"-"` + ItemsArray []*Schema `json:"-"` + MinItems *int `json:"minItems,omitempty"` + MaxItems *int `json:"maxItems,omitempty"` + AdditionalItems *Schema `json:"additionalItems,omitempty"` + UniqueItems bool `json:"uniqueItems,omitempty"` + Contains *Schema `json:"contains,omitempty"` + MinContains *int `json:"minContains,omitempty"` // *int, not int: default is 1, not 0 + MaxContains *int `json:"maxContains,omitempty"` + UnevaluatedItems *Schema `json:"unevaluatedItems,omitempty"` + + // objects + MinProperties *int `json:"minProperties,omitempty"` + MaxProperties *int `json:"maxProperties,omitempty"` + Required []string `json:"required,omitempty"` + DependentRequired map[string][]string `json:"dependentRequired,omitempty"` + Properties map[string]*Schema `json:"properties,omitempty"` + PatternProperties map[string]*Schema `json:"patternProperties,omitempty"` + AdditionalProperties *Schema `json:"additionalProperties,omitempty"` + PropertyNames *Schema `json:"propertyNames,omitempty"` + UnevaluatedProperties *Schema `json:"unevaluatedProperties,omitempty"` + + // logic + AllOf []*Schema `json:"allOf,omitempty"` + AnyOf []*Schema `json:"anyOf,omitempty"` + OneOf []*Schema `json:"oneOf,omitempty"` + Not *Schema `json:"not,omitempty"` + + // conditional + If *Schema `json:"if,omitempty"` + Then *Schema `json:"then,omitempty"` + Else *Schema `json:"else,omitempty"` + DependentSchemas map[string]*Schema `json:"dependentSchemas,omitempty"` + + // other + // https://json-schema.org/draft/2020-12/draft-bhutton-json-schema-validation-00#rfc.section.8 + ContentEncoding string `json:"contentEncoding,omitempty"` + ContentMediaType string `json:"contentMediaType,omitempty"` + ContentSchema *Schema `json:"contentSchema,omitempty"` + + // https://json-schema.org/draft/2020-12/draft-bhutton-json-schema-validation-00#rfc.section.7 + Format string `json:"format,omitempty"` + + // Extra allows for additional keywords beyond those specified. + Extra map[string]any `json:"-"` + + // PropertyOrder records the ordering of properties for JSON rendering. + // + // During [For], PropertyOrder is set to the field order, + // if the type used for inference is a struct. + // + // If PropertyOrder is set, it controls the relative ordering of properties in [Schema.MarshalJSON]. + // The rendered JSON first lists any properties that appear in the PropertyOrder slice in the order + // they appear, followed by all other properties that do not appear in the PropertyOrder slice in an + // undefined but deterministic order. + PropertyOrder []string `json:"-"` +} + +// falseSchema returns a new Schema tree that fails to validate any value. +func falseSchema() *Schema { + return &Schema{Not: &Schema{}} +} + +// anchorInfo records the subschema to which an anchor refers, and whether +// the anchor keyword is $anchor or $dynamicAnchor. +type anchorInfo struct { + schema *Schema + dynamic bool +} + +// String returns a short description of the schema. +func (s *Schema) String() string { + if s.ID != "" { + return s.ID + } + if a := cmp.Or(s.Anchor, s.DynamicAnchor); a != "" { + return fmt.Sprintf("anchor %s", a) + } + return "" +} + +// CloneSchemas returns a copy of s. +// The copy is shallow except for sub-schemas, which are themelves copied with CloneSchemas. +// This allows both s and s.CloneSchemas() to appear as sub-schemas of the same parent. +func (s *Schema) CloneSchemas() *Schema { + if s == nil { + return nil + } + s2 := *s + v := reflect.ValueOf(&s2) + for _, info := range schemaFieldInfos { + fv := v.Elem().FieldByIndex(info.sf.Index) + switch info.sf.Type { + case schemaType: + sscss := fv.Interface().(*Schema) + fv.Set(reflect.ValueOf(sscss.CloneSchemas())) + + case schemaSliceType: + slice := fv.Interface().([]*Schema) + slice = slices.Clone(slice) + for i, ss := range slice { + slice[i] = ss.CloneSchemas() + } + fv.Set(reflect.ValueOf(slice)) + + case schemaMapType: + m := fv.Interface().(map[string]*Schema) + m = maps.Clone(m) + for k, ss := range m { + m[k] = ss.CloneSchemas() + } + fv.Set(reflect.ValueOf(m)) + + } + } + return &s2 +} + +func (s *Schema) basicChecks() error { + if s.Type != "" && s.Types != nil { + return errors.New("both Type and Types are set; at most one should be") + } + if s.Defs != nil && s.Definitions != nil { + return errors.New("both Defs and Definitions are set; at most one should be") + } + if s.Items != nil && s.ItemsArray != nil { + return errors.New("both Items and ItemsArray are set; at most one should be") + } + propertyOrderSeen := make(map[string]bool) + for _, val := range s.PropertyOrder { + if _, ok := propertyOrderSeen[val]; ok { + // Duplicate found + return fmt.Errorf("property order slice cannot contain duplicate entries, found duplicate %q", val) + } + propertyOrderSeen[val] = true + } + + for key := range s.DependencySchemas { + // Check if the key exists in the dependency strings map + if _, exists := s.DependencyStrings[key]; exists { + return fmt.Errorf("dependency key %q cannot be defined as both a schema and a string array", key) + } + } + return nil +} + +type schemaWithoutMethods Schema // doesn't implement json.{Unm,M}arshaler + +func (s Schema) MarshalJSON() ([]byte, error) { + // NOTE: Use a value receiver here to avoid the encoding/json bugs + // described in golang/go#22967, golang/go#33993, and golang/go#55890. + // With a pointer receiver, MarshalJSON is only called for Schema in + // some cases (for example when the field value is addressable, or not + // stored as a map value), which leads to inconsistent JSON encoding. + // A value receiver makes Schema itself implement json.Marshaler and + // ensures that encoding/json always calls this method. + if err := s.basicChecks(); err != nil { + return nil, err + } + // Marshal either Type or Types as "type". + var typ any + switch { + case s.Type != "": + typ = s.Type + case s.Types != nil: + typ = s.Types + } + + var items any + switch { + case s.Items != nil: + items = s.Items + case s.ItemsArray != nil: + items = s.ItemsArray + } + + var dep map[string]any + size := len(s.DependencySchemas) + len(s.DependencyStrings) + if size > 0 { + dep = make(map[string]any, size) + for k, v := range s.DependencySchemas { + dep[k] = v + } + for k, v := range s.DependencyStrings { + dep[k] = v + } + } + + ms := struct { + Type any `json:"type,omitempty"` + Properties json.Marshaler `json:"properties,omitempty"` + Dependencies map[string]any `json:"dependencies,omitempty"` + Items any `json:"items,omitempty"` + *schemaWithoutMethods + }{ + Type: typ, + Dependencies: dep, + Items: items, + schemaWithoutMethods: (*schemaWithoutMethods)(&s), + } + // Marshal properties, even if the empty map (but not nil). + if s.Properties != nil { + ms.Properties = orderedProperties{ + props: s.Properties, + order: s.PropertyOrder, + } + } + + bs, err := marshalStructWithMap(&ms, "Extra") + if err != nil { + return nil, err + } + // Marshal {} as true and {"not": {}} as false. + // It is wasteful to do this here instead of earlier, but much easier. + switch { + case bytes.Equal(bs, []byte(`{}`)): + bs = []byte("true") + case bytes.Equal(bs, []byte(`{"not":true}`)): + bs = []byte("false") + } + return bs, nil +} + +// orderedProperties is a helper to marshal the properties map in a specific order. +type orderedProperties struct { + props map[string]*Schema + order []string +} + +func (op orderedProperties) MarshalJSON() ([]byte, error) { + var buf bytes.Buffer + buf.WriteByte('{') + + first := true + processed := make(map[string]bool, len(op.props)) + + // Helper closure to write "key": value + writeEntry := func(key string, val *Schema) error { + if !first { + buf.WriteByte(',') + } + first = false + + // Marshal the Key + keyBytes, err := json.Marshal(key) + if err != nil { + return err + } + buf.Write(keyBytes) + + buf.WriteByte(':') + + // Marshal the Value + valBytes, err := json.Marshal(val) + if err != nil { + return err + } + buf.Write(valBytes) + return nil + } + + // Write keys explicitly listed in PropertyOrder + for _, name := range op.order { + if prop, ok := op.props[name]; ok { + if err := writeEntry(name, prop); err != nil { + return nil, err + } + processed[name] = true + } + } + + // Write any remaining keys + var remaining []string + for name := range op.props { + if !processed[name] { + remaining = append(remaining, name) + } + } + + // Sort the slice alphabetically + slices.Sort(remaining) + + for _, name := range remaining { + if err := writeEntry(name, op.props[name]); err != nil { + return nil, err + } + } + + buf.WriteByte('}') + return buf.Bytes(), nil +} + +func (s *Schema) UnmarshalJSON(data []byte) error { + // A JSON boolean is a valid schema. + var b bool + if err := json.Unmarshal(data, &b); err == nil { + if b { + // true is the empty schema, which validates everything. + *s = Schema{} + } else { + // false is the schema that validates nothing. + *s = *falseSchema() + } + return nil + } + + ms := struct { + Type json.RawMessage `json:"type,omitempty"` + Dependencies map[string]json.RawMessage `json:"dependencies,omitempty"` + Items json.RawMessage `json:"items,omitempty"` + Const json.RawMessage `json:"const,omitempty"` + MinLength *integer `json:"minLength,omitempty"` + MaxLength *integer `json:"maxLength,omitempty"` + MinItems *integer `json:"minItems,omitempty"` + MaxItems *integer `json:"maxItems,omitempty"` + MinProperties *integer `json:"minProperties,omitempty"` + MaxProperties *integer `json:"maxProperties,omitempty"` + MinContains *integer `json:"minContains,omitempty"` + MaxContains *integer `json:"maxContains,omitempty"` + + *schemaWithoutMethods + }{ + schemaWithoutMethods: (*schemaWithoutMethods)(s), + } + if err := unmarshalStructWithMap(data, &ms, "Extra"); err != nil { + return err + } + // Unmarshal "type" as either Type or Types. + var err error + if len(ms.Type) > 0 { + switch ms.Type[0] { + case '"': + err = json.Unmarshal(ms.Type, &s.Type) + case '[': + err = json.Unmarshal(ms.Type, &s.Types) + default: + err = fmt.Errorf(`invalid value for "type": %q`, ms.Type) + } + } + if err != nil { + return err + } + + // Unmarshal "items" as either Items or ItemsArray. + if len(ms.Items) > 0 { + switch ms.Items[0] { + case '[': + var schemas []*Schema + err = json.Unmarshal(ms.Items, &schemas) + s.ItemsArray = schemas + default: + var schema Schema + err = json.Unmarshal(ms.Items, &schema) + s.Items = &schema + } + } + if err != nil { + return err + } + + // Unmarshal "Dependencies" values as either string arrays or schemas + // and assign them to specific map DependencySchemas or DependencyStrings. + for k, v := range ms.Dependencies { + if len(v) > 0 { + switch v[0] { + case '[': + var dstrings []string + err = json.Unmarshal(v, &dstrings) + if s.DependencyStrings == nil { + s.DependencyStrings = make(map[string][]string) + } + s.DependencyStrings[k] = dstrings + default: + var dschema Schema + err = json.Unmarshal(v, &dschema) + if s.DependencySchemas == nil { + s.DependencySchemas = make(map[string]*Schema) + } + s.DependencySchemas[k] = &dschema + } + } + if err != nil { + return err + } + } + + unmarshalAnyPtr := func(p **any, raw json.RawMessage) error { + if len(raw) == 0 { + return nil + } + if bytes.Equal(raw, []byte("null")) { + *p = new(any) + return nil + } + return json.Unmarshal(raw, p) + } + + // Setting Const to a pointer to null will marshal properly, but won't + // unmarshal: the *any is set to nil, not a pointer to nil. + if err := unmarshalAnyPtr(&s.Const, ms.Const); err != nil { + return err + } + + set := func(dst **int, src *integer) { + if src != nil { + *dst = Ptr(int(*src)) + } + } + + set(&s.MinLength, ms.MinLength) + set(&s.MaxLength, ms.MaxLength) + set(&s.MinItems, ms.MinItems) + set(&s.MaxItems, ms.MaxItems) + set(&s.MinProperties, ms.MinProperties) + set(&s.MaxProperties, ms.MaxProperties) + set(&s.MinContains, ms.MinContains) + set(&s.MaxContains, ms.MaxContains) + + return nil +} + +type integer int32 // for the integer-valued fields of Schema + +func (ip *integer) UnmarshalJSON(data []byte) error { + if len(data) == 0 { + // nothing to do + return nil + } + // If there is a decimal point, src is a floating-point number. + var i int64 + if bytes.ContainsRune(data, '.') { + var f float64 + if err := json.Unmarshal(data, &f); err != nil { + return errors.New("not a number") + } + i = int64(f) + if float64(i) != f { + return errors.New("not an integer value") + } + } else { + if err := json.Unmarshal(data, &i); err != nil { + return errors.New("cannot be unmarshaled into an int") + } + } + // Ensure behavior is the same on both 32-bit and 64-bit systems. + if i < math.MinInt32 || i > math.MaxInt32 { + return errors.New("integer is out of range") + } + *ip = integer(i) + return nil +} + +// Ptr returns a pointer to a new variable whose value is x. +func Ptr[T any](x T) *T { return &x } + +// every applies f preorder to every schema under s including s. +// The second argument to f is the path to the schema appended to the argument path. +// It stops when f returns false. +func (s *Schema) every(f func(*Schema) bool) bool { + return f(s) && s.everyChild(func(s *Schema) bool { return s.every(f) }) +} + +// everyChild reports whether f is true for every immediate child schema of s. +func (s *Schema) everyChild(f func(*Schema) bool) bool { + v := reflect.ValueOf(s) + for _, info := range schemaFieldInfos { + fv := v.Elem().FieldByIndex(info.sf.Index) + switch info.sf.Type { + case schemaType: + // A field that contains an individual schema. A nil is valid: it just means the field isn't present. + c := fv.Interface().(*Schema) + if c != nil && !f(c) { + return false + } + + case schemaSliceType: + slice := fv.Interface().([]*Schema) + for _, c := range slice { + if !f(c) { + return false + } + } + + case schemaMapType: + // Sort keys for determinism. + m := fv.Interface().(map[string]*Schema) + for _, k := range slices.Sorted(maps.Keys(m)) { + if !f(m[k]) { + return false + } + } + } + } + + return true +} + +// all wraps every in an iterator. +func (s *Schema) all() iter.Seq[*Schema] { + return func(yield func(*Schema) bool) { s.every(yield) } +} + +// children wraps everyChild in an iterator. +func (s *Schema) children() iter.Seq[*Schema] { + return func(yield func(*Schema) bool) { s.everyChild(yield) } +} + +var ( + schemaType = reflect.TypeFor[*Schema]() + schemaSliceType = reflect.TypeFor[[]*Schema]() + schemaMapType = reflect.TypeFor[map[string]*Schema]() +) + +type structFieldInfo struct { + sf reflect.StructField + jsonName string +} + +var ( + // the visible fields of Schema that have a JSON name, sorted by that name + schemaFieldInfos []structFieldInfo + // map from JSON name to field + schemaFieldMap = map[string]reflect.StructField{} +) + +func init() { + t := reflect.VisibleFields(reflect.TypeFor[Schema]()) + for _, sf := range t { + info := fieldJSONInfo(sf) + if !info.omit { + schemaFieldInfos = append(schemaFieldInfos, structFieldInfo{sf, info.name}) + } else { + // jsoninfo.name is used to build the info paths. The items and dependencies are ommited, + // since the original fields are separated to handle the union types supported in json and + // these fields have custom marshalling and unmarshalling logic. + // we still need these fields in schemaFieldInfos for creating schema trees and calculating paths and refs. + // so we manually create them and assign the jsonName to the original field json name. + switch sf.Name { + case "Items", "ItemsArray": + schemaFieldInfos = append(schemaFieldInfos, structFieldInfo{sf, "items"}) + case "DependencySchemas", "DependencyStrings": + schemaFieldInfos = append(schemaFieldInfos, structFieldInfo{sf, "dependencies"}) + } + } + } + // The value of "dependencies" this sort of schemaFieldInfos. + // This sort is unstable and is comparing the json.names of DependencyStrings and DependencySchemas which are both "dependencies". + // Since the sort is unstable it cannot be guarantied that "dependencies" has the DependencySchemas value. + slices.SortFunc(schemaFieldInfos, func(i1, i2 structFieldInfo) int { + return cmp.Compare(i1.jsonName, i2.jsonName) + }) + for _, info := range schemaFieldInfos { + schemaFieldMap[info.jsonName] = info.sf + } +} diff --git a/mcp/dsx-exchange-mcp/vendor/github.com/google/jsonschema-go/jsonschema/util.go b/mcp/dsx-exchange-mcp/vendor/github.com/google/jsonschema-go/jsonschema/util.go new file mode 100644 index 0000000..5cfa27d --- /dev/null +++ b/mcp/dsx-exchange-mcp/vendor/github.com/google/jsonschema-go/jsonschema/util.go @@ -0,0 +1,463 @@ +// Copyright 2025 The JSON Schema Go Project Authors. All rights reserved. +// Use of this source code is governed by an MIT-style +// license that can be found in the LICENSE file. + +package jsonschema + +import ( + "bytes" + "cmp" + "encoding/binary" + "encoding/json" + "fmt" + "hash/maphash" + "math" + "math/big" + "reflect" + "slices" + "strings" + "sync" +) + +// Equal reports whether two Go values representing JSON values are equal according +// to the JSON Schema spec. +// The values must not contain cycles. +// See https://json-schema.org/draft/2020-12/json-schema-core#section-4.2.2. +// It behaves like reflect.DeepEqual, except that numbers are compared according +// to mathematical equality. +func Equal(x, y any) bool { + return equalValue(reflect.ValueOf(x), reflect.ValueOf(y)) +} + +func equalValue(x, y reflect.Value) bool { + // Copied from src/reflect/deepequal.go, omitting the visited check (because JSON + // values are trees). + if !x.IsValid() || !y.IsValid() { + return x.IsValid() == y.IsValid() + } + + // Treat numbers specially. + rx, ok1 := jsonNumber(x) + ry, ok2 := jsonNumber(y) + if ok1 && ok2 { + return rx.Cmp(ry) == 0 + } + if x.Kind() != y.Kind() { + return false + } + switch x.Kind() { + case reflect.Array: + if x.Len() != y.Len() { + return false + } + for i := range x.Len() { + if !equalValue(x.Index(i), y.Index(i)) { + return false + } + } + return true + case reflect.Slice: + if x.IsNil() != y.IsNil() { + return false + } + if x.Len() != y.Len() { + return false + } + if x.UnsafePointer() == y.UnsafePointer() { + return true + } + // Special case for []byte, which is common. + if x.Type().Elem().Kind() == reflect.Uint8 && x.Type() == y.Type() { + return bytes.Equal(x.Bytes(), y.Bytes()) + } + for i := range x.Len() { + if !equalValue(x.Index(i), y.Index(i)) { + return false + } + } + return true + case reflect.Interface: + if x.IsNil() || y.IsNil() { + return x.IsNil() == y.IsNil() + } + return equalValue(x.Elem(), y.Elem()) + case reflect.Pointer: + if x.UnsafePointer() == y.UnsafePointer() { + return true + } + return equalValue(x.Elem(), y.Elem()) + case reflect.Struct: + t := x.Type() + if t != y.Type() { + return false + } + for i := range t.NumField() { + sf := t.Field(i) + if !sf.IsExported() { + continue + } + if !equalValue(x.FieldByIndex(sf.Index), y.FieldByIndex(sf.Index)) { + return false + } + } + return true + case reflect.Map: + if x.IsNil() != y.IsNil() { + return false + } + if x.Len() != y.Len() { + return false + } + if x.UnsafePointer() == y.UnsafePointer() { + return true + } + iter := x.MapRange() + for iter.Next() { + vx := iter.Value() + vy := y.MapIndex(iter.Key()) + if !vy.IsValid() || !equalValue(vx, vy) { + return false + } + } + return true + case reflect.Func: + if x.Type() != y.Type() { + return false + } + if x.IsNil() && y.IsNil() { + return true + } + panic("cannot compare functions") + case reflect.String: + return x.String() == y.String() + case reflect.Bool: + return x.Bool() == y.Bool() + // Ints, uints and floats handled in jsonNumber, at top of function. + default: + panic(fmt.Sprintf("unsupported kind: %s", x.Kind())) + } +} + +// hashValue adds v to the data hashed by h. v must not have cycles. +// hashValue panics if the value contains functions or channels, or maps whose +// key type is not string. +// It ignores unexported fields of structs. +// Calls to hashValue with the equal values (in the sense +// of [Equal]) result in the same sequence of values written to the hash. +func hashValue(h *maphash.Hash, v reflect.Value) { + // TODO: replace writes of basic types with WriteComparable in 1.24. + + writeUint := func(u uint64) { + var buf [8]byte + binary.BigEndian.PutUint64(buf[:], u) + h.Write(buf[:]) + } + + var write func(reflect.Value) + write = func(v reflect.Value) { + if r, ok := jsonNumber(v); ok { + // We want 1.0 and 1 to hash the same. + // big.Rats are always normalized, so they will be. + // We could do this more efficiently by handling the int and float cases + // separately, but that's premature. + writeUint(uint64(r.Sign() + 1)) + h.Write(r.Num().Bytes()) + h.Write(r.Denom().Bytes()) + return + } + switch v.Kind() { + case reflect.Invalid: + h.WriteByte(0) + case reflect.String: + h.WriteString(v.String()) + case reflect.Bool: + if v.Bool() { + h.WriteByte(1) + } else { + h.WriteByte(0) + } + case reflect.Complex64, reflect.Complex128: + c := v.Complex() + writeUint(math.Float64bits(real(c))) + writeUint(math.Float64bits(imag(c))) + case reflect.Array, reflect.Slice: + // Although we could treat []byte more efficiently, + // JSON values are unlikely to contain them. + writeUint(uint64(v.Len())) + for i := range v.Len() { + write(v.Index(i)) + } + case reflect.Interface, reflect.Pointer: + write(v.Elem()) + case reflect.Struct: + t := v.Type() + for i := range t.NumField() { + if sf := t.Field(i); sf.IsExported() { + write(v.FieldByIndex(sf.Index)) + } + } + case reflect.Map: + if v.Type().Key().Kind() != reflect.String { + panic("map with non-string key") + } + // Sort the keys so the hash is deterministic. + keys := v.MapKeys() + // Write the length. That distinguishes between, say, two consecutive + // maps with disjoint keys from one map that has the items of both. + writeUint(uint64(len(keys))) + slices.SortFunc(keys, func(x, y reflect.Value) int { return cmp.Compare(x.String(), y.String()) }) + for _, k := range keys { + write(k) + write(v.MapIndex(k)) + } + // Ints, uints and floats handled in jsonNumber, at top of function. + default: + panic(fmt.Sprintf("unsupported kind: %s", v.Kind())) + } + } + + write(v) +} + +// jsonNumber converts a numeric value or a json.Number to a [big.Rat]. +// If v is not a number, it returns nil, false. +func jsonNumber(v reflect.Value) (*big.Rat, bool) { + r := new(big.Rat) + switch { + case !v.IsValid(): + return nil, false + case v.CanInt(): + r.SetInt64(v.Int()) + case v.CanUint(): + r.SetUint64(v.Uint()) + case v.CanFloat(): + r.SetFloat64(v.Float()) + default: + jn, ok := v.Interface().(json.Number) + if !ok { + return nil, false + } + if _, ok := r.SetString(jn.String()); !ok { + // This can fail in rare cases; for example, "1e9999999". + // That is a valid JSON number, since the spec puts no limit on the size + // of the exponent. + return nil, false + } + } + return r, true +} + +// jsonType returns a string describing the type of the JSON value, +// as described in the JSON Schema specification: +// https://json-schema.org/draft/2020-12/draft-bhutton-json-schema-validation-01#section-6.1.1. +// It returns "", false if the value is not valid JSON. +func jsonType(v reflect.Value) (string, bool) { + if !v.IsValid() { + // Not v.IsNil(): a nil []any is still a JSON array. + return "null", true + } + if v.CanInt() || v.CanUint() { + return "integer", true + } + if v.CanFloat() { + if _, f := math.Modf(v.Float()); f == 0 { + return "integer", true + } + return "number", true + } + switch v.Kind() { + case reflect.Bool: + return "boolean", true + case reflect.String: + return "string", true + case reflect.Slice, reflect.Array: + return "array", true + case reflect.Map, reflect.Struct: + return "object", true + default: + return "", false + } +} + +func assert(cond bool, msg string) { + if !cond { + panic("assertion failed: " + msg) + } +} + +// marshalStructWithMap marshals its first argument to JSON, treating the field named +// mapField as an embedded map. The first argument must be a pointer to +// a struct. The underlying type of mapField must be a map[string]any, and it must have +// a "-" json tag, meaning it will not be marshaled. +// +// For example, given this struct: +// +// type S struct { +// A int +// Extra map[string] any `json:"-"` +// } +// +// and this value: +// +// s := S{A: 1, Extra: map[string]any{"B": 2}} +// +// the call marshalJSONWithMap(s, "Extra") would return +// +// {"A": 1, "B": 2} +// +// It is an error if the map contains the same key as another struct field's +// JSON name. +// +// marshalStructWithMap calls json.Marshal on a value of type T, so T must not +// have a MarshalJSON method that calls this function, on pain of infinite regress. +// +// Note that there is a similar function in mcp/util.go, but they are not the same. +// Here the function requires `-` json tag, does not clear the mapField map, +// and handles embedded struct due to the implementation of jsonNames in this package. +// +// TODO: avoid this restriction on T by forcing it to marshal in a default way. +// See https://go.dev/play/p/EgXKJHxEx_R. +func marshalStructWithMap[T any](s *T, mapField string) ([]byte, error) { + // Marshal the struct and the map separately, and concatenate the bytes. + // This strategy is dramatically less complicated than + // constructing a synthetic struct or map with the combined keys. + if s == nil { + return []byte("null"), nil + } + s2 := *s + vMapField := reflect.ValueOf(&s2).Elem().FieldByName(mapField) + mapVal := vMapField.Interface().(map[string]any) + + // Check for duplicates. + names := jsonNames(reflect.TypeFor[T]()) + for key := range mapVal { + if names[key] { + return nil, fmt.Errorf("map key %q duplicates struct field", key) + } + } + + structBytes, err := json.Marshal(s2) + if err != nil { + return nil, fmt.Errorf("marshalStructWithMap(%+v): %w", s, err) + } + if len(mapVal) == 0 { + return structBytes, nil + } + mapBytes, err := json.Marshal(mapVal) + if err != nil { + return nil, err + } + if len(structBytes) == 2 { // must be "{}" + return mapBytes, nil + } + // "{X}" + "{Y}" => "{X,Y}" + res := append(structBytes[:len(structBytes)-1], ',') + res = append(res, mapBytes[1:]...) + return res, nil +} + +// unmarshalStructWithMap is the inverse of marshalStructWithMap. +// T has the same restrictions as in that function. +// +// Note that there is a similar function in mcp/util.go, but they are not the same. +// Here jsonNames also returns fields from embedded structs, hence this function +// handles embedded structs as well. +func unmarshalStructWithMap[T any](data []byte, v *T, mapField string) error { + // Unmarshal into the struct, ignoring unknown fields. + if err := json.Unmarshal(data, v); err != nil { + return err + } + // Unmarshal into the map. + m := map[string]any{} + if err := json.Unmarshal(data, &m); err != nil { + return err + } + // Delete from the map the fields of the struct. + for n := range jsonNames(reflect.TypeFor[T]()) { + delete(m, n) + } + if len(m) != 0 { + reflect.ValueOf(v).Elem().FieldByName(mapField).Set(reflect.ValueOf(m)) + } + return nil +} + +var jsonNamesMap sync.Map // from reflect.Type to map[string]bool + +// jsonNames returns the set of JSON object keys that t will marshal into, +// including fields from embedded structs in t. +// t must be a struct type. +// +// Note that there is a similar function in mcp/util.go, but they are not the same +// Here the function recurses over embedded structs and includes fields from them. +func jsonNames(t reflect.Type) map[string]bool { + // Lock not necessary: at worst we'll duplicate work. + if val, ok := jsonNamesMap.Load(t); ok { + return val.(map[string]bool) + } + m := map[string]bool{} + for i := range t.NumField() { + field := t.Field(i) + // handle embedded structs + if field.Anonymous { + fieldType := field.Type + if fieldType.Kind() == reflect.Ptr { + fieldType = fieldType.Elem() + } + for n := range jsonNames(fieldType) { + m[n] = true + } + continue + } + info := fieldJSONInfo(field) + if !info.omit { + m[info.name] = true + } + } + jsonNamesMap.Store(t, m) + return m +} + +type jsonInfo struct { + omit bool // unexported or first tag element is "-" + name string // Go field name or first tag element. Empty if omit is true. + settings map[string]bool // "omitempty", "omitzero", etc. +} + +// fieldJSONInfo reports information about how encoding/json +// handles the given struct field. +// If the field is unexported, jsonInfo.omit is true and no other jsonInfo field +// is populated. +// If the field is exported and has no tag, then name is the field's name and all +// other fields are false. +// Otherwise, the information is obtained from the tag. +func fieldJSONInfo(f reflect.StructField) jsonInfo { + if !f.IsExported() { + return jsonInfo{omit: true} + } + info := jsonInfo{name: f.Name} + if tag, ok := f.Tag.Lookup("json"); ok { + name, rest, found := strings.Cut(tag, ",") + // "-" means omit, but "-," means the name is "-" + if name == "-" && !found { + return jsonInfo{omit: true} + } + if name != "" { + info.name = name + } + if len(rest) > 0 { + info.settings = map[string]bool{} + for _, s := range strings.Split(rest, ",") { + info.settings[s] = true + } + } + } + return info +} + +// wrapf wraps *errp with the given formatted message if *errp is not nil. +func wrapf(errp *error, format string, args ...any) { + if *errp != nil { + *errp = fmt.Errorf("%s: %w", fmt.Sprintf(format, args...), *errp) + } +} diff --git a/mcp/dsx-exchange-mcp/vendor/github.com/google/jsonschema-go/jsonschema/validate.go b/mcp/dsx-exchange-mcp/vendor/github.com/google/jsonschema-go/jsonschema/validate.go new file mode 100644 index 0000000..bbef590 --- /dev/null +++ b/mcp/dsx-exchange-mcp/vendor/github.com/google/jsonschema-go/jsonschema/validate.go @@ -0,0 +1,905 @@ +// Copyright 2025 The JSON Schema Go Project Authors. All rights reserved. +// Use of this source code is governed by an MIT-style +// license that can be found in the LICENSE file. + +package jsonschema + +import ( + "encoding/json" + "errors" + "fmt" + "hash/maphash" + "iter" + "math" + "math/big" + "reflect" + "slices" + "strings" + "sync" + "unicode/utf8" +) + +// The values of the "$schema" keyword for the versions that we can validate. +const ( + draft7SchemaVersion = "http://json-schema.org/draft-07/schema#" + draft7SecSchemaVersion = "https://json-schema.org/draft-07/schema#" + draft202012SchemaVersion = "https://json-schema.org/draft/2020-12/schema" +) + +// isValidSchemaVersion checks if the given schema version is supported +func isValidSchemaVersion(version string) bool { + return version == "" || version == draft7SchemaVersion || version == draft7SecSchemaVersion || version == draft202012SchemaVersion +} + +// Validate validates the instance, which must be a JSON value, against the schema. +// It returns nil if validation is successful or an error if it is not. +// If the schema type is "object", instance should be a map[string]any. +func (rs *Resolved) Validate(instance any) error { + if s := rs.root.Schema; !isValidSchemaVersion(s) { + return fmt.Errorf("cannot validate version %s, supported versions: draft-07 and draft 2020-12", s) + } + st := &state{rs: rs} + return st.validate(reflect.ValueOf(instance), st.rs.root, nil) +} + +// validateDefaults walks the schema tree. If it finds a default, it validates it +// against the schema containing it. +// +// TODO(jba): account for dynamic refs. This algorithm simple-mindedly +// treats each schema with a default as its own root. +func (rs *Resolved) validateDefaults() error { + if s := rs.root.Schema; !isValidSchemaVersion(s) { + return fmt.Errorf("cannot validate version %s, supported versions: draft-07 and draft 2020-12", s) + } + st := &state{rs: rs} + for s := range rs.root.all() { + // We checked for nil schemas in [Schema.Resolve]. + assert(s != nil, "nil schema") + if s.DynamicRef != "" { + return fmt.Errorf("jsonschema: %s: validateDefaults does not support dynamic refs", rs.schemaString(s)) + } + if s.Default != nil { + var d any + if err := json.Unmarshal(s.Default, &d); err != nil { + return fmt.Errorf("unmarshaling default value of schema %s: %w", rs.schemaString(s), err) + } + if err := st.validate(reflect.ValueOf(d), s, nil); err != nil { + return err + } + } + } + return nil +} + +// state is the state of single call to ResolvedSchema.Validate. +type state struct { + rs *Resolved + // stack holds the schemas from recursive calls to validate. + // These are the "dynamic scopes" used to resolve dynamic references. + // https://json-schema.org/draft/2020-12/json-schema-core#scopes + stack []*Schema +} + +// validate validates the reflected value of the instance. +func (st *state) validate(instance reflect.Value, schema *Schema, callerAnns *annotations) (err error) { + defer wrapf(&err, "validating %s", st.rs.schemaString(schema)) + + // Maintain a stack for dynamic schema resolution. + st.stack = append(st.stack, schema) // push + defer func() { + st.stack = st.stack[:len(st.stack)-1] // pop + }() + + // We checked for nil schemas in [Schema.Resolve]. + assert(schema != nil, "nil schema") + + // Step through interfaces and pointers. + for instance.Kind() == reflect.Pointer || instance.Kind() == reflect.Interface { + instance = instance.Elem() + } + + schemaInfo := st.rs.resolvedInfos[schema] + + var anns annotations // all the annotations for this call and child calls + // $ref: https://json-schema.org/draft/2020-12/json-schema-core#section-8.2.3.1 + if schema.Ref != "" { + if err := st.validate(instance, schemaInfo.resolvedRef, &anns); err != nil { + return err + } + // https://json-schema.org/draft-07/draft-handrews-json-schema-01#rfc.section.8.3 + // "All other properties in a "$ref" object MUST be ignored." + if st.rs.draft == draft7 { + return nil + } + } + + // type: https://json-schema.org/draft/2020-12/draft-bhutton-json-schema-validation-01#section-6.1.1 + if schema.Type != "" || schema.Types != nil { + gotType, ok := jsonType(instance) + if !ok { + return fmt.Errorf("type: %v of type %[1]T is not a valid JSON value", instance) + } + if schema.Type != "" { + // "number" subsumes integers + if !(gotType == schema.Type || + gotType == "integer" && schema.Type == "number") { + return fmt.Errorf("type: %v has type %q, want %q", instance, gotType, schema.Type) + } + } else { + if !(slices.Contains(schema.Types, gotType) || (gotType == "integer" && slices.Contains(schema.Types, "number"))) { + return fmt.Errorf("type: %v has type %q, want one of %q", + instance, gotType, strings.Join(schema.Types, ", ")) + } + } + } + // enum: https://json-schema.org/draft/2020-12/draft-bhutton-json-schema-validation-01#section-6.1.2 + if schema.Enum != nil { + ok := false + for _, e := range schema.Enum { + if equalValue(reflect.ValueOf(e), instance) { + ok = true + break + } + } + if !ok { + return fmt.Errorf("enum: %v does not equal any of: %v", instance, schema.Enum) + } + } + + // const: https://json-schema.org/draft/2020-12/draft-bhutton-json-schema-validation-01#section-6.1.3 + if schema.Const != nil { + if !equalValue(reflect.ValueOf(*schema.Const), instance) { + return fmt.Errorf("const: %v does not equal %v", instance, *schema.Const) + } + } + + // numbers: https://json-schema.org/draft/2020-12/draft-bhutton-json-schema-validation-01#section-6.2 + if schema.MultipleOf != nil || schema.Minimum != nil || schema.Maximum != nil || schema.ExclusiveMinimum != nil || schema.ExclusiveMaximum != nil { + n, ok := jsonNumber(instance) + if ok { // these keywords don't apply to non-numbers + if schema.MultipleOf != nil { + // TODO: validate MultipleOf as non-zero. + // The test suite assumes floats. + nf, _ := n.Float64() // don't care if it's exact or not + if _, f := math.Modf(nf / *schema.MultipleOf); f != 0 { + return fmt.Errorf("multipleOf: %s is not a multiple of %f", n, *schema.MultipleOf) + } + } + + m := new(big.Rat) // reuse for all of the following + cmp := func(f float64) int { return n.Cmp(m.SetFloat64(f)) } + + if schema.Minimum != nil && cmp(*schema.Minimum) < 0 { + return fmt.Errorf("minimum: %s is less than %f", n, *schema.Minimum) + } + if schema.Maximum != nil && cmp(*schema.Maximum) > 0 { + return fmt.Errorf("maximum: %s is greater than %f", n, *schema.Maximum) + } + if schema.ExclusiveMinimum != nil && cmp(*schema.ExclusiveMinimum) <= 0 { + return fmt.Errorf("exclusiveMinimum: %s is less than or equal to %f", n, *schema.ExclusiveMinimum) + } + if schema.ExclusiveMaximum != nil && cmp(*schema.ExclusiveMaximum) >= 0 { + return fmt.Errorf("exclusiveMaximum: %s is greater than or equal to %f", n, *schema.ExclusiveMaximum) + } + } + } + + // strings: https://json-schema.org/draft/2020-12/draft-bhutton-json-schema-validation-01#section-6.3 + if instance.Kind() == reflect.String && (schema.MinLength != nil || schema.MaxLength != nil || schema.Pattern != "") { + str := instance.String() + n := utf8.RuneCountInString(str) + if schema.MinLength != nil { + if m := *schema.MinLength; n < m { + return fmt.Errorf("minLength: %q contains %d Unicode code points, fewer than %d", str, n, m) + } + } + if schema.MaxLength != nil { + if m := *schema.MaxLength; n > m { + return fmt.Errorf("maxLength: %q contains %d Unicode code points, more than %d", str, n, m) + } + } + + if schema.Pattern != "" && !schemaInfo.pattern.MatchString(str) { + return fmt.Errorf("pattern: %q does not match regular expression %q", str, schema.Pattern) + } + } + + // $dynamicRef: https://json-schema.org/draft/2020-12/json-schema-core#section-8.2.3.2 + if schema.DynamicRef != "" { + // The ref behaves lexically or dynamically, but not both. + assert((schemaInfo.resolvedDynamicRef == nil) != (schemaInfo.dynamicRefAnchor == ""), + "DynamicRef not resolved properly") + if schemaInfo.resolvedDynamicRef != nil { + // Same as $ref. + if err := st.validate(instance, schemaInfo.resolvedDynamicRef, &anns); err != nil { + return err + } + } else { + // Dynamic behavior. + // Look for the base of the outermost schema on the stack with this dynamic + // anchor. (Yes, outermost: the one farthest from here. This the opposite + // of how ordinary dynamic variables behave.) + // Why the base of the schema being validated and not the schema itself? + // Because the base is the scope for anchors. In fact it's possible to + // refer to a schema that is not on the stack, but a child of some base + // on the stack. + // For an example, search for "detached" in testdata/draft2020-12/dynamicRef.json. + var dynamicSchema *Schema + for _, s := range st.stack { + base := st.rs.resolvedInfos[s].base + info, ok := st.rs.resolvedInfos[base].anchors[schemaInfo.dynamicRefAnchor] + if ok && info.dynamic { + dynamicSchema = info.schema + break + } + } + if dynamicSchema == nil { + return fmt.Errorf("missing dynamic anchor %q", schemaInfo.dynamicRefAnchor) + } + if err := st.validate(instance, dynamicSchema, &anns); err != nil { + return err + } + } + } + + // logic + // https://json-schema.org/draft/2020-12/json-schema-core#section-10.2 + // These must happen before arrays and objects because if they evaluate an item or property, + // then the unevaluatedItems/Properties schemas don't apply to it. + // See https://json-schema.org/draft/2020-12/json-schema-core#section-11.2, paragraph 4. + // + // If any of these fail, then validation fails, even if there is an unevaluatedXXX + // keyword in the schema. The spec is unclear about this, but that is the intention. + + valid := func(s *Schema, anns *annotations) bool { return st.validate(instance, s, anns) == nil } + + if schema.AllOf != nil { + for _, ss := range schema.AllOf { + if err := st.validate(instance, ss, &anns); err != nil { + return err + } + } + } + if schema.AnyOf != nil { + // We must visit them all, to collect annotations. + ok := false + for _, ss := range schema.AnyOf { + if valid(ss, &anns) { + ok = true + } + } + if !ok { + return fmt.Errorf("anyOf: did not validate against any of %v", schema.AnyOf) + } + } + if schema.OneOf != nil { + // Exactly one. + var okSchema *Schema + for _, ss := range schema.OneOf { + if valid(ss, &anns) { + if okSchema != nil { + return fmt.Errorf("oneOf: validated against both %v and %v", okSchema, ss) + } + okSchema = ss + } + } + if okSchema == nil { + return fmt.Errorf("oneOf: did not validate against any of %v", schema.OneOf) + } + } + if schema.Not != nil { + // Ignore annotations from "not". + if valid(schema.Not, nil) { + return fmt.Errorf("not: validated against %v", schema.Not) + } + } + if schema.If != nil { + var ss *Schema + if valid(schema.If, &anns) { + ss = schema.Then + } else { + ss = schema.Else + } + if ss != nil { + if err := st.validate(instance, ss, &anns); err != nil { + return err + } + } + } + + // arrays + if instance.Kind() == reflect.Array || instance.Kind() == reflect.Slice { + // Handle both draft-07 and draft 2020-12 + // https://json-schema.org/draft/2020-12/json-schema-core#section-10.3.1 + // This validate call doesn't collect annotations for the items of the instance; they are separate + // instances in their own right. + // TODO(jba): if the test suite doesn't cover this case, add a test. For example, nested arrays. + if st.rs.draft == draft7 { + // For draft-07: additionalItems applies to remaining items after items array. + // If items is a Schema or if items is not set, additionalItems should be ignored + if schema.ItemsArray != nil { + for i, ischema := range schema.ItemsArray { + if i >= instance.Len() { + break // shorter is OK + } + if err := st.validate(instance.Index(i), ischema, nil); err != nil { + return err + } + } + anns.noteEndIndex(min(len(schema.ItemsArray), instance.Len())) + if schema.AdditionalItems != nil { + for i := len(schema.ItemsArray); i < instance.Len(); i++ { + if err := st.validate(instance.Index(i), schema.AdditionalItems, nil); err != nil { + return err + } + } + anns.allItems = true + } + } else if schema.Items != nil { + for i := 0; i < instance.Len(); i++ { + if err := st.validate(instance.Index(i), schema.Items, nil); err != nil { + return err + } + } + // Note that all the items in this array have been validated. + anns.allItems = true + } + } else if st.rs.draft == draft2020 { + // For draft 2020-12: items applies to remaining items after prefixItems + for i, ischema := range schema.PrefixItems { + if i >= instance.Len() { + break // shorter is OK + } + if err := st.validate(instance.Index(i), ischema, nil); err != nil { + return err + } + } + anns.noteEndIndex(min(len(schema.PrefixItems), instance.Len())) + if schema.Items != nil { + for i := len(schema.PrefixItems); i < instance.Len(); i++ { + if err := st.validate(instance.Index(i), schema.Items, nil); err != nil { + return err + } + } + // Note that all the items in this array have been validated. + anns.allItems = true + } + } + nContains := 0 + if schema.Contains != nil { + for i := range instance.Len() { + if err := st.validate(instance.Index(i), schema.Contains, nil); err == nil { + nContains++ + anns.noteIndex(i) + } + } + if nContains == 0 && (schema.MinContains == nil || *schema.MinContains > 0) { + return fmt.Errorf("contains: %s does not have an item matching %s", instance, schema.Contains) + } + } + + // https://json-schema.org/draft/2020-12/draft-bhutton-json-schema-validation-01#section-6.4 + // TODO(jba): check that these next four keywords' values are integers. + if schema.MinContains != nil && schema.Contains != nil { + if m := *schema.MinContains; nContains < m { + return fmt.Errorf("minContains: contains validated %d items, less than %d", nContains, m) + } + } + if schema.MaxContains != nil && schema.Contains != nil { + if m := *schema.MaxContains; nContains > m { + return fmt.Errorf("maxContains: contains validated %d items, greater than %d", nContains, m) + } + } + if schema.MinItems != nil { + if m := *schema.MinItems; instance.Len() < m { + return fmt.Errorf("minItems: array length %d is less than %d", instance.Len(), m) + } + } + if schema.MaxItems != nil { + if m := *schema.MaxItems; instance.Len() > m { + return fmt.Errorf("maxItems: array length %d is greater than %d", instance.Len(), m) + } + } + if schema.UniqueItems { + if instance.Len() > 1 { + // Hash each item and compare the hashes. + // If two hashes differ, the items differ. + // If two hashes are the same, compare the collisions for equality. + // (The same logic as hash table lookup.) + // TODO(jba): Use container/hash.Map when it becomes available (https://go.dev/issue/69559), + hashes := map[uint64][]int{} // from hash to indices + seed := maphash.MakeSeed() + for i := range instance.Len() { + item := instance.Index(i) + var h maphash.Hash + h.SetSeed(seed) + hashValue(&h, item) + hv := h.Sum64() + if sames := hashes[hv]; len(sames) > 0 { + for _, j := range sames { + if equalValue(item, instance.Index(j)) { + return fmt.Errorf("uniqueItems: array items %d and %d are equal", i, j) + } + } + } + hashes[hv] = append(hashes[hv], i) + } + } + } + + // https://json-schema.org/draft/2020-12/json-schema-core#section-11.2 + if schema.UnevaluatedItems != nil && !anns.allItems { + // Apply this subschema to all items in the array that haven't been successfully validated. + // That includes validations by subschemas on the same instance, like allOf. + for i := anns.endIndex; i < instance.Len(); i++ { + if !anns.evaluatedIndexes[i] { + if err := st.validate(instance.Index(i), schema.UnevaluatedItems, nil); err != nil { + return err + } + } + } + anns.allItems = true + } + } + + // objects + // https://json-schema.org/draft/2020-12/json-schema-core#section-10.3.2 + // Validating structs is problematic. See https://github.com/google/jsonschema-go/issues/23. + if instance.Kind() == reflect.Struct { + return errors.New("cannot validate against a struct; see https://github.com/google/jsonschema-go/issues/23 for details") + } + if instance.Kind() == reflect.Map { + if kt := instance.Type().Key(); kt.Kind() != reflect.String { + return fmt.Errorf("map key type %s is not a string", kt) + } + // Track the evaluated properties for just this schema, to support additionalProperties. + // If we used anns here, then we'd be including properties evaluated in subschemas + // from allOf, etc., which additionalProperties shouldn't observe. + evalProps := map[string]bool{} + for prop, subschema := range schema.Properties { + val := property(instance, prop) + if !val.IsValid() { + // It's OK if the instance doesn't have the property. + continue + } + // If the instance is a struct and an optional property has the zero + // value, then we could interpret it as present or missing. Be generous: + // assume it's missing, and thus always validates successfully. + if instance.Kind() == reflect.Struct && val.IsZero() && !schemaInfo.isRequired[prop] { + continue + } + if err := st.validate(val, subschema, nil); err != nil { + return err + } + evalProps[prop] = true + } + if len(schema.PatternProperties) > 0 { + for prop, val := range properties(instance) { + // Check every matching pattern. + for re, schema := range schemaInfo.patternProperties { + if re.MatchString(prop) { + if err := st.validate(val, schema, nil); err != nil { + return err + } + evalProps[prop] = true + } + } + } + } + if schema.AdditionalProperties != nil { + // Special case for a better error message when additional properties is + // 'falsy' + // + // If additionalProperties is {"not":{}} (which is how we + // unmarshal "false"), we can produce a better error message that + // summarizes all the extra properties. Otherwise, we fall back to the + // default validation. + // + // Note: this is much faster than comparing with falseSchema using Equal. + isFalsy := schema.AdditionalProperties.Not != nil && reflect.ValueOf(*schema.AdditionalProperties.Not).IsZero() + if isFalsy { + var disallowed []string + for prop := range properties(instance) { + if !evalProps[prop] { + disallowed = append(disallowed, prop) + } + } + if len(disallowed) > 0 { + return fmt.Errorf("unexpected additional properties %q", disallowed) + } + } else { + // Apply to all properties not handled above. + for prop, val := range properties(instance) { + if !evalProps[prop] { + if err := st.validate(val, schema.AdditionalProperties, nil); err != nil { + return err + } + evalProps[prop] = true + } + } + } + } + anns.noteProperties(evalProps) + if schema.PropertyNames != nil { + // Note: properties unnecessarily fetches each value. We could define a propertyNames function + // if performance ever matters. + for prop := range properties(instance) { + if err := st.validate(reflect.ValueOf(prop), schema.PropertyNames, nil); err != nil { + return err + } + } + } + + // https://json-schema.org/draft/2020-12/draft-bhutton-json-schema-validation-01#section-6.5 + var min, max int + if schema.MinProperties != nil || schema.MaxProperties != nil { + min, max = numPropertiesBounds(instance, schemaInfo.isRequired) + } + if schema.MinProperties != nil { + if n, m := max, *schema.MinProperties; n < m { + return fmt.Errorf("minProperties: object has %d properties, less than %d", n, m) + } + } + if schema.MaxProperties != nil { + if n, m := min, *schema.MaxProperties; n > m { + return fmt.Errorf("maxProperties: object has %d properties, greater than %d", n, m) + } + } + + hasProperty := func(prop string) bool { + return property(instance, prop).IsValid() + } + + missingProperties := func(props []string) []string { + var missing []string + for _, p := range props { + if !hasProperty(p) { + missing = append(missing, p) + } + } + return missing + } + + if schema.Required != nil { + if m := missingProperties(schema.Required); len(m) > 0 { + return fmt.Errorf("required: missing properties: %q", m) + } + } + + if st.rs.draft == draft7 { + if schema.DependencyStrings != nil { + for dprop, dstrings := range schema.DependencyStrings { + if hasProperty(dprop) { + if m := missingProperties(dstrings); len(m) > 0 { + return fmt.Errorf("dependentRequired[%q]: missing properties %q", dprop, m) + } + } + } + } + if schema.DependencySchemas != nil { + for dprop, dschema := range schema.DependencySchemas { + if hasProperty(dprop) { + err := st.validate(instance, dschema, &anns) + if err != nil { + return err + } + } + } + } + } else if st.rs.draft == draft2020 { + if schema.DependentRequired != nil { + // "Validation succeeds if, for each name that appears in both the instance + // and as a name within this keyword's value, every item in the corresponding + // array is also the name of a property in the instance." §6.5.4 + for dprop, reqs := range schema.DependentRequired { + if hasProperty(dprop) { + if m := missingProperties(reqs); len(m) > 0 { + return fmt.Errorf("dependentRequired[%q]: missing properties %q", dprop, m) + } + } + } + } + + // https://json-schema.org/draft/2020-12/json-schema-core#section-10.2.2.4 + if schema.DependentSchemas != nil { + // This does not collect annotations, although it seems like it should. + for dprop, ss := range schema.DependentSchemas { + if hasProperty(dprop) { + // TODO: include dependentSchemas[dprop] in the errors. + err := st.validate(instance, ss, &anns) + if err != nil { + return err + } + } + } + } + } + + if schema.UnevaluatedProperties != nil && !anns.allProperties { + // This looks a lot like AdditionalProperties, but depends on in-place keywords like allOf + // in addition to sibling keywords. + for prop, val := range properties(instance) { + if !anns.evaluatedProperties[prop] { + if err := st.validate(val, schema.UnevaluatedProperties, nil); err != nil { + return err + } + } + } + // The spec says the annotation should be the set of evaluated properties, but we can optimize + // by setting a single boolean, since after this succeeds all properties will be validated. + // See https://json-schema.slack.com/archives/CT7FF623C/p1745592564381459. + anns.allProperties = true + } + } + + if callerAnns != nil { + // Our caller wants to know what we've validated. + callerAnns.merge(&anns) + } + return nil +} + +// resolveDynamicRef returns the schema referred to by the argument schema's +// $dynamicRef value. +// It returns an error if the dynamic reference has no referent. +// If there is no $dynamicRef, resolveDynamicRef returns nil, nil. +// See https://json-schema.org/draft/2020-12/json-schema-core#section-8.2.3.2. +func (st *state) resolveDynamicRef(schema *Schema) (*Schema, error) { + if schema.DynamicRef == "" { + return nil, nil + } + info := st.rs.resolvedInfos[schema] + // The ref behaves lexically or dynamically, but not both. + assert((info.resolvedDynamicRef == nil) != (info.dynamicRefAnchor == ""), + "DynamicRef not statically resolved properly") + if r := info.resolvedDynamicRef; r != nil { + // Same as $ref. + return r, nil + } + // Dynamic behavior. + // Look for the base of the outermost schema on the stack with this dynamic + // anchor. (Yes, outermost: the one farthest from here. This the opposite + // of how ordinary dynamic variables behave.) + // Why the base of the schema being validated and not the schema itself? + // Because the base is the scope for anchors. In fact it's possible to + // refer to a schema that is not on the stack, but a child of some base + // on the stack. + // For an example, search for "detached" in testdata/draft2020-12/dynamicRef.json. + for _, s := range st.stack { + base := st.rs.resolvedInfos[s].base + info, ok := st.rs.resolvedInfos[base].anchors[info.dynamicRefAnchor] + if ok && info.dynamic { + return info.schema, nil + } + } + return nil, fmt.Errorf("missing dynamic anchor %q", info.dynamicRefAnchor) +} + +// ApplyDefaults modifies an instance by applying the schema's defaults to it. If +// a schema or sub-schema has a default, then a corresponding missing instance value +// is set to the default. +// +// The JSON Schema specification does not describe how defaults should be interpreted. +// This method honors defaults only on properties, and only those that are not required. +// If the instance is a map and the property is missing, the property is added to +// the map with the default. +// ApplyDefaults does not support structs, because it cannot know whether a field +// is missing in the JSON, or was explicitly set to its zero value. +// +// ApplyDefaults can panic if a default cannot be assigned to a field. +// +// The argument must be a pointer to the instance. +// (In case we decide that top-level defaults are meaningful.) +// +// It is recommended to first call Resolve with a ValidateDefaults option of true, +// then call this method, and lastly call Validate. +func (rs *Resolved) ApplyDefaults(instancep any) error { + // TODO(jba): consider what defaults on top-level or array instances might mean. + // TODO(jba): follow $ref and $dynamicRef + st := &state{rs: rs} + return st.applyDefaults(reflect.ValueOf(instancep), rs.root) +} + +// Recursive helper used by ApplyDefaults. Applies defaults on sub-schemas +// of object properties recursively. +func (st *state) applyDefaults(instancep reflect.Value, schema *Schema) (err error) { + defer wrapf(&err, "applyDefaults: schema %s, instance %v", st.rs.schemaString(schema), instancep) + + schemaInfo := st.rs.resolvedInfos[schema] + instance := instancep.Elem() + if instance.Kind() == reflect.Interface && instance.IsValid() { + // If we unmarshalled into 'any', the default object unmarshalling will be map[string]any. + instance = instance.Elem() + } + if instance.Kind() == reflect.Map || instance.Kind() == reflect.Struct { + if instance.Kind() == reflect.Map { + if kt := instance.Type().Key(); kt.Kind() != reflect.String { + return fmt.Errorf("map key type %s is not a string", kt) + } + } + for prop, subschema := range schema.Properties { + // Ignore defaults on required properties. (A required property shouldn't have a default.) + if schemaInfo.isRequired[prop] { + continue + } + val := property(instance, prop) + switch instance.Kind() { + case reflect.Map: + // If there is a default for this property, and the map key is missing, + // set the map value to the default. + if subschema.Default != nil && !val.IsValid() { + // Create an lvalue, since map values aren't addressable. + lvalue := reflect.New(instance.Type().Elem()) + if err := json.Unmarshal(subschema.Default, lvalue.Interface()); err != nil { + return err + } + // Recurse unconditionally; applyDefaults will only act on object-like values. + if err := st.applyDefaults(lvalue, subschema); err != nil { + return err + } + instance.SetMapIndex(reflect.ValueOf(prop), lvalue.Elem()) + } else if val.IsValid() { + // Recurse into an existing sub-instance. + // MapIndex returns a non-addressable value; copy into an addressable lvalue, recurse, then set back. + lvalue := reflect.New(instance.Type().Elem()) + // Initialize the lvalue with current value. + lvalue.Elem().Set(val) + if err := st.applyDefaults(lvalue, subschema); err != nil { + return err + } + instance.SetMapIndex(reflect.ValueOf(prop), lvalue.Elem()) + } else if schemaHasDefaultsInProperties(subschema) { + // Property is missing, but descendants still have some defaults + // Create an empty container and recurse to populate + elemType := instance.Type().Elem() + var child reflect.Value + switch elemType.Kind() { + case reflect.Interface: + child = reflect.ValueOf(map[string]any{}) + case reflect.Map: + child = reflect.MakeMap(elemType) + case reflect.Struct: + child = reflect.New(elemType).Elem() + } + if child.IsValid() { + lvalue := reflect.New(elemType) + lvalue.Elem().Set(child) + if err := st.applyDefaults(lvalue, subschema); err != nil { + return err + } + instance.SetMapIndex(reflect.ValueOf(prop), lvalue.Elem()) + } + } + case reflect.Struct: + return errors.New("cannot apply defaults to a struct") + default: + panic(fmt.Sprintf("applyDefaults: property %s: bad value %s of kind %s", + prop, instance, instance.Kind())) + } + } + } + return nil +} + +// schemaHasDefaultsInProperties reports whether s or any descendant schema under +// its Properties contains a default. Only walks Properties to match ApplyDefaults semantics. +func schemaHasDefaultsInProperties(s *Schema) bool { + if s == nil { + return false + } + if s.Default != nil { + return true + } + if s.Properties != nil { + for _, ss := range s.Properties { + if schemaHasDefaultsInProperties(ss) { + return true + } + } + } + return false +} + +// property returns the value of the property of v with the given name, or the invalid +// reflect.Value if there is none. +// If v is a map, the property is the value of the map whose key is name. +// If v is a struct, the property is the value of the field with the given name according +// to the encoding/json package (see [jsonName]). +// If v is anything else, property panics. +func property(v reflect.Value, name string) reflect.Value { + switch v.Kind() { + case reflect.Map: + return v.MapIndex(reflect.ValueOf(name)) + case reflect.Struct: + props := structPropertiesOf(v.Type()) + // Ignore nonexistent properties. + if sf, ok := props[name]; ok { + return v.FieldByIndex(sf.Index) + } + return reflect.Value{} + default: + panic(fmt.Sprintf("property(%q): bad value %s of kind %s", name, v, v.Kind())) + } +} + +// properties returns an iterator over the names and values of all properties +// in v, which must be a map or a struct. +// If a struct, zero-valued properties that are marked omitempty or omitzero +// are excluded. +func properties(v reflect.Value) iter.Seq2[string, reflect.Value] { + return func(yield func(string, reflect.Value) bool) { + switch v.Kind() { + case reflect.Map: + for k, e := range v.Seq2() { + if !yield(k.String(), e) { + return + } + } + case reflect.Struct: + for name, sf := range structPropertiesOf(v.Type()) { + val := v.FieldByIndex(sf.Index) + if val.IsZero() { + info := fieldJSONInfo(sf) + if info.settings["omitempty"] || info.settings["omitzero"] { + continue + } + } + if !yield(name, val) { + return + } + } + default: + panic(fmt.Sprintf("bad value %s of kind %s", v, v.Kind())) + } + } +} + +// numPropertiesBounds returns bounds on the number of v's properties. +// v must be a map or a struct. +// If v is a map, both bounds are the map's size. +// If v is a struct, the max is the number of struct properties. +// But since we don't know whether a zero value indicates a missing optional property +// or not, be generous and use the number of non-zero properties as the min. +func numPropertiesBounds(v reflect.Value, isRequired map[string]bool) (int, int) { + switch v.Kind() { + case reflect.Map: + return v.Len(), v.Len() + case reflect.Struct: + sp := structPropertiesOf(v.Type()) + min := 0 + for prop, sf := range sp { + if !v.FieldByIndex(sf.Index).IsZero() || isRequired[prop] { + min++ + } + } + return min, len(sp) + default: + panic(fmt.Sprintf("properties: bad value: %s of kind %s", v, v.Kind())) + } +} + +// A propertyMap is a map from property name to struct field index. +type propertyMap = map[string]reflect.StructField + +var structProperties sync.Map // from reflect.Type to propertyMap + +// structPropertiesOf returns the JSON Schema properties for the struct type t. +// The caller must not mutate the result. +func structPropertiesOf(t reflect.Type) propertyMap { + // Mutex not necessary: at worst we'll recompute the same value. + if props, ok := structProperties.Load(t); ok { + return props.(propertyMap) + } + props := map[string]reflect.StructField{} + for _, sf := range reflect.VisibleFields(t) { + if sf.Anonymous { + continue + } + info := fieldJSONInfo(sf) + if !info.omit { + props[info.name] = sf + } + } + structProperties.Store(t, props) + return props +} diff --git a/mcp/dsx-exchange-mcp/vendor/github.com/gorilla/websocket/.gitignore b/mcp/dsx-exchange-mcp/vendor/github.com/gorilla/websocket/.gitignore new file mode 100644 index 0000000..cd3fcd1 --- /dev/null +++ b/mcp/dsx-exchange-mcp/vendor/github.com/gorilla/websocket/.gitignore @@ -0,0 +1,25 @@ +# Compiled Object files, Static and Dynamic libs (Shared Objects) +*.o +*.a +*.so + +# Folders +_obj +_test + +# Architecture specific extensions/prefixes +*.[568vq] +[568vq].out + +*.cgo1.go +*.cgo2.c +_cgo_defun.c +_cgo_gotypes.go +_cgo_export.* + +_testmain.go + +*.exe + +.idea/ +*.iml diff --git a/mcp/dsx-exchange-mcp/vendor/github.com/gorilla/websocket/AUTHORS b/mcp/dsx-exchange-mcp/vendor/github.com/gorilla/websocket/AUTHORS new file mode 100644 index 0000000..1931f40 --- /dev/null +++ b/mcp/dsx-exchange-mcp/vendor/github.com/gorilla/websocket/AUTHORS @@ -0,0 +1,9 @@ +# This is the official list of Gorilla WebSocket authors for copyright +# purposes. +# +# Please keep the list sorted. + +Gary Burd +Google LLC (https://opensource.google.com/) +Joachim Bauch + diff --git a/mcp/dsx-exchange-mcp/vendor/github.com/gorilla/websocket/LICENSE b/mcp/dsx-exchange-mcp/vendor/github.com/gorilla/websocket/LICENSE new file mode 100644 index 0000000..9171c97 --- /dev/null +++ b/mcp/dsx-exchange-mcp/vendor/github.com/gorilla/websocket/LICENSE @@ -0,0 +1,22 @@ +Copyright (c) 2013 The Gorilla WebSocket Authors. All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are met: + + Redistributions of source code must retain the above copyright notice, this + list of conditions and the following disclaimer. + + Redistributions in binary form must reproduce the above copyright notice, + this list of conditions and the following disclaimer in the documentation + and/or other materials provided with the distribution. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND +ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED +WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE +FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL +DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR +SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER +CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, +OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/mcp/dsx-exchange-mcp/vendor/github.com/gorilla/websocket/README.md b/mcp/dsx-exchange-mcp/vendor/github.com/gorilla/websocket/README.md new file mode 100644 index 0000000..d33ed7f --- /dev/null +++ b/mcp/dsx-exchange-mcp/vendor/github.com/gorilla/websocket/README.md @@ -0,0 +1,33 @@ +# Gorilla WebSocket + +[![GoDoc](https://godoc.org/github.com/gorilla/websocket?status.svg)](https://godoc.org/github.com/gorilla/websocket) +[![CircleCI](https://circleci.com/gh/gorilla/websocket.svg?style=svg)](https://circleci.com/gh/gorilla/websocket) + +Gorilla WebSocket is a [Go](http://golang.org/) implementation of the +[WebSocket](http://www.rfc-editor.org/rfc/rfc6455.txt) protocol. + + +### Documentation + +* [API Reference](https://pkg.go.dev/github.com/gorilla/websocket?tab=doc) +* [Chat example](https://github.com/gorilla/websocket/tree/master/examples/chat) +* [Command example](https://github.com/gorilla/websocket/tree/master/examples/command) +* [Client and server example](https://github.com/gorilla/websocket/tree/master/examples/echo) +* [File watch example](https://github.com/gorilla/websocket/tree/master/examples/filewatch) + +### Status + +The Gorilla WebSocket package provides a complete and tested implementation of +the [WebSocket](http://www.rfc-editor.org/rfc/rfc6455.txt) protocol. The +package API is stable. + +### Installation + + go get github.com/gorilla/websocket + +### Protocol Compliance + +The Gorilla WebSocket package passes the server tests in the [Autobahn Test +Suite](https://github.com/crossbario/autobahn-testsuite) using the application in the [examples/autobahn +subdirectory](https://github.com/gorilla/websocket/tree/master/examples/autobahn). + diff --git a/mcp/dsx-exchange-mcp/vendor/github.com/gorilla/websocket/client.go b/mcp/dsx-exchange-mcp/vendor/github.com/gorilla/websocket/client.go new file mode 100644 index 0000000..04fdafe --- /dev/null +++ b/mcp/dsx-exchange-mcp/vendor/github.com/gorilla/websocket/client.go @@ -0,0 +1,434 @@ +// Copyright 2013 The Gorilla WebSocket Authors. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +package websocket + +import ( + "bytes" + "context" + "crypto/tls" + "errors" + "fmt" + "io" + "io/ioutil" + "net" + "net/http" + "net/http/httptrace" + "net/url" + "strings" + "time" +) + +// ErrBadHandshake is returned when the server response to opening handshake is +// invalid. +var ErrBadHandshake = errors.New("websocket: bad handshake") + +var errInvalidCompression = errors.New("websocket: invalid compression negotiation") + +// NewClient creates a new client connection using the given net connection. +// The URL u specifies the host and request URI. Use requestHeader to specify +// the origin (Origin), subprotocols (Sec-WebSocket-Protocol) and cookies +// (Cookie). Use the response.Header to get the selected subprotocol +// (Sec-WebSocket-Protocol) and cookies (Set-Cookie). +// +// If the WebSocket handshake fails, ErrBadHandshake is returned along with a +// non-nil *http.Response so that callers can handle redirects, authentication, +// etc. +// +// Deprecated: Use Dialer instead. +func NewClient(netConn net.Conn, u *url.URL, requestHeader http.Header, readBufSize, writeBufSize int) (c *Conn, response *http.Response, err error) { + d := Dialer{ + ReadBufferSize: readBufSize, + WriteBufferSize: writeBufSize, + NetDial: func(net, addr string) (net.Conn, error) { + return netConn, nil + }, + } + return d.Dial(u.String(), requestHeader) +} + +// A Dialer contains options for connecting to WebSocket server. +// +// It is safe to call Dialer's methods concurrently. +type Dialer struct { + // NetDial specifies the dial function for creating TCP connections. If + // NetDial is nil, net.Dial is used. + NetDial func(network, addr string) (net.Conn, error) + + // NetDialContext specifies the dial function for creating TCP connections. If + // NetDialContext is nil, NetDial is used. + NetDialContext func(ctx context.Context, network, addr string) (net.Conn, error) + + // NetDialTLSContext specifies the dial function for creating TLS/TCP connections. If + // NetDialTLSContext is nil, NetDialContext is used. + // If NetDialTLSContext is set, Dial assumes the TLS handshake is done there and + // TLSClientConfig is ignored. + NetDialTLSContext func(ctx context.Context, network, addr string) (net.Conn, error) + + // Proxy specifies a function to return a proxy for a given + // Request. If the function returns a non-nil error, the + // request is aborted with the provided error. + // If Proxy is nil or returns a nil *URL, no proxy is used. + Proxy func(*http.Request) (*url.URL, error) + + // TLSClientConfig specifies the TLS configuration to use with tls.Client. + // If nil, the default configuration is used. + // If either NetDialTLS or NetDialTLSContext are set, Dial assumes the TLS handshake + // is done there and TLSClientConfig is ignored. + TLSClientConfig *tls.Config + + // HandshakeTimeout specifies the duration for the handshake to complete. + HandshakeTimeout time.Duration + + // ReadBufferSize and WriteBufferSize specify I/O buffer sizes in bytes. If a buffer + // size is zero, then a useful default size is used. The I/O buffer sizes + // do not limit the size of the messages that can be sent or received. + ReadBufferSize, WriteBufferSize int + + // WriteBufferPool is a pool of buffers for write operations. If the value + // is not set, then write buffers are allocated to the connection for the + // lifetime of the connection. + // + // A pool is most useful when the application has a modest volume of writes + // across a large number of connections. + // + // Applications should use a single pool for each unique value of + // WriteBufferSize. + WriteBufferPool BufferPool + + // Subprotocols specifies the client's requested subprotocols. + Subprotocols []string + + // EnableCompression specifies if the client should attempt to negotiate + // per message compression (RFC 7692). Setting this value to true does not + // guarantee that compression will be supported. Currently only "no context + // takeover" modes are supported. + EnableCompression bool + + // Jar specifies the cookie jar. + // If Jar is nil, cookies are not sent in requests and ignored + // in responses. + Jar http.CookieJar +} + +// Dial creates a new client connection by calling DialContext with a background context. +func (d *Dialer) Dial(urlStr string, requestHeader http.Header) (*Conn, *http.Response, error) { + return d.DialContext(context.Background(), urlStr, requestHeader) +} + +var errMalformedURL = errors.New("malformed ws or wss URL") + +func hostPortNoPort(u *url.URL) (hostPort, hostNoPort string) { + hostPort = u.Host + hostNoPort = u.Host + if i := strings.LastIndex(u.Host, ":"); i > strings.LastIndex(u.Host, "]") { + hostNoPort = hostNoPort[:i] + } else { + switch u.Scheme { + case "wss": + hostPort += ":443" + case "https": + hostPort += ":443" + default: + hostPort += ":80" + } + } + return hostPort, hostNoPort +} + +// DefaultDialer is a dialer with all fields set to the default values. +var DefaultDialer = &Dialer{ + Proxy: http.ProxyFromEnvironment, + HandshakeTimeout: 45 * time.Second, +} + +// nilDialer is dialer to use when receiver is nil. +var nilDialer = *DefaultDialer + +// DialContext creates a new client connection. Use requestHeader to specify the +// origin (Origin), subprotocols (Sec-WebSocket-Protocol) and cookies (Cookie). +// Use the response.Header to get the selected subprotocol +// (Sec-WebSocket-Protocol) and cookies (Set-Cookie). +// +// The context will be used in the request and in the Dialer. +// +// If the WebSocket handshake fails, ErrBadHandshake is returned along with a +// non-nil *http.Response so that callers can handle redirects, authentication, +// etcetera. The response body may not contain the entire response and does not +// need to be closed by the application. +func (d *Dialer) DialContext(ctx context.Context, urlStr string, requestHeader http.Header) (*Conn, *http.Response, error) { + if d == nil { + d = &nilDialer + } + + challengeKey, err := generateChallengeKey() + if err != nil { + return nil, nil, err + } + + u, err := url.Parse(urlStr) + if err != nil { + return nil, nil, err + } + + switch u.Scheme { + case "ws": + u.Scheme = "http" + case "wss": + u.Scheme = "https" + default: + return nil, nil, errMalformedURL + } + + if u.User != nil { + // User name and password are not allowed in websocket URIs. + return nil, nil, errMalformedURL + } + + req := &http.Request{ + Method: http.MethodGet, + URL: u, + Proto: "HTTP/1.1", + ProtoMajor: 1, + ProtoMinor: 1, + Header: make(http.Header), + Host: u.Host, + } + req = req.WithContext(ctx) + + // Set the cookies present in the cookie jar of the dialer + if d.Jar != nil { + for _, cookie := range d.Jar.Cookies(u) { + req.AddCookie(cookie) + } + } + + // Set the request headers using the capitalization for names and values in + // RFC examples. Although the capitalization shouldn't matter, there are + // servers that depend on it. The Header.Set method is not used because the + // method canonicalizes the header names. + req.Header["Upgrade"] = []string{"websocket"} + req.Header["Connection"] = []string{"Upgrade"} + req.Header["Sec-WebSocket-Key"] = []string{challengeKey} + req.Header["Sec-WebSocket-Version"] = []string{"13"} + if len(d.Subprotocols) > 0 { + req.Header["Sec-WebSocket-Protocol"] = []string{strings.Join(d.Subprotocols, ", ")} + } + for k, vs := range requestHeader { + switch { + case k == "Host": + if len(vs) > 0 { + req.Host = vs[0] + } + case k == "Upgrade" || + k == "Connection" || + k == "Sec-Websocket-Key" || + k == "Sec-Websocket-Version" || + k == "Sec-Websocket-Extensions" || + (k == "Sec-Websocket-Protocol" && len(d.Subprotocols) > 0): + return nil, nil, errors.New("websocket: duplicate header not allowed: " + k) + case k == "Sec-Websocket-Protocol": + req.Header["Sec-WebSocket-Protocol"] = vs + default: + req.Header[k] = vs + } + } + + if d.EnableCompression { + req.Header["Sec-WebSocket-Extensions"] = []string{"permessage-deflate; server_no_context_takeover; client_no_context_takeover"} + } + + if d.HandshakeTimeout != 0 { + var cancel func() + ctx, cancel = context.WithTimeout(ctx, d.HandshakeTimeout) + defer cancel() + } + + // Get network dial function. + var netDial func(network, add string) (net.Conn, error) + + switch u.Scheme { + case "http": + if d.NetDialContext != nil { + netDial = func(network, addr string) (net.Conn, error) { + return d.NetDialContext(ctx, network, addr) + } + } else if d.NetDial != nil { + netDial = d.NetDial + } + case "https": + if d.NetDialTLSContext != nil { + netDial = func(network, addr string) (net.Conn, error) { + return d.NetDialTLSContext(ctx, network, addr) + } + } else if d.NetDialContext != nil { + netDial = func(network, addr string) (net.Conn, error) { + return d.NetDialContext(ctx, network, addr) + } + } else if d.NetDial != nil { + netDial = d.NetDial + } + default: + return nil, nil, errMalformedURL + } + + if netDial == nil { + netDialer := &net.Dialer{} + netDial = func(network, addr string) (net.Conn, error) { + return netDialer.DialContext(ctx, network, addr) + } + } + + // If needed, wrap the dial function to set the connection deadline. + if deadline, ok := ctx.Deadline(); ok { + forwardDial := netDial + netDial = func(network, addr string) (net.Conn, error) { + c, err := forwardDial(network, addr) + if err != nil { + return nil, err + } + err = c.SetDeadline(deadline) + if err != nil { + c.Close() + return nil, err + } + return c, nil + } + } + + // If needed, wrap the dial function to connect through a proxy. + if d.Proxy != nil { + proxyURL, err := d.Proxy(req) + if err != nil { + return nil, nil, err + } + if proxyURL != nil { + dialer, err := proxy_FromURL(proxyURL, netDialerFunc(netDial)) + if err != nil { + return nil, nil, err + } + netDial = dialer.Dial + } + } + + hostPort, hostNoPort := hostPortNoPort(u) + trace := httptrace.ContextClientTrace(ctx) + if trace != nil && trace.GetConn != nil { + trace.GetConn(hostPort) + } + + netConn, err := netDial("tcp", hostPort) + if err != nil { + return nil, nil, err + } + if trace != nil && trace.GotConn != nil { + trace.GotConn(httptrace.GotConnInfo{ + Conn: netConn, + }) + } + + defer func() { + if netConn != nil { + netConn.Close() + } + }() + + if u.Scheme == "https" && d.NetDialTLSContext == nil { + // If NetDialTLSContext is set, assume that the TLS handshake has already been done + + cfg := cloneTLSConfig(d.TLSClientConfig) + if cfg.ServerName == "" { + cfg.ServerName = hostNoPort + } + tlsConn := tls.Client(netConn, cfg) + netConn = tlsConn + + if trace != nil && trace.TLSHandshakeStart != nil { + trace.TLSHandshakeStart() + } + err := doHandshake(ctx, tlsConn, cfg) + if trace != nil && trace.TLSHandshakeDone != nil { + trace.TLSHandshakeDone(tlsConn.ConnectionState(), err) + } + + if err != nil { + return nil, nil, err + } + } + + conn := newConn(netConn, false, d.ReadBufferSize, d.WriteBufferSize, d.WriteBufferPool, nil, nil) + + if err := req.Write(netConn); err != nil { + return nil, nil, err + } + + if trace != nil && trace.GotFirstResponseByte != nil { + if peek, err := conn.br.Peek(1); err == nil && len(peek) == 1 { + trace.GotFirstResponseByte() + } + } + + resp, err := http.ReadResponse(conn.br, req) + if err != nil { + if d.TLSClientConfig != nil { + for _, proto := range d.TLSClientConfig.NextProtos { + if proto != "http/1.1" { + return nil, nil, fmt.Errorf( + "websocket: protocol %q was given but is not supported;"+ + "sharing tls.Config with net/http Transport can cause this error: %w", + proto, err, + ) + } + } + } + return nil, nil, err + } + + if d.Jar != nil { + if rc := resp.Cookies(); len(rc) > 0 { + d.Jar.SetCookies(u, rc) + } + } + + if resp.StatusCode != 101 || + !tokenListContainsValue(resp.Header, "Upgrade", "websocket") || + !tokenListContainsValue(resp.Header, "Connection", "upgrade") || + resp.Header.Get("Sec-Websocket-Accept") != computeAcceptKey(challengeKey) { + // Before closing the network connection on return from this + // function, slurp up some of the response to aid application + // debugging. + buf := make([]byte, 1024) + n, _ := io.ReadFull(resp.Body, buf) + resp.Body = ioutil.NopCloser(bytes.NewReader(buf[:n])) + return nil, resp, ErrBadHandshake + } + + for _, ext := range parseExtensions(resp.Header) { + if ext[""] != "permessage-deflate" { + continue + } + _, snct := ext["server_no_context_takeover"] + _, cnct := ext["client_no_context_takeover"] + if !snct || !cnct { + return nil, resp, errInvalidCompression + } + conn.newCompressionWriter = compressNoContextTakeover + conn.newDecompressionReader = decompressNoContextTakeover + break + } + + resp.Body = ioutil.NopCloser(bytes.NewReader([]byte{})) + conn.subprotocol = resp.Header.Get("Sec-Websocket-Protocol") + + netConn.SetDeadline(time.Time{}) + netConn = nil // to avoid close in defer. + return conn, resp, nil +} + +func cloneTLSConfig(cfg *tls.Config) *tls.Config { + if cfg == nil { + return &tls.Config{} + } + return cfg.Clone() +} diff --git a/mcp/dsx-exchange-mcp/vendor/github.com/gorilla/websocket/compression.go b/mcp/dsx-exchange-mcp/vendor/github.com/gorilla/websocket/compression.go new file mode 100644 index 0000000..813ffb1 --- /dev/null +++ b/mcp/dsx-exchange-mcp/vendor/github.com/gorilla/websocket/compression.go @@ -0,0 +1,148 @@ +// Copyright 2017 The Gorilla WebSocket Authors. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +package websocket + +import ( + "compress/flate" + "errors" + "io" + "strings" + "sync" +) + +const ( + minCompressionLevel = -2 // flate.HuffmanOnly not defined in Go < 1.6 + maxCompressionLevel = flate.BestCompression + defaultCompressionLevel = 1 +) + +var ( + flateWriterPools [maxCompressionLevel - minCompressionLevel + 1]sync.Pool + flateReaderPool = sync.Pool{New: func() interface{} { + return flate.NewReader(nil) + }} +) + +func decompressNoContextTakeover(r io.Reader) io.ReadCloser { + const tail = + // Add four bytes as specified in RFC + "\x00\x00\xff\xff" + + // Add final block to squelch unexpected EOF error from flate reader. + "\x01\x00\x00\xff\xff" + + fr, _ := flateReaderPool.Get().(io.ReadCloser) + fr.(flate.Resetter).Reset(io.MultiReader(r, strings.NewReader(tail)), nil) + return &flateReadWrapper{fr} +} + +func isValidCompressionLevel(level int) bool { + return minCompressionLevel <= level && level <= maxCompressionLevel +} + +func compressNoContextTakeover(w io.WriteCloser, level int) io.WriteCloser { + p := &flateWriterPools[level-minCompressionLevel] + tw := &truncWriter{w: w} + fw, _ := p.Get().(*flate.Writer) + if fw == nil { + fw, _ = flate.NewWriter(tw, level) + } else { + fw.Reset(tw) + } + return &flateWriteWrapper{fw: fw, tw: tw, p: p} +} + +// truncWriter is an io.Writer that writes all but the last four bytes of the +// stream to another io.Writer. +type truncWriter struct { + w io.WriteCloser + n int + p [4]byte +} + +func (w *truncWriter) Write(p []byte) (int, error) { + n := 0 + + // fill buffer first for simplicity. + if w.n < len(w.p) { + n = copy(w.p[w.n:], p) + p = p[n:] + w.n += n + if len(p) == 0 { + return n, nil + } + } + + m := len(p) + if m > len(w.p) { + m = len(w.p) + } + + if nn, err := w.w.Write(w.p[:m]); err != nil { + return n + nn, err + } + + copy(w.p[:], w.p[m:]) + copy(w.p[len(w.p)-m:], p[len(p)-m:]) + nn, err := w.w.Write(p[:len(p)-m]) + return n + nn, err +} + +type flateWriteWrapper struct { + fw *flate.Writer + tw *truncWriter + p *sync.Pool +} + +func (w *flateWriteWrapper) Write(p []byte) (int, error) { + if w.fw == nil { + return 0, errWriteClosed + } + return w.fw.Write(p) +} + +func (w *flateWriteWrapper) Close() error { + if w.fw == nil { + return errWriteClosed + } + err1 := w.fw.Flush() + w.p.Put(w.fw) + w.fw = nil + if w.tw.p != [4]byte{0, 0, 0xff, 0xff} { + return errors.New("websocket: internal error, unexpected bytes at end of flate stream") + } + err2 := w.tw.w.Close() + if err1 != nil { + return err1 + } + return err2 +} + +type flateReadWrapper struct { + fr io.ReadCloser +} + +func (r *flateReadWrapper) Read(p []byte) (int, error) { + if r.fr == nil { + return 0, io.ErrClosedPipe + } + n, err := r.fr.Read(p) + if err == io.EOF { + // Preemptively place the reader back in the pool. This helps with + // scenarios where the application does not call NextReader() soon after + // this final read. + r.Close() + } + return n, err +} + +func (r *flateReadWrapper) Close() error { + if r.fr == nil { + return io.ErrClosedPipe + } + err := r.fr.Close() + flateReaderPool.Put(r.fr) + r.fr = nil + return err +} diff --git a/mcp/dsx-exchange-mcp/vendor/github.com/gorilla/websocket/conn.go b/mcp/dsx-exchange-mcp/vendor/github.com/gorilla/websocket/conn.go new file mode 100644 index 0000000..5161ef8 --- /dev/null +++ b/mcp/dsx-exchange-mcp/vendor/github.com/gorilla/websocket/conn.go @@ -0,0 +1,1238 @@ +// Copyright 2013 The Gorilla WebSocket Authors. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +package websocket + +import ( + "bufio" + "encoding/binary" + "errors" + "io" + "io/ioutil" + "math/rand" + "net" + "strconv" + "strings" + "sync" + "time" + "unicode/utf8" +) + +const ( + // Frame header byte 0 bits from Section 5.2 of RFC 6455 + finalBit = 1 << 7 + rsv1Bit = 1 << 6 + rsv2Bit = 1 << 5 + rsv3Bit = 1 << 4 + + // Frame header byte 1 bits from Section 5.2 of RFC 6455 + maskBit = 1 << 7 + + maxFrameHeaderSize = 2 + 8 + 4 // Fixed header + length + mask + maxControlFramePayloadSize = 125 + + writeWait = time.Second + + defaultReadBufferSize = 4096 + defaultWriteBufferSize = 4096 + + continuationFrame = 0 + noFrame = -1 +) + +// Close codes defined in RFC 6455, section 11.7. +const ( + CloseNormalClosure = 1000 + CloseGoingAway = 1001 + CloseProtocolError = 1002 + CloseUnsupportedData = 1003 + CloseNoStatusReceived = 1005 + CloseAbnormalClosure = 1006 + CloseInvalidFramePayloadData = 1007 + ClosePolicyViolation = 1008 + CloseMessageTooBig = 1009 + CloseMandatoryExtension = 1010 + CloseInternalServerErr = 1011 + CloseServiceRestart = 1012 + CloseTryAgainLater = 1013 + CloseTLSHandshake = 1015 +) + +// The message types are defined in RFC 6455, section 11.8. +const ( + // TextMessage denotes a text data message. The text message payload is + // interpreted as UTF-8 encoded text data. + TextMessage = 1 + + // BinaryMessage denotes a binary data message. + BinaryMessage = 2 + + // CloseMessage denotes a close control message. The optional message + // payload contains a numeric code and text. Use the FormatCloseMessage + // function to format a close message payload. + CloseMessage = 8 + + // PingMessage denotes a ping control message. The optional message payload + // is UTF-8 encoded text. + PingMessage = 9 + + // PongMessage denotes a pong control message. The optional message payload + // is UTF-8 encoded text. + PongMessage = 10 +) + +// ErrCloseSent is returned when the application writes a message to the +// connection after sending a close message. +var ErrCloseSent = errors.New("websocket: close sent") + +// ErrReadLimit is returned when reading a message that is larger than the +// read limit set for the connection. +var ErrReadLimit = errors.New("websocket: read limit exceeded") + +// netError satisfies the net Error interface. +type netError struct { + msg string + temporary bool + timeout bool +} + +func (e *netError) Error() string { return e.msg } +func (e *netError) Temporary() bool { return e.temporary } +func (e *netError) Timeout() bool { return e.timeout } + +// CloseError represents a close message. +type CloseError struct { + // Code is defined in RFC 6455, section 11.7. + Code int + + // Text is the optional text payload. + Text string +} + +func (e *CloseError) Error() string { + s := []byte("websocket: close ") + s = strconv.AppendInt(s, int64(e.Code), 10) + switch e.Code { + case CloseNormalClosure: + s = append(s, " (normal)"...) + case CloseGoingAway: + s = append(s, " (going away)"...) + case CloseProtocolError: + s = append(s, " (protocol error)"...) + case CloseUnsupportedData: + s = append(s, " (unsupported data)"...) + case CloseNoStatusReceived: + s = append(s, " (no status)"...) + case CloseAbnormalClosure: + s = append(s, " (abnormal closure)"...) + case CloseInvalidFramePayloadData: + s = append(s, " (invalid payload data)"...) + case ClosePolicyViolation: + s = append(s, " (policy violation)"...) + case CloseMessageTooBig: + s = append(s, " (message too big)"...) + case CloseMandatoryExtension: + s = append(s, " (mandatory extension missing)"...) + case CloseInternalServerErr: + s = append(s, " (internal server error)"...) + case CloseTLSHandshake: + s = append(s, " (TLS handshake error)"...) + } + if e.Text != "" { + s = append(s, ": "...) + s = append(s, e.Text...) + } + return string(s) +} + +// IsCloseError returns boolean indicating whether the error is a *CloseError +// with one of the specified codes. +func IsCloseError(err error, codes ...int) bool { + if e, ok := err.(*CloseError); ok { + for _, code := range codes { + if e.Code == code { + return true + } + } + } + return false +} + +// IsUnexpectedCloseError returns boolean indicating whether the error is a +// *CloseError with a code not in the list of expected codes. +func IsUnexpectedCloseError(err error, expectedCodes ...int) bool { + if e, ok := err.(*CloseError); ok { + for _, code := range expectedCodes { + if e.Code == code { + return false + } + } + return true + } + return false +} + +var ( + errWriteTimeout = &netError{msg: "websocket: write timeout", timeout: true, temporary: true} + errUnexpectedEOF = &CloseError{Code: CloseAbnormalClosure, Text: io.ErrUnexpectedEOF.Error()} + errBadWriteOpCode = errors.New("websocket: bad write message type") + errWriteClosed = errors.New("websocket: write closed") + errInvalidControlFrame = errors.New("websocket: invalid control frame") +) + +func newMaskKey() [4]byte { + n := rand.Uint32() + return [4]byte{byte(n), byte(n >> 8), byte(n >> 16), byte(n >> 24)} +} + +func hideTempErr(err error) error { + if e, ok := err.(net.Error); ok && e.Temporary() { + err = &netError{msg: e.Error(), timeout: e.Timeout()} + } + return err +} + +func isControl(frameType int) bool { + return frameType == CloseMessage || frameType == PingMessage || frameType == PongMessage +} + +func isData(frameType int) bool { + return frameType == TextMessage || frameType == BinaryMessage +} + +var validReceivedCloseCodes = map[int]bool{ + // see http://www.iana.org/assignments/websocket/websocket.xhtml#close-code-number + + CloseNormalClosure: true, + CloseGoingAway: true, + CloseProtocolError: true, + CloseUnsupportedData: true, + CloseNoStatusReceived: false, + CloseAbnormalClosure: false, + CloseInvalidFramePayloadData: true, + ClosePolicyViolation: true, + CloseMessageTooBig: true, + CloseMandatoryExtension: true, + CloseInternalServerErr: true, + CloseServiceRestart: true, + CloseTryAgainLater: true, + CloseTLSHandshake: false, +} + +func isValidReceivedCloseCode(code int) bool { + return validReceivedCloseCodes[code] || (code >= 3000 && code <= 4999) +} + +// BufferPool represents a pool of buffers. The *sync.Pool type satisfies this +// interface. The type of the value stored in a pool is not specified. +type BufferPool interface { + // Get gets a value from the pool or returns nil if the pool is empty. + Get() interface{} + // Put adds a value to the pool. + Put(interface{}) +} + +// writePoolData is the type added to the write buffer pool. This wrapper is +// used to prevent applications from peeking at and depending on the values +// added to the pool. +type writePoolData struct{ buf []byte } + +// The Conn type represents a WebSocket connection. +type Conn struct { + conn net.Conn + isServer bool + subprotocol string + + // Write fields + mu chan struct{} // used as mutex to protect write to conn + writeBuf []byte // frame is constructed in this buffer. + writePool BufferPool + writeBufSize int + writeDeadline time.Time + writer io.WriteCloser // the current writer returned to the application + isWriting bool // for best-effort concurrent write detection + + writeErrMu sync.Mutex + writeErr error + + enableWriteCompression bool + compressionLevel int + newCompressionWriter func(io.WriteCloser, int) io.WriteCloser + + // Read fields + reader io.ReadCloser // the current reader returned to the application + readErr error + br *bufio.Reader + // bytes remaining in current frame. + // set setReadRemaining to safely update this value and prevent overflow + readRemaining int64 + readFinal bool // true the current message has more frames. + readLength int64 // Message size. + readLimit int64 // Maximum message size. + readMaskPos int + readMaskKey [4]byte + handlePong func(string) error + handlePing func(string) error + handleClose func(int, string) error + readErrCount int + messageReader *messageReader // the current low-level reader + + readDecompress bool // whether last read frame had RSV1 set + newDecompressionReader func(io.Reader) io.ReadCloser +} + +func newConn(conn net.Conn, isServer bool, readBufferSize, writeBufferSize int, writeBufferPool BufferPool, br *bufio.Reader, writeBuf []byte) *Conn { + + if br == nil { + if readBufferSize == 0 { + readBufferSize = defaultReadBufferSize + } else if readBufferSize < maxControlFramePayloadSize { + // must be large enough for control frame + readBufferSize = maxControlFramePayloadSize + } + br = bufio.NewReaderSize(conn, readBufferSize) + } + + if writeBufferSize <= 0 { + writeBufferSize = defaultWriteBufferSize + } + writeBufferSize += maxFrameHeaderSize + + if writeBuf == nil && writeBufferPool == nil { + writeBuf = make([]byte, writeBufferSize) + } + + mu := make(chan struct{}, 1) + mu <- struct{}{} + c := &Conn{ + isServer: isServer, + br: br, + conn: conn, + mu: mu, + readFinal: true, + writeBuf: writeBuf, + writePool: writeBufferPool, + writeBufSize: writeBufferSize, + enableWriteCompression: true, + compressionLevel: defaultCompressionLevel, + } + c.SetCloseHandler(nil) + c.SetPingHandler(nil) + c.SetPongHandler(nil) + return c +} + +// setReadRemaining tracks the number of bytes remaining on the connection. If n +// overflows, an ErrReadLimit is returned. +func (c *Conn) setReadRemaining(n int64) error { + if n < 0 { + return ErrReadLimit + } + + c.readRemaining = n + return nil +} + +// Subprotocol returns the negotiated protocol for the connection. +func (c *Conn) Subprotocol() string { + return c.subprotocol +} + +// Close closes the underlying network connection without sending or waiting +// for a close message. +func (c *Conn) Close() error { + return c.conn.Close() +} + +// LocalAddr returns the local network address. +func (c *Conn) LocalAddr() net.Addr { + return c.conn.LocalAddr() +} + +// RemoteAddr returns the remote network address. +func (c *Conn) RemoteAddr() net.Addr { + return c.conn.RemoteAddr() +} + +// Write methods + +func (c *Conn) writeFatal(err error) error { + err = hideTempErr(err) + c.writeErrMu.Lock() + if c.writeErr == nil { + c.writeErr = err + } + c.writeErrMu.Unlock() + return err +} + +func (c *Conn) read(n int) ([]byte, error) { + p, err := c.br.Peek(n) + if err == io.EOF { + err = errUnexpectedEOF + } + c.br.Discard(len(p)) + return p, err +} + +func (c *Conn) write(frameType int, deadline time.Time, buf0, buf1 []byte) error { + <-c.mu + defer func() { c.mu <- struct{}{} }() + + c.writeErrMu.Lock() + err := c.writeErr + c.writeErrMu.Unlock() + if err != nil { + return err + } + + c.conn.SetWriteDeadline(deadline) + if len(buf1) == 0 { + _, err = c.conn.Write(buf0) + } else { + err = c.writeBufs(buf0, buf1) + } + if err != nil { + return c.writeFatal(err) + } + if frameType == CloseMessage { + c.writeFatal(ErrCloseSent) + } + return nil +} + +func (c *Conn) writeBufs(bufs ...[]byte) error { + b := net.Buffers(bufs) + _, err := b.WriteTo(c.conn) + return err +} + +// WriteControl writes a control message with the given deadline. The allowed +// message types are CloseMessage, PingMessage and PongMessage. +func (c *Conn) WriteControl(messageType int, data []byte, deadline time.Time) error { + if !isControl(messageType) { + return errBadWriteOpCode + } + if len(data) > maxControlFramePayloadSize { + return errInvalidControlFrame + } + + b0 := byte(messageType) | finalBit + b1 := byte(len(data)) + if !c.isServer { + b1 |= maskBit + } + + buf := make([]byte, 0, maxFrameHeaderSize+maxControlFramePayloadSize) + buf = append(buf, b0, b1) + + if c.isServer { + buf = append(buf, data...) + } else { + key := newMaskKey() + buf = append(buf, key[:]...) + buf = append(buf, data...) + maskBytes(key, 0, buf[6:]) + } + + d := 1000 * time.Hour + if !deadline.IsZero() { + d = deadline.Sub(time.Now()) + if d < 0 { + return errWriteTimeout + } + } + + timer := time.NewTimer(d) + select { + case <-c.mu: + timer.Stop() + case <-timer.C: + return errWriteTimeout + } + defer func() { c.mu <- struct{}{} }() + + c.writeErrMu.Lock() + err := c.writeErr + c.writeErrMu.Unlock() + if err != nil { + return err + } + + c.conn.SetWriteDeadline(deadline) + _, err = c.conn.Write(buf) + if err != nil { + return c.writeFatal(err) + } + if messageType == CloseMessage { + c.writeFatal(ErrCloseSent) + } + return err +} + +// beginMessage prepares a connection and message writer for a new message. +func (c *Conn) beginMessage(mw *messageWriter, messageType int) error { + // Close previous writer if not already closed by the application. It's + // probably better to return an error in this situation, but we cannot + // change this without breaking existing applications. + if c.writer != nil { + c.writer.Close() + c.writer = nil + } + + if !isControl(messageType) && !isData(messageType) { + return errBadWriteOpCode + } + + c.writeErrMu.Lock() + err := c.writeErr + c.writeErrMu.Unlock() + if err != nil { + return err + } + + mw.c = c + mw.frameType = messageType + mw.pos = maxFrameHeaderSize + + if c.writeBuf == nil { + wpd, ok := c.writePool.Get().(writePoolData) + if ok { + c.writeBuf = wpd.buf + } else { + c.writeBuf = make([]byte, c.writeBufSize) + } + } + return nil +} + +// NextWriter returns a writer for the next message to send. The writer's Close +// method flushes the complete message to the network. +// +// There can be at most one open writer on a connection. NextWriter closes the +// previous writer if the application has not already done so. +// +// All message types (TextMessage, BinaryMessage, CloseMessage, PingMessage and +// PongMessage) are supported. +func (c *Conn) NextWriter(messageType int) (io.WriteCloser, error) { + var mw messageWriter + if err := c.beginMessage(&mw, messageType); err != nil { + return nil, err + } + c.writer = &mw + if c.newCompressionWriter != nil && c.enableWriteCompression && isData(messageType) { + w := c.newCompressionWriter(c.writer, c.compressionLevel) + mw.compress = true + c.writer = w + } + return c.writer, nil +} + +type messageWriter struct { + c *Conn + compress bool // whether next call to flushFrame should set RSV1 + pos int // end of data in writeBuf. + frameType int // type of the current frame. + err error +} + +func (w *messageWriter) endMessage(err error) error { + if w.err != nil { + return err + } + c := w.c + w.err = err + c.writer = nil + if c.writePool != nil { + c.writePool.Put(writePoolData{buf: c.writeBuf}) + c.writeBuf = nil + } + return err +} + +// flushFrame writes buffered data and extra as a frame to the network. The +// final argument indicates that this is the last frame in the message. +func (w *messageWriter) flushFrame(final bool, extra []byte) error { + c := w.c + length := w.pos - maxFrameHeaderSize + len(extra) + + // Check for invalid control frames. + if isControl(w.frameType) && + (!final || length > maxControlFramePayloadSize) { + return w.endMessage(errInvalidControlFrame) + } + + b0 := byte(w.frameType) + if final { + b0 |= finalBit + } + if w.compress { + b0 |= rsv1Bit + } + w.compress = false + + b1 := byte(0) + if !c.isServer { + b1 |= maskBit + } + + // Assume that the frame starts at beginning of c.writeBuf. + framePos := 0 + if c.isServer { + // Adjust up if mask not included in the header. + framePos = 4 + } + + switch { + case length >= 65536: + c.writeBuf[framePos] = b0 + c.writeBuf[framePos+1] = b1 | 127 + binary.BigEndian.PutUint64(c.writeBuf[framePos+2:], uint64(length)) + case length > 125: + framePos += 6 + c.writeBuf[framePos] = b0 + c.writeBuf[framePos+1] = b1 | 126 + binary.BigEndian.PutUint16(c.writeBuf[framePos+2:], uint16(length)) + default: + framePos += 8 + c.writeBuf[framePos] = b0 + c.writeBuf[framePos+1] = b1 | byte(length) + } + + if !c.isServer { + key := newMaskKey() + copy(c.writeBuf[maxFrameHeaderSize-4:], key[:]) + maskBytes(key, 0, c.writeBuf[maxFrameHeaderSize:w.pos]) + if len(extra) > 0 { + return w.endMessage(c.writeFatal(errors.New("websocket: internal error, extra used in client mode"))) + } + } + + // Write the buffers to the connection with best-effort detection of + // concurrent writes. See the concurrency section in the package + // documentation for more info. + + if c.isWriting { + panic("concurrent write to websocket connection") + } + c.isWriting = true + + err := c.write(w.frameType, c.writeDeadline, c.writeBuf[framePos:w.pos], extra) + + if !c.isWriting { + panic("concurrent write to websocket connection") + } + c.isWriting = false + + if err != nil { + return w.endMessage(err) + } + + if final { + w.endMessage(errWriteClosed) + return nil + } + + // Setup for next frame. + w.pos = maxFrameHeaderSize + w.frameType = continuationFrame + return nil +} + +func (w *messageWriter) ncopy(max int) (int, error) { + n := len(w.c.writeBuf) - w.pos + if n <= 0 { + if err := w.flushFrame(false, nil); err != nil { + return 0, err + } + n = len(w.c.writeBuf) - w.pos + } + if n > max { + n = max + } + return n, nil +} + +func (w *messageWriter) Write(p []byte) (int, error) { + if w.err != nil { + return 0, w.err + } + + if len(p) > 2*len(w.c.writeBuf) && w.c.isServer { + // Don't buffer large messages. + err := w.flushFrame(false, p) + if err != nil { + return 0, err + } + return len(p), nil + } + + nn := len(p) + for len(p) > 0 { + n, err := w.ncopy(len(p)) + if err != nil { + return 0, err + } + copy(w.c.writeBuf[w.pos:], p[:n]) + w.pos += n + p = p[n:] + } + return nn, nil +} + +func (w *messageWriter) WriteString(p string) (int, error) { + if w.err != nil { + return 0, w.err + } + + nn := len(p) + for len(p) > 0 { + n, err := w.ncopy(len(p)) + if err != nil { + return 0, err + } + copy(w.c.writeBuf[w.pos:], p[:n]) + w.pos += n + p = p[n:] + } + return nn, nil +} + +func (w *messageWriter) ReadFrom(r io.Reader) (nn int64, err error) { + if w.err != nil { + return 0, w.err + } + for { + if w.pos == len(w.c.writeBuf) { + err = w.flushFrame(false, nil) + if err != nil { + break + } + } + var n int + n, err = r.Read(w.c.writeBuf[w.pos:]) + w.pos += n + nn += int64(n) + if err != nil { + if err == io.EOF { + err = nil + } + break + } + } + return nn, err +} + +func (w *messageWriter) Close() error { + if w.err != nil { + return w.err + } + return w.flushFrame(true, nil) +} + +// WritePreparedMessage writes prepared message into connection. +func (c *Conn) WritePreparedMessage(pm *PreparedMessage) error { + frameType, frameData, err := pm.frame(prepareKey{ + isServer: c.isServer, + compress: c.newCompressionWriter != nil && c.enableWriteCompression && isData(pm.messageType), + compressionLevel: c.compressionLevel, + }) + if err != nil { + return err + } + if c.isWriting { + panic("concurrent write to websocket connection") + } + c.isWriting = true + err = c.write(frameType, c.writeDeadline, frameData, nil) + if !c.isWriting { + panic("concurrent write to websocket connection") + } + c.isWriting = false + return err +} + +// WriteMessage is a helper method for getting a writer using NextWriter, +// writing the message and closing the writer. +func (c *Conn) WriteMessage(messageType int, data []byte) error { + + if c.isServer && (c.newCompressionWriter == nil || !c.enableWriteCompression) { + // Fast path with no allocations and single frame. + + var mw messageWriter + if err := c.beginMessage(&mw, messageType); err != nil { + return err + } + n := copy(c.writeBuf[mw.pos:], data) + mw.pos += n + data = data[n:] + return mw.flushFrame(true, data) + } + + w, err := c.NextWriter(messageType) + if err != nil { + return err + } + if _, err = w.Write(data); err != nil { + return err + } + return w.Close() +} + +// SetWriteDeadline sets the write deadline on the underlying network +// connection. After a write has timed out, the websocket state is corrupt and +// all future writes will return an error. A zero value for t means writes will +// not time out. +func (c *Conn) SetWriteDeadline(t time.Time) error { + c.writeDeadline = t + return nil +} + +// Read methods + +func (c *Conn) advanceFrame() (int, error) { + // 1. Skip remainder of previous frame. + + if c.readRemaining > 0 { + if _, err := io.CopyN(ioutil.Discard, c.br, c.readRemaining); err != nil { + return noFrame, err + } + } + + // 2. Read and parse first two bytes of frame header. + // To aid debugging, collect and report all errors in the first two bytes + // of the header. + + var errors []string + + p, err := c.read(2) + if err != nil { + return noFrame, err + } + + frameType := int(p[0] & 0xf) + final := p[0]&finalBit != 0 + rsv1 := p[0]&rsv1Bit != 0 + rsv2 := p[0]&rsv2Bit != 0 + rsv3 := p[0]&rsv3Bit != 0 + mask := p[1]&maskBit != 0 + c.setReadRemaining(int64(p[1] & 0x7f)) + + c.readDecompress = false + if rsv1 { + if c.newDecompressionReader != nil { + c.readDecompress = true + } else { + errors = append(errors, "RSV1 set") + } + } + + if rsv2 { + errors = append(errors, "RSV2 set") + } + + if rsv3 { + errors = append(errors, "RSV3 set") + } + + switch frameType { + case CloseMessage, PingMessage, PongMessage: + if c.readRemaining > maxControlFramePayloadSize { + errors = append(errors, "len > 125 for control") + } + if !final { + errors = append(errors, "FIN not set on control") + } + case TextMessage, BinaryMessage: + if !c.readFinal { + errors = append(errors, "data before FIN") + } + c.readFinal = final + case continuationFrame: + if c.readFinal { + errors = append(errors, "continuation after FIN") + } + c.readFinal = final + default: + errors = append(errors, "bad opcode "+strconv.Itoa(frameType)) + } + + if mask != c.isServer { + errors = append(errors, "bad MASK") + } + + if len(errors) > 0 { + return noFrame, c.handleProtocolError(strings.Join(errors, ", ")) + } + + // 3. Read and parse frame length as per + // https://tools.ietf.org/html/rfc6455#section-5.2 + // + // The length of the "Payload data", in bytes: if 0-125, that is the payload + // length. + // - If 126, the following 2 bytes interpreted as a 16-bit unsigned + // integer are the payload length. + // - If 127, the following 8 bytes interpreted as + // a 64-bit unsigned integer (the most significant bit MUST be 0) are the + // payload length. Multibyte length quantities are expressed in network byte + // order. + + switch c.readRemaining { + case 126: + p, err := c.read(2) + if err != nil { + return noFrame, err + } + + if err := c.setReadRemaining(int64(binary.BigEndian.Uint16(p))); err != nil { + return noFrame, err + } + case 127: + p, err := c.read(8) + if err != nil { + return noFrame, err + } + + if err := c.setReadRemaining(int64(binary.BigEndian.Uint64(p))); err != nil { + return noFrame, err + } + } + + // 4. Handle frame masking. + + if mask { + c.readMaskPos = 0 + p, err := c.read(len(c.readMaskKey)) + if err != nil { + return noFrame, err + } + copy(c.readMaskKey[:], p) + } + + // 5. For text and binary messages, enforce read limit and return. + + if frameType == continuationFrame || frameType == TextMessage || frameType == BinaryMessage { + + c.readLength += c.readRemaining + // Don't allow readLength to overflow in the presence of a large readRemaining + // counter. + if c.readLength < 0 { + return noFrame, ErrReadLimit + } + + if c.readLimit > 0 && c.readLength > c.readLimit { + c.WriteControl(CloseMessage, FormatCloseMessage(CloseMessageTooBig, ""), time.Now().Add(writeWait)) + return noFrame, ErrReadLimit + } + + return frameType, nil + } + + // 6. Read control frame payload. + + var payload []byte + if c.readRemaining > 0 { + payload, err = c.read(int(c.readRemaining)) + c.setReadRemaining(0) + if err != nil { + return noFrame, err + } + if c.isServer { + maskBytes(c.readMaskKey, 0, payload) + } + } + + // 7. Process control frame payload. + + switch frameType { + case PongMessage: + if err := c.handlePong(string(payload)); err != nil { + return noFrame, err + } + case PingMessage: + if err := c.handlePing(string(payload)); err != nil { + return noFrame, err + } + case CloseMessage: + closeCode := CloseNoStatusReceived + closeText := "" + if len(payload) >= 2 { + closeCode = int(binary.BigEndian.Uint16(payload)) + if !isValidReceivedCloseCode(closeCode) { + return noFrame, c.handleProtocolError("bad close code " + strconv.Itoa(closeCode)) + } + closeText = string(payload[2:]) + if !utf8.ValidString(closeText) { + return noFrame, c.handleProtocolError("invalid utf8 payload in close frame") + } + } + if err := c.handleClose(closeCode, closeText); err != nil { + return noFrame, err + } + return noFrame, &CloseError{Code: closeCode, Text: closeText} + } + + return frameType, nil +} + +func (c *Conn) handleProtocolError(message string) error { + data := FormatCloseMessage(CloseProtocolError, message) + if len(data) > maxControlFramePayloadSize { + data = data[:maxControlFramePayloadSize] + } + c.WriteControl(CloseMessage, data, time.Now().Add(writeWait)) + return errors.New("websocket: " + message) +} + +// NextReader returns the next data message received from the peer. The +// returned messageType is either TextMessage or BinaryMessage. +// +// There can be at most one open reader on a connection. NextReader discards +// the previous message if the application has not already consumed it. +// +// Applications must break out of the application's read loop when this method +// returns a non-nil error value. Errors returned from this method are +// permanent. Once this method returns a non-nil error, all subsequent calls to +// this method return the same error. +func (c *Conn) NextReader() (messageType int, r io.Reader, err error) { + // Close previous reader, only relevant for decompression. + if c.reader != nil { + c.reader.Close() + c.reader = nil + } + + c.messageReader = nil + c.readLength = 0 + + for c.readErr == nil { + frameType, err := c.advanceFrame() + if err != nil { + c.readErr = hideTempErr(err) + break + } + + if frameType == TextMessage || frameType == BinaryMessage { + c.messageReader = &messageReader{c} + c.reader = c.messageReader + if c.readDecompress { + c.reader = c.newDecompressionReader(c.reader) + } + return frameType, c.reader, nil + } + } + + // Applications that do handle the error returned from this method spin in + // tight loop on connection failure. To help application developers detect + // this error, panic on repeated reads to the failed connection. + c.readErrCount++ + if c.readErrCount >= 1000 { + panic("repeated read on failed websocket connection") + } + + return noFrame, nil, c.readErr +} + +type messageReader struct{ c *Conn } + +func (r *messageReader) Read(b []byte) (int, error) { + c := r.c + if c.messageReader != r { + return 0, io.EOF + } + + for c.readErr == nil { + + if c.readRemaining > 0 { + if int64(len(b)) > c.readRemaining { + b = b[:c.readRemaining] + } + n, err := c.br.Read(b) + c.readErr = hideTempErr(err) + if c.isServer { + c.readMaskPos = maskBytes(c.readMaskKey, c.readMaskPos, b[:n]) + } + rem := c.readRemaining + rem -= int64(n) + c.setReadRemaining(rem) + if c.readRemaining > 0 && c.readErr == io.EOF { + c.readErr = errUnexpectedEOF + } + return n, c.readErr + } + + if c.readFinal { + c.messageReader = nil + return 0, io.EOF + } + + frameType, err := c.advanceFrame() + switch { + case err != nil: + c.readErr = hideTempErr(err) + case frameType == TextMessage || frameType == BinaryMessage: + c.readErr = errors.New("websocket: internal error, unexpected text or binary in Reader") + } + } + + err := c.readErr + if err == io.EOF && c.messageReader == r { + err = errUnexpectedEOF + } + return 0, err +} + +func (r *messageReader) Close() error { + return nil +} + +// ReadMessage is a helper method for getting a reader using NextReader and +// reading from that reader to a buffer. +func (c *Conn) ReadMessage() (messageType int, p []byte, err error) { + var r io.Reader + messageType, r, err = c.NextReader() + if err != nil { + return messageType, nil, err + } + p, err = ioutil.ReadAll(r) + return messageType, p, err +} + +// SetReadDeadline sets the read deadline on the underlying network connection. +// After a read has timed out, the websocket connection state is corrupt and +// all future reads will return an error. A zero value for t means reads will +// not time out. +func (c *Conn) SetReadDeadline(t time.Time) error { + return c.conn.SetReadDeadline(t) +} + +// SetReadLimit sets the maximum size in bytes for a message read from the peer. If a +// message exceeds the limit, the connection sends a close message to the peer +// and returns ErrReadLimit to the application. +func (c *Conn) SetReadLimit(limit int64) { + c.readLimit = limit +} + +// CloseHandler returns the current close handler +func (c *Conn) CloseHandler() func(code int, text string) error { + return c.handleClose +} + +// SetCloseHandler sets the handler for close messages received from the peer. +// The code argument to h is the received close code or CloseNoStatusReceived +// if the close message is empty. The default close handler sends a close +// message back to the peer. +// +// The handler function is called from the NextReader, ReadMessage and message +// reader Read methods. The application must read the connection to process +// close messages as described in the section on Control Messages above. +// +// The connection read methods return a CloseError when a close message is +// received. Most applications should handle close messages as part of their +// normal error handling. Applications should only set a close handler when the +// application must perform some action before sending a close message back to +// the peer. +func (c *Conn) SetCloseHandler(h func(code int, text string) error) { + if h == nil { + h = func(code int, text string) error { + message := FormatCloseMessage(code, "") + c.WriteControl(CloseMessage, message, time.Now().Add(writeWait)) + return nil + } + } + c.handleClose = h +} + +// PingHandler returns the current ping handler +func (c *Conn) PingHandler() func(appData string) error { + return c.handlePing +} + +// SetPingHandler sets the handler for ping messages received from the peer. +// The appData argument to h is the PING message application data. The default +// ping handler sends a pong to the peer. +// +// The handler function is called from the NextReader, ReadMessage and message +// reader Read methods. The application must read the connection to process +// ping messages as described in the section on Control Messages above. +func (c *Conn) SetPingHandler(h func(appData string) error) { + if h == nil { + h = func(message string) error { + err := c.WriteControl(PongMessage, []byte(message), time.Now().Add(writeWait)) + if err == ErrCloseSent { + return nil + } else if e, ok := err.(net.Error); ok && e.Temporary() { + return nil + } + return err + } + } + c.handlePing = h +} + +// PongHandler returns the current pong handler +func (c *Conn) PongHandler() func(appData string) error { + return c.handlePong +} + +// SetPongHandler sets the handler for pong messages received from the peer. +// The appData argument to h is the PONG message application data. The default +// pong handler does nothing. +// +// The handler function is called from the NextReader, ReadMessage and message +// reader Read methods. The application must read the connection to process +// pong messages as described in the section on Control Messages above. +func (c *Conn) SetPongHandler(h func(appData string) error) { + if h == nil { + h = func(string) error { return nil } + } + c.handlePong = h +} + +// NetConn returns the underlying connection that is wrapped by c. +// Note that writing to or reading from this connection directly will corrupt the +// WebSocket connection. +func (c *Conn) NetConn() net.Conn { + return c.conn +} + +// UnderlyingConn returns the internal net.Conn. This can be used to further +// modifications to connection specific flags. +// Deprecated: Use the NetConn method. +func (c *Conn) UnderlyingConn() net.Conn { + return c.conn +} + +// EnableWriteCompression enables and disables write compression of +// subsequent text and binary messages. This function is a noop if +// compression was not negotiated with the peer. +func (c *Conn) EnableWriteCompression(enable bool) { + c.enableWriteCompression = enable +} + +// SetCompressionLevel sets the flate compression level for subsequent text and +// binary messages. This function is a noop if compression was not negotiated +// with the peer. See the compress/flate package for a description of +// compression levels. +func (c *Conn) SetCompressionLevel(level int) error { + if !isValidCompressionLevel(level) { + return errors.New("websocket: invalid compression level") + } + c.compressionLevel = level + return nil +} + +// FormatCloseMessage formats closeCode and text as a WebSocket close message. +// An empty message is returned for code CloseNoStatusReceived. +func FormatCloseMessage(closeCode int, text string) []byte { + if closeCode == CloseNoStatusReceived { + // Return empty message because it's illegal to send + // CloseNoStatusReceived. Return non-nil value in case application + // checks for nil. + return []byte{} + } + buf := make([]byte, 2+len(text)) + binary.BigEndian.PutUint16(buf, uint16(closeCode)) + copy(buf[2:], text) + return buf +} diff --git a/mcp/dsx-exchange-mcp/vendor/github.com/gorilla/websocket/doc.go b/mcp/dsx-exchange-mcp/vendor/github.com/gorilla/websocket/doc.go new file mode 100644 index 0000000..8db0cef --- /dev/null +++ b/mcp/dsx-exchange-mcp/vendor/github.com/gorilla/websocket/doc.go @@ -0,0 +1,227 @@ +// Copyright 2013 The Gorilla WebSocket Authors. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +// Package websocket implements the WebSocket protocol defined in RFC 6455. +// +// Overview +// +// The Conn type represents a WebSocket connection. A server application calls +// the Upgrader.Upgrade method from an HTTP request handler to get a *Conn: +// +// var upgrader = websocket.Upgrader{ +// ReadBufferSize: 1024, +// WriteBufferSize: 1024, +// } +// +// func handler(w http.ResponseWriter, r *http.Request) { +// conn, err := upgrader.Upgrade(w, r, nil) +// if err != nil { +// log.Println(err) +// return +// } +// ... Use conn to send and receive messages. +// } +// +// Call the connection's WriteMessage and ReadMessage methods to send and +// receive messages as a slice of bytes. This snippet of code shows how to echo +// messages using these methods: +// +// for { +// messageType, p, err := conn.ReadMessage() +// if err != nil { +// log.Println(err) +// return +// } +// if err := conn.WriteMessage(messageType, p); err != nil { +// log.Println(err) +// return +// } +// } +// +// In above snippet of code, p is a []byte and messageType is an int with value +// websocket.BinaryMessage or websocket.TextMessage. +// +// An application can also send and receive messages using the io.WriteCloser +// and io.Reader interfaces. To send a message, call the connection NextWriter +// method to get an io.WriteCloser, write the message to the writer and close +// the writer when done. To receive a message, call the connection NextReader +// method to get an io.Reader and read until io.EOF is returned. This snippet +// shows how to echo messages using the NextWriter and NextReader methods: +// +// for { +// messageType, r, err := conn.NextReader() +// if err != nil { +// return +// } +// w, err := conn.NextWriter(messageType) +// if err != nil { +// return err +// } +// if _, err := io.Copy(w, r); err != nil { +// return err +// } +// if err := w.Close(); err != nil { +// return err +// } +// } +// +// Data Messages +// +// The WebSocket protocol distinguishes between text and binary data messages. +// Text messages are interpreted as UTF-8 encoded text. The interpretation of +// binary messages is left to the application. +// +// This package uses the TextMessage and BinaryMessage integer constants to +// identify the two data message types. The ReadMessage and NextReader methods +// return the type of the received message. The messageType argument to the +// WriteMessage and NextWriter methods specifies the type of a sent message. +// +// It is the application's responsibility to ensure that text messages are +// valid UTF-8 encoded text. +// +// Control Messages +// +// The WebSocket protocol defines three types of control messages: close, ping +// and pong. Call the connection WriteControl, WriteMessage or NextWriter +// methods to send a control message to the peer. +// +// Connections handle received close messages by calling the handler function +// set with the SetCloseHandler method and by returning a *CloseError from the +// NextReader, ReadMessage or the message Read method. The default close +// handler sends a close message to the peer. +// +// Connections handle received ping messages by calling the handler function +// set with the SetPingHandler method. The default ping handler sends a pong +// message to the peer. +// +// Connections handle received pong messages by calling the handler function +// set with the SetPongHandler method. The default pong handler does nothing. +// If an application sends ping messages, then the application should set a +// pong handler to receive the corresponding pong. +// +// The control message handler functions are called from the NextReader, +// ReadMessage and message reader Read methods. The default close and ping +// handlers can block these methods for a short time when the handler writes to +// the connection. +// +// The application must read the connection to process close, ping and pong +// messages sent from the peer. If the application is not otherwise interested +// in messages from the peer, then the application should start a goroutine to +// read and discard messages from the peer. A simple example is: +// +// func readLoop(c *websocket.Conn) { +// for { +// if _, _, err := c.NextReader(); err != nil { +// c.Close() +// break +// } +// } +// } +// +// Concurrency +// +// Connections support one concurrent reader and one concurrent writer. +// +// Applications are responsible for ensuring that no more than one goroutine +// calls the write methods (NextWriter, SetWriteDeadline, WriteMessage, +// WriteJSON, EnableWriteCompression, SetCompressionLevel) concurrently and +// that no more than one goroutine calls the read methods (NextReader, +// SetReadDeadline, ReadMessage, ReadJSON, SetPongHandler, SetPingHandler) +// concurrently. +// +// The Close and WriteControl methods can be called concurrently with all other +// methods. +// +// Origin Considerations +// +// Web browsers allow Javascript applications to open a WebSocket connection to +// any host. It's up to the server to enforce an origin policy using the Origin +// request header sent by the browser. +// +// The Upgrader calls the function specified in the CheckOrigin field to check +// the origin. If the CheckOrigin function returns false, then the Upgrade +// method fails the WebSocket handshake with HTTP status 403. +// +// If the CheckOrigin field is nil, then the Upgrader uses a safe default: fail +// the handshake if the Origin request header is present and the Origin host is +// not equal to the Host request header. +// +// The deprecated package-level Upgrade function does not perform origin +// checking. The application is responsible for checking the Origin header +// before calling the Upgrade function. +// +// Buffers +// +// Connections buffer network input and output to reduce the number +// of system calls when reading or writing messages. +// +// Write buffers are also used for constructing WebSocket frames. See RFC 6455, +// Section 5 for a discussion of message framing. A WebSocket frame header is +// written to the network each time a write buffer is flushed to the network. +// Decreasing the size of the write buffer can increase the amount of framing +// overhead on the connection. +// +// The buffer sizes in bytes are specified by the ReadBufferSize and +// WriteBufferSize fields in the Dialer and Upgrader. The Dialer uses a default +// size of 4096 when a buffer size field is set to zero. The Upgrader reuses +// buffers created by the HTTP server when a buffer size field is set to zero. +// The HTTP server buffers have a size of 4096 at the time of this writing. +// +// The buffer sizes do not limit the size of a message that can be read or +// written by a connection. +// +// Buffers are held for the lifetime of the connection by default. If the +// Dialer or Upgrader WriteBufferPool field is set, then a connection holds the +// write buffer only when writing a message. +// +// Applications should tune the buffer sizes to balance memory use and +// performance. Increasing the buffer size uses more memory, but can reduce the +// number of system calls to read or write the network. In the case of writing, +// increasing the buffer size can reduce the number of frame headers written to +// the network. +// +// Some guidelines for setting buffer parameters are: +// +// Limit the buffer sizes to the maximum expected message size. Buffers larger +// than the largest message do not provide any benefit. +// +// Depending on the distribution of message sizes, setting the buffer size to +// a value less than the maximum expected message size can greatly reduce memory +// use with a small impact on performance. Here's an example: If 99% of the +// messages are smaller than 256 bytes and the maximum message size is 512 +// bytes, then a buffer size of 256 bytes will result in 1.01 more system calls +// than a buffer size of 512 bytes. The memory savings is 50%. +// +// A write buffer pool is useful when the application has a modest number +// writes over a large number of connections. when buffers are pooled, a larger +// buffer size has a reduced impact on total memory use and has the benefit of +// reducing system calls and frame overhead. +// +// Compression EXPERIMENTAL +// +// Per message compression extensions (RFC 7692) are experimentally supported +// by this package in a limited capacity. Setting the EnableCompression option +// to true in Dialer or Upgrader will attempt to negotiate per message deflate +// support. +// +// var upgrader = websocket.Upgrader{ +// EnableCompression: true, +// } +// +// If compression was successfully negotiated with the connection's peer, any +// message received in compressed form will be automatically decompressed. +// All Read methods will return uncompressed bytes. +// +// Per message compression of messages written to a connection can be enabled +// or disabled by calling the corresponding Conn method: +// +// conn.EnableWriteCompression(false) +// +// Currently this package does not support compression with "context takeover". +// This means that messages must be compressed and decompressed in isolation, +// without retaining sliding window or dictionary state across messages. For +// more details refer to RFC 7692. +// +// Use of compression is experimental and may result in decreased performance. +package websocket diff --git a/mcp/dsx-exchange-mcp/vendor/github.com/gorilla/websocket/join.go b/mcp/dsx-exchange-mcp/vendor/github.com/gorilla/websocket/join.go new file mode 100644 index 0000000..c64f8c8 --- /dev/null +++ b/mcp/dsx-exchange-mcp/vendor/github.com/gorilla/websocket/join.go @@ -0,0 +1,42 @@ +// Copyright 2019 The Gorilla WebSocket Authors. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +package websocket + +import ( + "io" + "strings" +) + +// JoinMessages concatenates received messages to create a single io.Reader. +// The string term is appended to each message. The returned reader does not +// support concurrent calls to the Read method. +func JoinMessages(c *Conn, term string) io.Reader { + return &joinReader{c: c, term: term} +} + +type joinReader struct { + c *Conn + term string + r io.Reader +} + +func (r *joinReader) Read(p []byte) (int, error) { + if r.r == nil { + var err error + _, r.r, err = r.c.NextReader() + if err != nil { + return 0, err + } + if r.term != "" { + r.r = io.MultiReader(r.r, strings.NewReader(r.term)) + } + } + n, err := r.r.Read(p) + if err == io.EOF { + err = nil + r.r = nil + } + return n, err +} diff --git a/mcp/dsx-exchange-mcp/vendor/github.com/gorilla/websocket/json.go b/mcp/dsx-exchange-mcp/vendor/github.com/gorilla/websocket/json.go new file mode 100644 index 0000000..dc2c1f6 --- /dev/null +++ b/mcp/dsx-exchange-mcp/vendor/github.com/gorilla/websocket/json.go @@ -0,0 +1,60 @@ +// Copyright 2013 The Gorilla WebSocket Authors. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +package websocket + +import ( + "encoding/json" + "io" +) + +// WriteJSON writes the JSON encoding of v as a message. +// +// Deprecated: Use c.WriteJSON instead. +func WriteJSON(c *Conn, v interface{}) error { + return c.WriteJSON(v) +} + +// WriteJSON writes the JSON encoding of v as a message. +// +// See the documentation for encoding/json Marshal for details about the +// conversion of Go values to JSON. +func (c *Conn) WriteJSON(v interface{}) error { + w, err := c.NextWriter(TextMessage) + if err != nil { + return err + } + err1 := json.NewEncoder(w).Encode(v) + err2 := w.Close() + if err1 != nil { + return err1 + } + return err2 +} + +// ReadJSON reads the next JSON-encoded message from the connection and stores +// it in the value pointed to by v. +// +// Deprecated: Use c.ReadJSON instead. +func ReadJSON(c *Conn, v interface{}) error { + return c.ReadJSON(v) +} + +// ReadJSON reads the next JSON-encoded message from the connection and stores +// it in the value pointed to by v. +// +// See the documentation for the encoding/json Unmarshal function for details +// about the conversion of JSON to a Go value. +func (c *Conn) ReadJSON(v interface{}) error { + _, r, err := c.NextReader() + if err != nil { + return err + } + err = json.NewDecoder(r).Decode(v) + if err == io.EOF { + // One value is expected in the message. + err = io.ErrUnexpectedEOF + } + return err +} diff --git a/mcp/dsx-exchange-mcp/vendor/github.com/gorilla/websocket/mask.go b/mcp/dsx-exchange-mcp/vendor/github.com/gorilla/websocket/mask.go new file mode 100644 index 0000000..d0742bf --- /dev/null +++ b/mcp/dsx-exchange-mcp/vendor/github.com/gorilla/websocket/mask.go @@ -0,0 +1,55 @@ +// Copyright 2016 The Gorilla WebSocket Authors. All rights reserved. Use of +// this source code is governed by a BSD-style license that can be found in the +// LICENSE file. + +//go:build !appengine +// +build !appengine + +package websocket + +import "unsafe" + +const wordSize = int(unsafe.Sizeof(uintptr(0))) + +func maskBytes(key [4]byte, pos int, b []byte) int { + // Mask one byte at a time for small buffers. + if len(b) < 2*wordSize { + for i := range b { + b[i] ^= key[pos&3] + pos++ + } + return pos & 3 + } + + // Mask one byte at a time to word boundary. + if n := int(uintptr(unsafe.Pointer(&b[0]))) % wordSize; n != 0 { + n = wordSize - n + for i := range b[:n] { + b[i] ^= key[pos&3] + pos++ + } + b = b[n:] + } + + // Create aligned word size key. + var k [wordSize]byte + for i := range k { + k[i] = key[(pos+i)&3] + } + kw := *(*uintptr)(unsafe.Pointer(&k)) + + // Mask one word at a time. + n := (len(b) / wordSize) * wordSize + for i := 0; i < n; i += wordSize { + *(*uintptr)(unsafe.Pointer(uintptr(unsafe.Pointer(&b[0])) + uintptr(i))) ^= kw + } + + // Mask one byte at a time for remaining bytes. + b = b[n:] + for i := range b { + b[i] ^= key[pos&3] + pos++ + } + + return pos & 3 +} diff --git a/mcp/dsx-exchange-mcp/vendor/github.com/gorilla/websocket/mask_safe.go b/mcp/dsx-exchange-mcp/vendor/github.com/gorilla/websocket/mask_safe.go new file mode 100644 index 0000000..36250ca --- /dev/null +++ b/mcp/dsx-exchange-mcp/vendor/github.com/gorilla/websocket/mask_safe.go @@ -0,0 +1,16 @@ +// Copyright 2016 The Gorilla WebSocket Authors. All rights reserved. Use of +// this source code is governed by a BSD-style license that can be found in the +// LICENSE file. + +//go:build appengine +// +build appengine + +package websocket + +func maskBytes(key [4]byte, pos int, b []byte) int { + for i := range b { + b[i] ^= key[pos&3] + pos++ + } + return pos & 3 +} diff --git a/mcp/dsx-exchange-mcp/vendor/github.com/gorilla/websocket/prepared.go b/mcp/dsx-exchange-mcp/vendor/github.com/gorilla/websocket/prepared.go new file mode 100644 index 0000000..c854225 --- /dev/null +++ b/mcp/dsx-exchange-mcp/vendor/github.com/gorilla/websocket/prepared.go @@ -0,0 +1,102 @@ +// Copyright 2017 The Gorilla WebSocket Authors. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +package websocket + +import ( + "bytes" + "net" + "sync" + "time" +) + +// PreparedMessage caches on the wire representations of a message payload. +// Use PreparedMessage to efficiently send a message payload to multiple +// connections. PreparedMessage is especially useful when compression is used +// because the CPU and memory expensive compression operation can be executed +// once for a given set of compression options. +type PreparedMessage struct { + messageType int + data []byte + mu sync.Mutex + frames map[prepareKey]*preparedFrame +} + +// prepareKey defines a unique set of options to cache prepared frames in PreparedMessage. +type prepareKey struct { + isServer bool + compress bool + compressionLevel int +} + +// preparedFrame contains data in wire representation. +type preparedFrame struct { + once sync.Once + data []byte +} + +// NewPreparedMessage returns an initialized PreparedMessage. You can then send +// it to connection using WritePreparedMessage method. Valid wire +// representation will be calculated lazily only once for a set of current +// connection options. +func NewPreparedMessage(messageType int, data []byte) (*PreparedMessage, error) { + pm := &PreparedMessage{ + messageType: messageType, + frames: make(map[prepareKey]*preparedFrame), + data: data, + } + + // Prepare a plain server frame. + _, frameData, err := pm.frame(prepareKey{isServer: true, compress: false}) + if err != nil { + return nil, err + } + + // To protect against caller modifying the data argument, remember the data + // copied to the plain server frame. + pm.data = frameData[len(frameData)-len(data):] + return pm, nil +} + +func (pm *PreparedMessage) frame(key prepareKey) (int, []byte, error) { + pm.mu.Lock() + frame, ok := pm.frames[key] + if !ok { + frame = &preparedFrame{} + pm.frames[key] = frame + } + pm.mu.Unlock() + + var err error + frame.once.Do(func() { + // Prepare a frame using a 'fake' connection. + // TODO: Refactor code in conn.go to allow more direct construction of + // the frame. + mu := make(chan struct{}, 1) + mu <- struct{}{} + var nc prepareConn + c := &Conn{ + conn: &nc, + mu: mu, + isServer: key.isServer, + compressionLevel: key.compressionLevel, + enableWriteCompression: true, + writeBuf: make([]byte, defaultWriteBufferSize+maxFrameHeaderSize), + } + if key.compress { + c.newCompressionWriter = compressNoContextTakeover + } + err = c.WriteMessage(pm.messageType, pm.data) + frame.data = nc.buf.Bytes() + }) + return pm.messageType, frame.data, err +} + +type prepareConn struct { + buf bytes.Buffer + net.Conn +} + +func (pc *prepareConn) Write(p []byte) (int, error) { return pc.buf.Write(p) } +func (pc *prepareConn) SetWriteDeadline(t time.Time) error { return nil } diff --git a/mcp/dsx-exchange-mcp/vendor/github.com/gorilla/websocket/proxy.go b/mcp/dsx-exchange-mcp/vendor/github.com/gorilla/websocket/proxy.go new file mode 100644 index 0000000..e0f466b --- /dev/null +++ b/mcp/dsx-exchange-mcp/vendor/github.com/gorilla/websocket/proxy.go @@ -0,0 +1,77 @@ +// Copyright 2017 The Gorilla WebSocket Authors. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +package websocket + +import ( + "bufio" + "encoding/base64" + "errors" + "net" + "net/http" + "net/url" + "strings" +) + +type netDialerFunc func(network, addr string) (net.Conn, error) + +func (fn netDialerFunc) Dial(network, addr string) (net.Conn, error) { + return fn(network, addr) +} + +func init() { + proxy_RegisterDialerType("http", func(proxyURL *url.URL, forwardDialer proxy_Dialer) (proxy_Dialer, error) { + return &httpProxyDialer{proxyURL: proxyURL, forwardDial: forwardDialer.Dial}, nil + }) +} + +type httpProxyDialer struct { + proxyURL *url.URL + forwardDial func(network, addr string) (net.Conn, error) +} + +func (hpd *httpProxyDialer) Dial(network string, addr string) (net.Conn, error) { + hostPort, _ := hostPortNoPort(hpd.proxyURL) + conn, err := hpd.forwardDial(network, hostPort) + if err != nil { + return nil, err + } + + connectHeader := make(http.Header) + if user := hpd.proxyURL.User; user != nil { + proxyUser := user.Username() + if proxyPassword, passwordSet := user.Password(); passwordSet { + credential := base64.StdEncoding.EncodeToString([]byte(proxyUser + ":" + proxyPassword)) + connectHeader.Set("Proxy-Authorization", "Basic "+credential) + } + } + + connectReq := &http.Request{ + Method: http.MethodConnect, + URL: &url.URL{Opaque: addr}, + Host: addr, + Header: connectHeader, + } + + if err := connectReq.Write(conn); err != nil { + conn.Close() + return nil, err + } + + // Read response. It's OK to use and discard buffered reader here becaue + // the remote server does not speak until spoken to. + br := bufio.NewReader(conn) + resp, err := http.ReadResponse(br, connectReq) + if err != nil { + conn.Close() + return nil, err + } + + if resp.StatusCode != 200 { + conn.Close() + f := strings.SplitN(resp.Status, " ", 2) + return nil, errors.New(f[1]) + } + return conn, nil +} diff --git a/mcp/dsx-exchange-mcp/vendor/github.com/gorilla/websocket/server.go b/mcp/dsx-exchange-mcp/vendor/github.com/gorilla/websocket/server.go new file mode 100644 index 0000000..bb33597 --- /dev/null +++ b/mcp/dsx-exchange-mcp/vendor/github.com/gorilla/websocket/server.go @@ -0,0 +1,365 @@ +// Copyright 2013 The Gorilla WebSocket Authors. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +package websocket + +import ( + "bufio" + "errors" + "io" + "net/http" + "net/url" + "strings" + "time" +) + +// HandshakeError describes an error with the handshake from the peer. +type HandshakeError struct { + message string +} + +func (e HandshakeError) Error() string { return e.message } + +// Upgrader specifies parameters for upgrading an HTTP connection to a +// WebSocket connection. +// +// It is safe to call Upgrader's methods concurrently. +type Upgrader struct { + // HandshakeTimeout specifies the duration for the handshake to complete. + HandshakeTimeout time.Duration + + // ReadBufferSize and WriteBufferSize specify I/O buffer sizes in bytes. If a buffer + // size is zero, then buffers allocated by the HTTP server are used. The + // I/O buffer sizes do not limit the size of the messages that can be sent + // or received. + ReadBufferSize, WriteBufferSize int + + // WriteBufferPool is a pool of buffers for write operations. If the value + // is not set, then write buffers are allocated to the connection for the + // lifetime of the connection. + // + // A pool is most useful when the application has a modest volume of writes + // across a large number of connections. + // + // Applications should use a single pool for each unique value of + // WriteBufferSize. + WriteBufferPool BufferPool + + // Subprotocols specifies the server's supported protocols in order of + // preference. If this field is not nil, then the Upgrade method negotiates a + // subprotocol by selecting the first match in this list with a protocol + // requested by the client. If there's no match, then no protocol is + // negotiated (the Sec-Websocket-Protocol header is not included in the + // handshake response). + Subprotocols []string + + // Error specifies the function for generating HTTP error responses. If Error + // is nil, then http.Error is used to generate the HTTP response. + Error func(w http.ResponseWriter, r *http.Request, status int, reason error) + + // CheckOrigin returns true if the request Origin header is acceptable. If + // CheckOrigin is nil, then a safe default is used: return false if the + // Origin request header is present and the origin host is not equal to + // request Host header. + // + // A CheckOrigin function should carefully validate the request origin to + // prevent cross-site request forgery. + CheckOrigin func(r *http.Request) bool + + // EnableCompression specify if the server should attempt to negotiate per + // message compression (RFC 7692). Setting this value to true does not + // guarantee that compression will be supported. Currently only "no context + // takeover" modes are supported. + EnableCompression bool +} + +func (u *Upgrader) returnError(w http.ResponseWriter, r *http.Request, status int, reason string) (*Conn, error) { + err := HandshakeError{reason} + if u.Error != nil { + u.Error(w, r, status, err) + } else { + w.Header().Set("Sec-Websocket-Version", "13") + http.Error(w, http.StatusText(status), status) + } + return nil, err +} + +// checkSameOrigin returns true if the origin is not set or is equal to the request host. +func checkSameOrigin(r *http.Request) bool { + origin := r.Header["Origin"] + if len(origin) == 0 { + return true + } + u, err := url.Parse(origin[0]) + if err != nil { + return false + } + return equalASCIIFold(u.Host, r.Host) +} + +func (u *Upgrader) selectSubprotocol(r *http.Request, responseHeader http.Header) string { + if u.Subprotocols != nil { + clientProtocols := Subprotocols(r) + for _, serverProtocol := range u.Subprotocols { + for _, clientProtocol := range clientProtocols { + if clientProtocol == serverProtocol { + return clientProtocol + } + } + } + } else if responseHeader != nil { + return responseHeader.Get("Sec-Websocket-Protocol") + } + return "" +} + +// Upgrade upgrades the HTTP server connection to the WebSocket protocol. +// +// The responseHeader is included in the response to the client's upgrade +// request. Use the responseHeader to specify cookies (Set-Cookie). To specify +// subprotocols supported by the server, set Upgrader.Subprotocols directly. +// +// If the upgrade fails, then Upgrade replies to the client with an HTTP error +// response. +func (u *Upgrader) Upgrade(w http.ResponseWriter, r *http.Request, responseHeader http.Header) (*Conn, error) { + const badHandshake = "websocket: the client is not using the websocket protocol: " + + if !tokenListContainsValue(r.Header, "Connection", "upgrade") { + return u.returnError(w, r, http.StatusBadRequest, badHandshake+"'upgrade' token not found in 'Connection' header") + } + + if !tokenListContainsValue(r.Header, "Upgrade", "websocket") { + return u.returnError(w, r, http.StatusBadRequest, badHandshake+"'websocket' token not found in 'Upgrade' header") + } + + if r.Method != http.MethodGet { + return u.returnError(w, r, http.StatusMethodNotAllowed, badHandshake+"request method is not GET") + } + + if !tokenListContainsValue(r.Header, "Sec-Websocket-Version", "13") { + return u.returnError(w, r, http.StatusBadRequest, "websocket: unsupported version: 13 not found in 'Sec-Websocket-Version' header") + } + + if _, ok := responseHeader["Sec-Websocket-Extensions"]; ok { + return u.returnError(w, r, http.StatusInternalServerError, "websocket: application specific 'Sec-WebSocket-Extensions' headers are unsupported") + } + + checkOrigin := u.CheckOrigin + if checkOrigin == nil { + checkOrigin = checkSameOrigin + } + if !checkOrigin(r) { + return u.returnError(w, r, http.StatusForbidden, "websocket: request origin not allowed by Upgrader.CheckOrigin") + } + + challengeKey := r.Header.Get("Sec-Websocket-Key") + if !isValidChallengeKey(challengeKey) { + return u.returnError(w, r, http.StatusBadRequest, "websocket: not a websocket handshake: 'Sec-WebSocket-Key' header must be Base64 encoded value of 16-byte in length") + } + + subprotocol := u.selectSubprotocol(r, responseHeader) + + // Negotiate PMCE + var compress bool + if u.EnableCompression { + for _, ext := range parseExtensions(r.Header) { + if ext[""] != "permessage-deflate" { + continue + } + compress = true + break + } + } + + h, ok := w.(http.Hijacker) + if !ok { + return u.returnError(w, r, http.StatusInternalServerError, "websocket: response does not implement http.Hijacker") + } + var brw *bufio.ReadWriter + netConn, brw, err := h.Hijack() + if err != nil { + return u.returnError(w, r, http.StatusInternalServerError, err.Error()) + } + + if brw.Reader.Buffered() > 0 { + netConn.Close() + return nil, errors.New("websocket: client sent data before handshake is complete") + } + + var br *bufio.Reader + if u.ReadBufferSize == 0 && bufioReaderSize(netConn, brw.Reader) > 256 { + // Reuse hijacked buffered reader as connection reader. + br = brw.Reader + } + + buf := bufioWriterBuffer(netConn, brw.Writer) + + var writeBuf []byte + if u.WriteBufferPool == nil && u.WriteBufferSize == 0 && len(buf) >= maxFrameHeaderSize+256 { + // Reuse hijacked write buffer as connection buffer. + writeBuf = buf + } + + c := newConn(netConn, true, u.ReadBufferSize, u.WriteBufferSize, u.WriteBufferPool, br, writeBuf) + c.subprotocol = subprotocol + + if compress { + c.newCompressionWriter = compressNoContextTakeover + c.newDecompressionReader = decompressNoContextTakeover + } + + // Use larger of hijacked buffer and connection write buffer for header. + p := buf + if len(c.writeBuf) > len(p) { + p = c.writeBuf + } + p = p[:0] + + p = append(p, "HTTP/1.1 101 Switching Protocols\r\nUpgrade: websocket\r\nConnection: Upgrade\r\nSec-WebSocket-Accept: "...) + p = append(p, computeAcceptKey(challengeKey)...) + p = append(p, "\r\n"...) + if c.subprotocol != "" { + p = append(p, "Sec-WebSocket-Protocol: "...) + p = append(p, c.subprotocol...) + p = append(p, "\r\n"...) + } + if compress { + p = append(p, "Sec-WebSocket-Extensions: permessage-deflate; server_no_context_takeover; client_no_context_takeover\r\n"...) + } + for k, vs := range responseHeader { + if k == "Sec-Websocket-Protocol" { + continue + } + for _, v := range vs { + p = append(p, k...) + p = append(p, ": "...) + for i := 0; i < len(v); i++ { + b := v[i] + if b <= 31 { + // prevent response splitting. + b = ' ' + } + p = append(p, b) + } + p = append(p, "\r\n"...) + } + } + p = append(p, "\r\n"...) + + // Clear deadlines set by HTTP server. + netConn.SetDeadline(time.Time{}) + + if u.HandshakeTimeout > 0 { + netConn.SetWriteDeadline(time.Now().Add(u.HandshakeTimeout)) + } + if _, err = netConn.Write(p); err != nil { + netConn.Close() + return nil, err + } + if u.HandshakeTimeout > 0 { + netConn.SetWriteDeadline(time.Time{}) + } + + return c, nil +} + +// Upgrade upgrades the HTTP server connection to the WebSocket protocol. +// +// Deprecated: Use websocket.Upgrader instead. +// +// Upgrade does not perform origin checking. The application is responsible for +// checking the Origin header before calling Upgrade. An example implementation +// of the same origin policy check is: +// +// if req.Header.Get("Origin") != "http://"+req.Host { +// http.Error(w, "Origin not allowed", http.StatusForbidden) +// return +// } +// +// If the endpoint supports subprotocols, then the application is responsible +// for negotiating the protocol used on the connection. Use the Subprotocols() +// function to get the subprotocols requested by the client. Use the +// Sec-Websocket-Protocol response header to specify the subprotocol selected +// by the application. +// +// The responseHeader is included in the response to the client's upgrade +// request. Use the responseHeader to specify cookies (Set-Cookie) and the +// negotiated subprotocol (Sec-Websocket-Protocol). +// +// The connection buffers IO to the underlying network connection. The +// readBufSize and writeBufSize parameters specify the size of the buffers to +// use. Messages can be larger than the buffers. +// +// If the request is not a valid WebSocket handshake, then Upgrade returns an +// error of type HandshakeError. Applications should handle this error by +// replying to the client with an HTTP error response. +func Upgrade(w http.ResponseWriter, r *http.Request, responseHeader http.Header, readBufSize, writeBufSize int) (*Conn, error) { + u := Upgrader{ReadBufferSize: readBufSize, WriteBufferSize: writeBufSize} + u.Error = func(w http.ResponseWriter, r *http.Request, status int, reason error) { + // don't return errors to maintain backwards compatibility + } + u.CheckOrigin = func(r *http.Request) bool { + // allow all connections by default + return true + } + return u.Upgrade(w, r, responseHeader) +} + +// Subprotocols returns the subprotocols requested by the client in the +// Sec-Websocket-Protocol header. +func Subprotocols(r *http.Request) []string { + h := strings.TrimSpace(r.Header.Get("Sec-Websocket-Protocol")) + if h == "" { + return nil + } + protocols := strings.Split(h, ",") + for i := range protocols { + protocols[i] = strings.TrimSpace(protocols[i]) + } + return protocols +} + +// IsWebSocketUpgrade returns true if the client requested upgrade to the +// WebSocket protocol. +func IsWebSocketUpgrade(r *http.Request) bool { + return tokenListContainsValue(r.Header, "Connection", "upgrade") && + tokenListContainsValue(r.Header, "Upgrade", "websocket") +} + +// bufioReaderSize size returns the size of a bufio.Reader. +func bufioReaderSize(originalReader io.Reader, br *bufio.Reader) int { + // This code assumes that peek on a reset reader returns + // bufio.Reader.buf[:0]. + // TODO: Use bufio.Reader.Size() after Go 1.10 + br.Reset(originalReader) + if p, err := br.Peek(0); err == nil { + return cap(p) + } + return 0 +} + +// writeHook is an io.Writer that records the last slice passed to it vio +// io.Writer.Write. +type writeHook struct { + p []byte +} + +func (wh *writeHook) Write(p []byte) (int, error) { + wh.p = p + return len(p), nil +} + +// bufioWriterBuffer grabs the buffer from a bufio.Writer. +func bufioWriterBuffer(originalWriter io.Writer, bw *bufio.Writer) []byte { + // This code assumes that bufio.Writer.buf[:1] is passed to the + // bufio.Writer's underlying writer. + var wh writeHook + bw.Reset(&wh) + bw.WriteByte(0) + bw.Flush() + + bw.Reset(originalWriter) + + return wh.p[:cap(wh.p)] +} diff --git a/mcp/dsx-exchange-mcp/vendor/github.com/gorilla/websocket/tls_handshake.go b/mcp/dsx-exchange-mcp/vendor/github.com/gorilla/websocket/tls_handshake.go new file mode 100644 index 0000000..a62b68c --- /dev/null +++ b/mcp/dsx-exchange-mcp/vendor/github.com/gorilla/websocket/tls_handshake.go @@ -0,0 +1,21 @@ +//go:build go1.17 +// +build go1.17 + +package websocket + +import ( + "context" + "crypto/tls" +) + +func doHandshake(ctx context.Context, tlsConn *tls.Conn, cfg *tls.Config) error { + if err := tlsConn.HandshakeContext(ctx); err != nil { + return err + } + if !cfg.InsecureSkipVerify { + if err := tlsConn.VerifyHostname(cfg.ServerName); err != nil { + return err + } + } + return nil +} diff --git a/mcp/dsx-exchange-mcp/vendor/github.com/gorilla/websocket/tls_handshake_116.go b/mcp/dsx-exchange-mcp/vendor/github.com/gorilla/websocket/tls_handshake_116.go new file mode 100644 index 0000000..e1b2b44 --- /dev/null +++ b/mcp/dsx-exchange-mcp/vendor/github.com/gorilla/websocket/tls_handshake_116.go @@ -0,0 +1,21 @@ +//go:build !go1.17 +// +build !go1.17 + +package websocket + +import ( + "context" + "crypto/tls" +) + +func doHandshake(ctx context.Context, tlsConn *tls.Conn, cfg *tls.Config) error { + if err := tlsConn.Handshake(); err != nil { + return err + } + if !cfg.InsecureSkipVerify { + if err := tlsConn.VerifyHostname(cfg.ServerName); err != nil { + return err + } + } + return nil +} diff --git a/mcp/dsx-exchange-mcp/vendor/github.com/gorilla/websocket/util.go b/mcp/dsx-exchange-mcp/vendor/github.com/gorilla/websocket/util.go new file mode 100644 index 0000000..31a5dee --- /dev/null +++ b/mcp/dsx-exchange-mcp/vendor/github.com/gorilla/websocket/util.go @@ -0,0 +1,298 @@ +// Copyright 2013 The Gorilla WebSocket Authors. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +package websocket + +import ( + "crypto/rand" + "crypto/sha1" + "encoding/base64" + "io" + "net/http" + "strings" + "unicode/utf8" +) + +var keyGUID = []byte("258EAFA5-E914-47DA-95CA-C5AB0DC85B11") + +func computeAcceptKey(challengeKey string) string { + h := sha1.New() + h.Write([]byte(challengeKey)) + h.Write(keyGUID) + return base64.StdEncoding.EncodeToString(h.Sum(nil)) +} + +func generateChallengeKey() (string, error) { + p := make([]byte, 16) + if _, err := io.ReadFull(rand.Reader, p); err != nil { + return "", err + } + return base64.StdEncoding.EncodeToString(p), nil +} + +// Token octets per RFC 2616. +var isTokenOctet = [256]bool{ + '!': true, + '#': true, + '$': true, + '%': true, + '&': true, + '\'': true, + '*': true, + '+': true, + '-': true, + '.': true, + '0': true, + '1': true, + '2': true, + '3': true, + '4': true, + '5': true, + '6': true, + '7': true, + '8': true, + '9': true, + 'A': true, + 'B': true, + 'C': true, + 'D': true, + 'E': true, + 'F': true, + 'G': true, + 'H': true, + 'I': true, + 'J': true, + 'K': true, + 'L': true, + 'M': true, + 'N': true, + 'O': true, + 'P': true, + 'Q': true, + 'R': true, + 'S': true, + 'T': true, + 'U': true, + 'W': true, + 'V': true, + 'X': true, + 'Y': true, + 'Z': true, + '^': true, + '_': true, + '`': true, + 'a': true, + 'b': true, + 'c': true, + 'd': true, + 'e': true, + 'f': true, + 'g': true, + 'h': true, + 'i': true, + 'j': true, + 'k': true, + 'l': true, + 'm': true, + 'n': true, + 'o': true, + 'p': true, + 'q': true, + 'r': true, + 's': true, + 't': true, + 'u': true, + 'v': true, + 'w': true, + 'x': true, + 'y': true, + 'z': true, + '|': true, + '~': true, +} + +// skipSpace returns a slice of the string s with all leading RFC 2616 linear +// whitespace removed. +func skipSpace(s string) (rest string) { + i := 0 + for ; i < len(s); i++ { + if b := s[i]; b != ' ' && b != '\t' { + break + } + } + return s[i:] +} + +// nextToken returns the leading RFC 2616 token of s and the string following +// the token. +func nextToken(s string) (token, rest string) { + i := 0 + for ; i < len(s); i++ { + if !isTokenOctet[s[i]] { + break + } + } + return s[:i], s[i:] +} + +// nextTokenOrQuoted returns the leading token or quoted string per RFC 2616 +// and the string following the token or quoted string. +func nextTokenOrQuoted(s string) (value string, rest string) { + if !strings.HasPrefix(s, "\"") { + return nextToken(s) + } + s = s[1:] + for i := 0; i < len(s); i++ { + switch s[i] { + case '"': + return s[:i], s[i+1:] + case '\\': + p := make([]byte, len(s)-1) + j := copy(p, s[:i]) + escape := true + for i = i + 1; i < len(s); i++ { + b := s[i] + switch { + case escape: + escape = false + p[j] = b + j++ + case b == '\\': + escape = true + case b == '"': + return string(p[:j]), s[i+1:] + default: + p[j] = b + j++ + } + } + return "", "" + } + } + return "", "" +} + +// equalASCIIFold returns true if s is equal to t with ASCII case folding as +// defined in RFC 4790. +func equalASCIIFold(s, t string) bool { + for s != "" && t != "" { + sr, size := utf8.DecodeRuneInString(s) + s = s[size:] + tr, size := utf8.DecodeRuneInString(t) + t = t[size:] + if sr == tr { + continue + } + if 'A' <= sr && sr <= 'Z' { + sr = sr + 'a' - 'A' + } + if 'A' <= tr && tr <= 'Z' { + tr = tr + 'a' - 'A' + } + if sr != tr { + return false + } + } + return s == t +} + +// tokenListContainsValue returns true if the 1#token header with the given +// name contains a token equal to value with ASCII case folding. +func tokenListContainsValue(header http.Header, name string, value string) bool { +headers: + for _, s := range header[name] { + for { + var t string + t, s = nextToken(skipSpace(s)) + if t == "" { + continue headers + } + s = skipSpace(s) + if s != "" && s[0] != ',' { + continue headers + } + if equalASCIIFold(t, value) { + return true + } + if s == "" { + continue headers + } + s = s[1:] + } + } + return false +} + +// parseExtensions parses WebSocket extensions from a header. +func parseExtensions(header http.Header) []map[string]string { + // From RFC 6455: + // + // Sec-WebSocket-Extensions = extension-list + // extension-list = 1#extension + // extension = extension-token *( ";" extension-param ) + // extension-token = registered-token + // registered-token = token + // extension-param = token [ "=" (token | quoted-string) ] + // ;When using the quoted-string syntax variant, the value + // ;after quoted-string unescaping MUST conform to the + // ;'token' ABNF. + + var result []map[string]string +headers: + for _, s := range header["Sec-Websocket-Extensions"] { + for { + var t string + t, s = nextToken(skipSpace(s)) + if t == "" { + continue headers + } + ext := map[string]string{"": t} + for { + s = skipSpace(s) + if !strings.HasPrefix(s, ";") { + break + } + var k string + k, s = nextToken(skipSpace(s[1:])) + if k == "" { + continue headers + } + s = skipSpace(s) + var v string + if strings.HasPrefix(s, "=") { + v, s = nextTokenOrQuoted(skipSpace(s[1:])) + s = skipSpace(s) + } + if s != "" && s[0] != ',' && s[0] != ';' { + continue headers + } + ext[k] = v + } + if s != "" && s[0] != ',' { + continue headers + } + result = append(result, ext) + if s == "" { + continue headers + } + s = s[1:] + } + } + return result +} + +// isValidChallengeKey checks if the argument meets RFC6455 specification. +func isValidChallengeKey(s string) bool { + // From RFC6455: + // + // A |Sec-WebSocket-Key| header field with a base64-encoded (see + // Section 4 of [RFC4648]) value that, when decoded, is 16 bytes in + // length. + + if s == "" { + return false + } + decoded, err := base64.StdEncoding.DecodeString(s) + return err == nil && len(decoded) == 16 +} diff --git a/mcp/dsx-exchange-mcp/vendor/github.com/gorilla/websocket/x_net_proxy.go b/mcp/dsx-exchange-mcp/vendor/github.com/gorilla/websocket/x_net_proxy.go new file mode 100644 index 0000000..2e668f6 --- /dev/null +++ b/mcp/dsx-exchange-mcp/vendor/github.com/gorilla/websocket/x_net_proxy.go @@ -0,0 +1,473 @@ +// Code generated by golang.org/x/tools/cmd/bundle. DO NOT EDIT. +//go:generate bundle -o x_net_proxy.go golang.org/x/net/proxy + +// Package proxy provides support for a variety of protocols to proxy network +// data. +// + +package websocket + +import ( + "errors" + "io" + "net" + "net/url" + "os" + "strconv" + "strings" + "sync" +) + +type proxy_direct struct{} + +// Direct is a direct proxy: one that makes network connections directly. +var proxy_Direct = proxy_direct{} + +func (proxy_direct) Dial(network, addr string) (net.Conn, error) { + return net.Dial(network, addr) +} + +// A PerHost directs connections to a default Dialer unless the host name +// requested matches one of a number of exceptions. +type proxy_PerHost struct { + def, bypass proxy_Dialer + + bypassNetworks []*net.IPNet + bypassIPs []net.IP + bypassZones []string + bypassHosts []string +} + +// NewPerHost returns a PerHost Dialer that directs connections to either +// defaultDialer or bypass, depending on whether the connection matches one of +// the configured rules. +func proxy_NewPerHost(defaultDialer, bypass proxy_Dialer) *proxy_PerHost { + return &proxy_PerHost{ + def: defaultDialer, + bypass: bypass, + } +} + +// Dial connects to the address addr on the given network through either +// defaultDialer or bypass. +func (p *proxy_PerHost) Dial(network, addr string) (c net.Conn, err error) { + host, _, err := net.SplitHostPort(addr) + if err != nil { + return nil, err + } + + return p.dialerForRequest(host).Dial(network, addr) +} + +func (p *proxy_PerHost) dialerForRequest(host string) proxy_Dialer { + if ip := net.ParseIP(host); ip != nil { + for _, net := range p.bypassNetworks { + if net.Contains(ip) { + return p.bypass + } + } + for _, bypassIP := range p.bypassIPs { + if bypassIP.Equal(ip) { + return p.bypass + } + } + return p.def + } + + for _, zone := range p.bypassZones { + if strings.HasSuffix(host, zone) { + return p.bypass + } + if host == zone[1:] { + // For a zone ".example.com", we match "example.com" + // too. + return p.bypass + } + } + for _, bypassHost := range p.bypassHosts { + if bypassHost == host { + return p.bypass + } + } + return p.def +} + +// AddFromString parses a string that contains comma-separated values +// specifying hosts that should use the bypass proxy. Each value is either an +// IP address, a CIDR range, a zone (*.example.com) or a host name +// (localhost). A best effort is made to parse the string and errors are +// ignored. +func (p *proxy_PerHost) AddFromString(s string) { + hosts := strings.Split(s, ",") + for _, host := range hosts { + host = strings.TrimSpace(host) + if len(host) == 0 { + continue + } + if strings.Contains(host, "/") { + // We assume that it's a CIDR address like 127.0.0.0/8 + if _, net, err := net.ParseCIDR(host); err == nil { + p.AddNetwork(net) + } + continue + } + if ip := net.ParseIP(host); ip != nil { + p.AddIP(ip) + continue + } + if strings.HasPrefix(host, "*.") { + p.AddZone(host[1:]) + continue + } + p.AddHost(host) + } +} + +// AddIP specifies an IP address that will use the bypass proxy. Note that +// this will only take effect if a literal IP address is dialed. A connection +// to a named host will never match an IP. +func (p *proxy_PerHost) AddIP(ip net.IP) { + p.bypassIPs = append(p.bypassIPs, ip) +} + +// AddNetwork specifies an IP range that will use the bypass proxy. Note that +// this will only take effect if a literal IP address is dialed. A connection +// to a named host will never match. +func (p *proxy_PerHost) AddNetwork(net *net.IPNet) { + p.bypassNetworks = append(p.bypassNetworks, net) +} + +// AddZone specifies a DNS suffix that will use the bypass proxy. A zone of +// "example.com" matches "example.com" and all of its subdomains. +func (p *proxy_PerHost) AddZone(zone string) { + if strings.HasSuffix(zone, ".") { + zone = zone[:len(zone)-1] + } + if !strings.HasPrefix(zone, ".") { + zone = "." + zone + } + p.bypassZones = append(p.bypassZones, zone) +} + +// AddHost specifies a host name that will use the bypass proxy. +func (p *proxy_PerHost) AddHost(host string) { + if strings.HasSuffix(host, ".") { + host = host[:len(host)-1] + } + p.bypassHosts = append(p.bypassHosts, host) +} + +// A Dialer is a means to establish a connection. +type proxy_Dialer interface { + // Dial connects to the given address via the proxy. + Dial(network, addr string) (c net.Conn, err error) +} + +// Auth contains authentication parameters that specific Dialers may require. +type proxy_Auth struct { + User, Password string +} + +// FromEnvironment returns the dialer specified by the proxy related variables in +// the environment. +func proxy_FromEnvironment() proxy_Dialer { + allProxy := proxy_allProxyEnv.Get() + if len(allProxy) == 0 { + return proxy_Direct + } + + proxyURL, err := url.Parse(allProxy) + if err != nil { + return proxy_Direct + } + proxy, err := proxy_FromURL(proxyURL, proxy_Direct) + if err != nil { + return proxy_Direct + } + + noProxy := proxy_noProxyEnv.Get() + if len(noProxy) == 0 { + return proxy + } + + perHost := proxy_NewPerHost(proxy, proxy_Direct) + perHost.AddFromString(noProxy) + return perHost +} + +// proxySchemes is a map from URL schemes to a function that creates a Dialer +// from a URL with such a scheme. +var proxy_proxySchemes map[string]func(*url.URL, proxy_Dialer) (proxy_Dialer, error) + +// RegisterDialerType takes a URL scheme and a function to generate Dialers from +// a URL with that scheme and a forwarding Dialer. Registered schemes are used +// by FromURL. +func proxy_RegisterDialerType(scheme string, f func(*url.URL, proxy_Dialer) (proxy_Dialer, error)) { + if proxy_proxySchemes == nil { + proxy_proxySchemes = make(map[string]func(*url.URL, proxy_Dialer) (proxy_Dialer, error)) + } + proxy_proxySchemes[scheme] = f +} + +// FromURL returns a Dialer given a URL specification and an underlying +// Dialer for it to make network requests. +func proxy_FromURL(u *url.URL, forward proxy_Dialer) (proxy_Dialer, error) { + var auth *proxy_Auth + if u.User != nil { + auth = new(proxy_Auth) + auth.User = u.User.Username() + if p, ok := u.User.Password(); ok { + auth.Password = p + } + } + + switch u.Scheme { + case "socks5": + return proxy_SOCKS5("tcp", u.Host, auth, forward) + } + + // If the scheme doesn't match any of the built-in schemes, see if it + // was registered by another package. + if proxy_proxySchemes != nil { + if f, ok := proxy_proxySchemes[u.Scheme]; ok { + return f(u, forward) + } + } + + return nil, errors.New("proxy: unknown scheme: " + u.Scheme) +} + +var ( + proxy_allProxyEnv = &proxy_envOnce{ + names: []string{"ALL_PROXY", "all_proxy"}, + } + proxy_noProxyEnv = &proxy_envOnce{ + names: []string{"NO_PROXY", "no_proxy"}, + } +) + +// envOnce looks up an environment variable (optionally by multiple +// names) once. It mitigates expensive lookups on some platforms +// (e.g. Windows). +// (Borrowed from net/http/transport.go) +type proxy_envOnce struct { + names []string + once sync.Once + val string +} + +func (e *proxy_envOnce) Get() string { + e.once.Do(e.init) + return e.val +} + +func (e *proxy_envOnce) init() { + for _, n := range e.names { + e.val = os.Getenv(n) + if e.val != "" { + return + } + } +} + +// SOCKS5 returns a Dialer that makes SOCKSv5 connections to the given address +// with an optional username and password. See RFC 1928 and RFC 1929. +func proxy_SOCKS5(network, addr string, auth *proxy_Auth, forward proxy_Dialer) (proxy_Dialer, error) { + s := &proxy_socks5{ + network: network, + addr: addr, + forward: forward, + } + if auth != nil { + s.user = auth.User + s.password = auth.Password + } + + return s, nil +} + +type proxy_socks5 struct { + user, password string + network, addr string + forward proxy_Dialer +} + +const proxy_socks5Version = 5 + +const ( + proxy_socks5AuthNone = 0 + proxy_socks5AuthPassword = 2 +) + +const proxy_socks5Connect = 1 + +const ( + proxy_socks5IP4 = 1 + proxy_socks5Domain = 3 + proxy_socks5IP6 = 4 +) + +var proxy_socks5Errors = []string{ + "", + "general failure", + "connection forbidden", + "network unreachable", + "host unreachable", + "connection refused", + "TTL expired", + "command not supported", + "address type not supported", +} + +// Dial connects to the address addr on the given network via the SOCKS5 proxy. +func (s *proxy_socks5) Dial(network, addr string) (net.Conn, error) { + switch network { + case "tcp", "tcp6", "tcp4": + default: + return nil, errors.New("proxy: no support for SOCKS5 proxy connections of type " + network) + } + + conn, err := s.forward.Dial(s.network, s.addr) + if err != nil { + return nil, err + } + if err := s.connect(conn, addr); err != nil { + conn.Close() + return nil, err + } + return conn, nil +} + +// connect takes an existing connection to a socks5 proxy server, +// and commands the server to extend that connection to target, +// which must be a canonical address with a host and port. +func (s *proxy_socks5) connect(conn net.Conn, target string) error { + host, portStr, err := net.SplitHostPort(target) + if err != nil { + return err + } + + port, err := strconv.Atoi(portStr) + if err != nil { + return errors.New("proxy: failed to parse port number: " + portStr) + } + if port < 1 || port > 0xffff { + return errors.New("proxy: port number out of range: " + portStr) + } + + // the size here is just an estimate + buf := make([]byte, 0, 6+len(host)) + + buf = append(buf, proxy_socks5Version) + if len(s.user) > 0 && len(s.user) < 256 && len(s.password) < 256 { + buf = append(buf, 2 /* num auth methods */, proxy_socks5AuthNone, proxy_socks5AuthPassword) + } else { + buf = append(buf, 1 /* num auth methods */, proxy_socks5AuthNone) + } + + if _, err := conn.Write(buf); err != nil { + return errors.New("proxy: failed to write greeting to SOCKS5 proxy at " + s.addr + ": " + err.Error()) + } + + if _, err := io.ReadFull(conn, buf[:2]); err != nil { + return errors.New("proxy: failed to read greeting from SOCKS5 proxy at " + s.addr + ": " + err.Error()) + } + if buf[0] != 5 { + return errors.New("proxy: SOCKS5 proxy at " + s.addr + " has unexpected version " + strconv.Itoa(int(buf[0]))) + } + if buf[1] == 0xff { + return errors.New("proxy: SOCKS5 proxy at " + s.addr + " requires authentication") + } + + // See RFC 1929 + if buf[1] == proxy_socks5AuthPassword { + buf = buf[:0] + buf = append(buf, 1 /* password protocol version */) + buf = append(buf, uint8(len(s.user))) + buf = append(buf, s.user...) + buf = append(buf, uint8(len(s.password))) + buf = append(buf, s.password...) + + if _, err := conn.Write(buf); err != nil { + return errors.New("proxy: failed to write authentication request to SOCKS5 proxy at " + s.addr + ": " + err.Error()) + } + + if _, err := io.ReadFull(conn, buf[:2]); err != nil { + return errors.New("proxy: failed to read authentication reply from SOCKS5 proxy at " + s.addr + ": " + err.Error()) + } + + if buf[1] != 0 { + return errors.New("proxy: SOCKS5 proxy at " + s.addr + " rejected username/password") + } + } + + buf = buf[:0] + buf = append(buf, proxy_socks5Version, proxy_socks5Connect, 0 /* reserved */) + + if ip := net.ParseIP(host); ip != nil { + if ip4 := ip.To4(); ip4 != nil { + buf = append(buf, proxy_socks5IP4) + ip = ip4 + } else { + buf = append(buf, proxy_socks5IP6) + } + buf = append(buf, ip...) + } else { + if len(host) > 255 { + return errors.New("proxy: destination host name too long: " + host) + } + buf = append(buf, proxy_socks5Domain) + buf = append(buf, byte(len(host))) + buf = append(buf, host...) + } + buf = append(buf, byte(port>>8), byte(port)) + + if _, err := conn.Write(buf); err != nil { + return errors.New("proxy: failed to write connect request to SOCKS5 proxy at " + s.addr + ": " + err.Error()) + } + + if _, err := io.ReadFull(conn, buf[:4]); err != nil { + return errors.New("proxy: failed to read connect reply from SOCKS5 proxy at " + s.addr + ": " + err.Error()) + } + + failure := "unknown error" + if int(buf[1]) < len(proxy_socks5Errors) { + failure = proxy_socks5Errors[buf[1]] + } + + if len(failure) > 0 { + return errors.New("proxy: SOCKS5 proxy at " + s.addr + " failed to connect: " + failure) + } + + bytesToDiscard := 0 + switch buf[3] { + case proxy_socks5IP4: + bytesToDiscard = net.IPv4len + case proxy_socks5IP6: + bytesToDiscard = net.IPv6len + case proxy_socks5Domain: + _, err := io.ReadFull(conn, buf[:1]) + if err != nil { + return errors.New("proxy: failed to read domain length from SOCKS5 proxy at " + s.addr + ": " + err.Error()) + } + bytesToDiscard = int(buf[0]) + default: + return errors.New("proxy: got unknown address type " + strconv.Itoa(int(buf[3])) + " from SOCKS5 proxy at " + s.addr) + } + + if cap(buf) < bytesToDiscard { + buf = make([]byte, bytesToDiscard) + } else { + buf = buf[:bytesToDiscard] + } + if _, err := io.ReadFull(conn, buf); err != nil { + return errors.New("proxy: failed to read address from SOCKS5 proxy at " + s.addr + ": " + err.Error()) + } + + // Also need to discard the port number + if _, err := io.ReadFull(conn, buf[:2]); err != nil { + return errors.New("proxy: failed to read port from SOCKS5 proxy at " + s.addr + ": " + err.Error()) + } + + return nil +} diff --git a/mcp/dsx-exchange-mcp/vendor/github.com/modelcontextprotocol/go-sdk/LICENSE b/mcp/dsx-exchange-mcp/vendor/github.com/modelcontextprotocol/go-sdk/LICENSE new file mode 100644 index 0000000..5791499 --- /dev/null +++ b/mcp/dsx-exchange-mcp/vendor/github.com/modelcontextprotocol/go-sdk/LICENSE @@ -0,0 +1,216 @@ +The MCP project is undergoing a licensing transition from the MIT License to the Apache License, Version 2.0 ("Apache-2.0"). All new code and specification contributions to the project are licensed under Apache-2.0. Documentation contributions (excluding specifications) are licensed under CC-BY-4.0. + +Contributions for which relicensing consent has been obtained are licensed under Apache-2.0. Contributions made by authors who originally licensed their work under the MIT License and who have not yet granted explicit permission to relicense remain licensed under the MIT License. + +No rights beyond those granted by the applicable original license are conveyed for such contributions. + +--- + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright + owner or by an individual or Legal Entity authorized to submit on behalf + of the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + +--- + +MIT License + +Copyright (c) 2024-2025 Model Context Protocol a Series of LF Projects, LLC. + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + +--- + +Creative Commons Attribution 4.0 International (CC-BY-4.0) + +Documentation in this project (excluding specifications) is licensed under +CC-BY-4.0. See https://creativecommons.org/licenses/by/4.0/legalcode for +the full license text. diff --git a/mcp/dsx-exchange-mcp/vendor/github.com/modelcontextprotocol/go-sdk/auth/auth.go b/mcp/dsx-exchange-mcp/vendor/github.com/modelcontextprotocol/go-sdk/auth/auth.go new file mode 100644 index 0000000..36ff259 --- /dev/null +++ b/mcp/dsx-exchange-mcp/vendor/github.com/modelcontextprotocol/go-sdk/auth/auth.go @@ -0,0 +1,170 @@ +// Copyright 2025 The Go MCP SDK Authors. All rights reserved. +// Use of this source code is governed by an MIT-style +// license that can be found in the LICENSE file. + +package auth + +import ( + "context" + "encoding/json" + "errors" + "net/http" + "slices" + "strings" + "time" + + "github.com/modelcontextprotocol/go-sdk/oauthex" +) + +// TokenInfo holds information from a bearer token. +type TokenInfo struct { + Scopes []string + Expiration time.Time + // UserID is an optional identifier for the authenticated user. + // If set by a TokenVerifier, it can be used by transports to prevent + // session hijacking by ensuring that all requests for a given session + // come from the same user. + UserID string + Extra map[string]any +} + +// The error that a TokenVerifier should return if the token cannot be verified. +var ErrInvalidToken = errors.New("invalid token") + +// The error that a TokenVerifier should return for OAuth-specific protocol errors. +var ErrOAuth = errors.New("oauth error") + +// A TokenVerifier checks the validity of a bearer token, and extracts information +// from it. If verification fails, it should return an error that unwraps to ErrInvalidToken. +// The HTTP request is provided in case verifying the token involves checking it. +type TokenVerifier func(ctx context.Context, token string, req *http.Request) (*TokenInfo, error) + +// RequireBearerTokenOptions are options for [RequireBearerToken]. +type RequireBearerTokenOptions struct { + // The URL for the resource server metadata OAuth flow, to be returned as part + // of the WWW-Authenticate header. + ResourceMetadataURL string + // The required scopes. + Scopes []string +} + +type tokenInfoKey struct{} + +// TokenInfoFromContext returns the [TokenInfo] stored in ctx, or nil if none. +func TokenInfoFromContext(ctx context.Context) *TokenInfo { + ti := ctx.Value(tokenInfoKey{}) + if ti == nil { + return nil + } + return ti.(*TokenInfo) +} + +// RequireBearerToken returns a piece of middleware that verifies a bearer token using the verifier. +// If verification succeeds, the [TokenInfo] is added to the request's context and the request proceeds. +// If verification fails, the request fails with a 401 Unauthenticated, and the WWW-Authenticate header +// is populated to enable [protected resource metadata]. +// +// [protected resource metadata]: https://datatracker.ietf.org/doc/rfc9728 +func RequireBearerToken(verifier TokenVerifier, opts *RequireBearerTokenOptions) func(http.Handler) http.Handler { + // Based on typescript-sdk/src/server/auth/middleware/bearerAuth.ts. + + return func(handler http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + tokenInfo, errmsg, code := verify(r, verifier, opts) + if code != 0 { + if code == http.StatusUnauthorized || code == http.StatusForbidden { + if opts != nil && opts.ResourceMetadataURL != "" { + w.Header().Add("WWW-Authenticate", "Bearer resource_metadata="+opts.ResourceMetadataURL) + } + } + http.Error(w, errmsg, code) + return + } + r = r.WithContext(context.WithValue(r.Context(), tokenInfoKey{}, tokenInfo)) + handler.ServeHTTP(w, r) + }) + } +} + +func verify(req *http.Request, verifier TokenVerifier, opts *RequireBearerTokenOptions) (_ *TokenInfo, errmsg string, code int) { + // Extract bearer token. + authHeader := req.Header.Get("Authorization") + fields := strings.Fields(authHeader) + if len(fields) != 2 || strings.ToLower(fields[0]) != "bearer" { + return nil, "no bearer token", http.StatusUnauthorized + } + + // Verify the token and get information from it. + tokenInfo, err := verifier(req.Context(), fields[1], req) + if err != nil { + if errors.Is(err, ErrInvalidToken) { + return nil, err.Error(), http.StatusUnauthorized + } + if errors.Is(err, ErrOAuth) { + return nil, err.Error(), http.StatusBadRequest + } + return nil, err.Error(), http.StatusInternalServerError + } + if tokenInfo == nil { + return nil, "token validation failed", http.StatusInternalServerError + } + + // Check scopes. All must be present. + if opts != nil { + // Note: quadratic, but N is small. + for _, s := range opts.Scopes { + if !slices.Contains(tokenInfo.Scopes, s) { + return nil, "insufficient scope", http.StatusForbidden + } + } + } + + // Check expiration. + if tokenInfo.Expiration.IsZero() { + return nil, "token missing expiration", http.StatusUnauthorized + } + if tokenInfo.Expiration.Before(time.Now()) { + return nil, "token expired", http.StatusUnauthorized + } + return tokenInfo, "", 0 +} + +// ProtectedResourceMetadataHandler returns an http.Handler that serves OAuth 2.0 +// protected resource metadata (RFC 9728) with CORS support. +// +// This handler allows cross-origin requests from any origin (Access-Control-Allow-Origin: *) +// because OAuth metadata is public information intended for client discovery (RFC 9728 §3.1). +// The metadata contains only non-sensitive configuration data about authorization servers +// and supported scopes. +// +// No validation of metadata fields is performed; ensure metadata accuracy at configuration time. +// +// For more sophisticated CORS policies or to restrict origins, wrap this handler with a +// CORS middleware like github.com/rs/cors or github.com/jub0bs/cors. +func ProtectedResourceMetadataHandler(metadata *oauthex.ProtectedResourceMetadata) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + // Set CORS headers for cross-origin client discovery. + // OAuth metadata is public information, so allowing any origin is safe. + w.Header().Set("Access-Control-Allow-Origin", "*") + w.Header().Set("Access-Control-Allow-Methods", "GET, OPTIONS") + w.Header().Set("Access-Control-Allow-Headers", "Content-Type") + + // Handle CORS preflight requests + if r.Method == http.MethodOptions { + w.WriteHeader(http.StatusNoContent) + return + } + + // Only GET allowed for metadata retrieval + if r.Method != http.MethodGet { + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) + return + } + + w.Header().Set("Content-Type", "application/json") + if err := json.NewEncoder(w).Encode(metadata); err != nil { + http.Error(w, "Failed to encode metadata", http.StatusInternalServerError) + return + } + }) +} diff --git a/mcp/dsx-exchange-mcp/vendor/github.com/modelcontextprotocol/go-sdk/auth/authorization_code.go b/mcp/dsx-exchange-mcp/vendor/github.com/modelcontextprotocol/go-sdk/auth/authorization_code.go new file mode 100644 index 0000000..2a6ed32 --- /dev/null +++ b/mcp/dsx-exchange-mcp/vendor/github.com/modelcontextprotocol/go-sdk/auth/authorization_code.go @@ -0,0 +1,548 @@ +// Copyright 2026 The Go MCP SDK Authors. All rights reserved. +// Use of this source code is governed by the license +// that can be found in the LICENSE file. + +//go:build mcp_go_client_oauth + +package auth + +import ( + "context" + "crypto/rand" + "errors" + "fmt" + "net/http" + "net/url" + "slices" + "strings" + + "github.com/modelcontextprotocol/go-sdk/oauthex" + "golang.org/x/oauth2" +) + +// ClientSecretAuthConfig is used to configure client authentication using client_secret. +// Authentication method will be selected based on the authorization server's supported methods, +// according to the following preference order: +// 1. client_secret_post +// 2. client_secret_basic +type ClientSecretAuthConfig struct { + // ClientID is the client ID to be used for client authentication. + ClientID string + // ClientSecret is the client secret to be used for client authentication. + ClientSecret string +} + +// ClientIDMetadataDocumentConfig is used to configure the Client ID Metadata Document +// based client registration per +// https://modelcontextprotocol.io/specification/2025-11-25/basic/authorization#client-id-metadata-documents. +// See https://client.dev/ for more information. +type ClientIDMetadataDocumentConfig struct { + // URL is the client identifier URL as per + // https://datatracker.ietf.org/doc/html/draft-ietf-oauth-client-id-metadata-document-00#section-3. + URL string +} + +// PreregisteredClientConfig is used to configure a pre-registered client per +// https://modelcontextprotocol.io/specification/2025-11-25/basic/authorization#preregistration. +// Currently only "client_secret_basic" and "client_secret_post" authentication methods are supported. +type PreregisteredClientConfig struct { + // ClientSecretAuthConfig is the client_secret based configuration to be used for client authentication. + ClientSecretAuthConfig *ClientSecretAuthConfig +} + +// DynamicClientRegistrationConfig is used to configure dynamic client registration per +// https://modelcontextprotocol.io/specification/2025-11-25/basic/authorization#dynamic-client-registration. +type DynamicClientRegistrationConfig struct { + // Metadata to be used in dynamic client registration request as per + // https://datatracker.ietf.org/doc/html/rfc7591#section-2. + Metadata *oauthex.ClientRegistrationMetadata +} + +// AuthorizationResult is the result of an authorization flow. +// It is returned by [AuthorizationCodeHandler].AuthorizationCodeFetcher implementations. +type AuthorizationResult struct { + // Code is the authorization code obtained from the authorization server. + Code string + // State string returned by the authorization server. + State string +} + +// AuthorizationArgs is the input to [AuthorizationCodeHandlerConfig].AuthorizationCodeFetcher. +type AuthorizationArgs struct { + // Authorization URL to be opened in a browser for the user to start the authorization process. + URL string +} + +// AuthorizationCodeHandlerConfig is the configuration for [AuthorizationCodeHandler]. +type AuthorizationCodeHandlerConfig struct { + // Client registration configuration. + // It is attempted in the following order: + // 1. Client ID Metadata Document + // 2. Preregistration + // 3. Dynamic Client Registration + // At least one method must be configured. + ClientIDMetadataDocumentConfig *ClientIDMetadataDocumentConfig + PreregisteredClientConfig *PreregisteredClientConfig + DynamicClientRegistrationConfig *DynamicClientRegistrationConfig + + // RedirectURL is a required URL to redirect to after authorization. + // The caller is responsible for handling the redirect out of band. + // + // If Dynamic Client Registration is used: + // - this field is permitted to be empty, in which case it will be set + // to the first redirect URI from + // DynamicClientRegistrationConfig.Metadata.RedirectURIs. + // - if the field is not empty, it must be one of the redirect URIs in + // DynamicClientRegistrationConfig.Metadata.RedirectURIs. + RedirectURL string + + // AuthorizationCodeFetcher is a required function called to initiate the authorization flow. + // It is responsible for opening the URL in a browser for the user to start the authorization process. + // It should return the authorization code and state once the Authorization Server + // redirects back to the RedirectURL. + AuthorizationCodeFetcher func(ctx context.Context, args *AuthorizationArgs) (*AuthorizationResult, error) +} + +// AuthorizationCodeHandler is an implementation of [OAuthHandler] that uses +// the authorization code flow to obtain access tokens. +type AuthorizationCodeHandler struct { + config *AuthorizationCodeHandlerConfig + + // tokenSource is the token source to use for authorization. + tokenSource oauth2.TokenSource +} + +var _ OAuthHandler = (*AuthorizationCodeHandler)(nil) + +func (h *AuthorizationCodeHandler) isOAuthHandler() {} + +func (h *AuthorizationCodeHandler) TokenSource(ctx context.Context) (oauth2.TokenSource, error) { + return h.tokenSource, nil +} + +// NewAuthorizationCodeHandler creates a new AuthorizationCodeHandler. +// It performs validation of the configuration and returns an error if it is invalid. +// The passed config is consumed by the handler and should not be modified after. +func NewAuthorizationCodeHandler(config *AuthorizationCodeHandlerConfig) (*AuthorizationCodeHandler, error) { + if config == nil { + return nil, errors.New("config must be provided") + } + if config.ClientIDMetadataDocumentConfig == nil && + config.PreregisteredClientConfig == nil && + config.DynamicClientRegistrationConfig == nil { + return nil, errors.New("at least one client registration configuration must be provided") + } + if config.AuthorizationCodeFetcher == nil { + return nil, errors.New("AuthorizationCodeFetcher is required") + } + if config.ClientIDMetadataDocumentConfig != nil && !isNonRootHTTPSURL(config.ClientIDMetadataDocumentConfig.URL) { + return nil, fmt.Errorf("client ID metadata document URL must be a non-root HTTPS URL") + } + preCfg := config.PreregisteredClientConfig + if preCfg != nil { + if preCfg.ClientSecretAuthConfig == nil { + return nil, errors.New("ClientSecretAuthConfig is required for pre-registered client") + } + if preCfg.ClientSecretAuthConfig.ClientID == "" || preCfg.ClientSecretAuthConfig.ClientSecret == "" { + return nil, fmt.Errorf("pre-registered client ID or secret is empty") + } + } + dCfg := config.DynamicClientRegistrationConfig + if dCfg != nil { + if dCfg.Metadata == nil { + return nil, errors.New("Metadata is required for dynamic client registration") + } + if len(dCfg.Metadata.RedirectURIs) == 0 { + return nil, errors.New("Metadata.RedirectURIs is required for dynamic client registration") + } + if config.RedirectURL == "" { + config.RedirectURL = dCfg.Metadata.RedirectURIs[0] + } else if !slices.Contains(dCfg.Metadata.RedirectURIs, config.RedirectURL) { + return nil, fmt.Errorf("RedirectURL %q is not in the list of allowed redirect URIs for dynamic client registration", config.RedirectURL) + } + } + if config.RedirectURL == "" { + // If the RedirectURL was supposed to be set by the dynamic client registration, + // it should have been set by now. Otherwise, it is required. + return nil, errors.New("RedirectURL is required") + } + return &AuthorizationCodeHandler{config: config}, nil +} + +func isNonRootHTTPSURL(u string) bool { + pu, err := url.Parse(u) + if err != nil { + return false + } + return pu.Scheme == "https" && pu.Path != "" +} + +// Authorize performs the authorization flow. +// It is designed to perform the whole Authorization Code Grant flow. +// On success, [AuthorizationCodeHandler.TokenSource] will return a token source with the fetched token. +func (h *AuthorizationCodeHandler) Authorize(ctx context.Context, req *http.Request, resp *http.Response) error { + defer resp.Body.Close() + + wwwChallenges, err := oauthex.ParseWWWAuthenticate(resp.Header[http.CanonicalHeaderKey("WWW-Authenticate")]) + if err != nil { + return fmt.Errorf("failed to parse WWW-Authenticate header: %v", err) + } + + if resp.StatusCode == http.StatusForbidden && errorFromChallenges(wwwChallenges) != "insufficient_scope" { + // We only want to perform step-up authorization for insufficient_scope errors. + // Returning nil, so that the call is retried immediately and the response + // is handled appropriately by the connection. + // Step-up authorization is defined at + // https://modelcontextprotocol.io/specification/2025-11-25/basic/authorization#step-up-authorization-flow + return nil + } + + prm, err := h.getProtectedResourceMetadata(ctx, wwwChallenges, req.URL.String()) + if err != nil { + return err + } + + asm, err := h.getAuthServerMetadata(ctx, prm) + if err != nil { + return err + } + + resolvedClientConfig, err := h.handleRegistration(ctx, asm) + if err != nil { + return err + } + + scps := scopesFromChallenges(wwwChallenges) + if len(scps) == 0 && len(prm.ScopesSupported) > 0 { + scps = prm.ScopesSupported + } + + cfg := &oauth2.Config{ + ClientID: resolvedClientConfig.clientID, + ClientSecret: resolvedClientConfig.clientSecret, + + Endpoint: oauth2.Endpoint{ + AuthURL: asm.AuthorizationEndpoint, + TokenURL: asm.TokenEndpoint, + AuthStyle: resolvedClientConfig.authStyle, + }, + RedirectURL: h.config.RedirectURL, + Scopes: scps, + } + + authRes, err := h.getAuthorizationCode(ctx, cfg, req.URL.String()) + if err != nil { + // Purposefully leaving the error unwrappable so it can be handled by the caller. + return err + } + + return h.exchangeAuthorizationCode(ctx, cfg, authRes, prm.Resource) +} + +// resourceMetadataURLFromChallenges returns a resource metadata URL from the given "WWW-Authenticate" header challenges, +// or the empty string if there is none. +func resourceMetadataURLFromChallenges(cs []oauthex.Challenge) string { + for _, c := range cs { + if u := c.Params["resource_metadata"]; u != "" { + return u + } + } + return "" +} + +// scopesFromChallenges returns the scopes from the given "WWW-Authenticate" header challenges. +// It only looks at challenges with the "Bearer" scheme. +func scopesFromChallenges(cs []oauthex.Challenge) []string { + for _, c := range cs { + if c.Scheme == "bearer" && c.Params["scope"] != "" { + return strings.Fields(c.Params["scope"]) + } + } + return nil +} + +// errorFromChallenges returns the error from the given "WWW-Authenticate" header challenges. +// It only looks at challenges with the "Bearer" scheme. +func errorFromChallenges(cs []oauthex.Challenge) string { + for _, c := range cs { + if c.Scheme == "bearer" && c.Params["error"] != "" { + return c.Params["error"] + } + } + return "" +} + +// getProtectedResourceMetadata returns the protected resource metadata. +// If no metadata was found or the fetched metadata fails security checks, +// it returns an error. +func (h *AuthorizationCodeHandler) getProtectedResourceMetadata(ctx context.Context, wwwChallenges []oauthex.Challenge, mcpServerURL string) (*oauthex.ProtectedResourceMetadata, error) { + var errs []error + // Use MCP server URL as the resource URI per + // https://modelcontextprotocol.io/specification/2025-11-25/basic/authorization#canonical-server-uri. + for _, url := range protectedResourceMetadataURLs(resourceMetadataURLFromChallenges(wwwChallenges), mcpServerURL) { + prm, err := oauthex.GetProtectedResourceMetadata(ctx, url.URL, url.Resource, http.DefaultClient) + if err != nil { + errs = append(errs, err) + continue + } + if prm == nil { + errs = append(errs, fmt.Errorf("protected resource metadata is nil")) + continue + } + return prm, nil + } + return nil, fmt.Errorf("failed to get protected resource metadata: %v", errors.Join(errs...)) +} + +type prmURL struct { + // URL represents a URL where Protected Resource Metadata may be retrieved. + URL string + // Resource represents the corresponding resource URL for [URL]. + // It is required to perform validation described in RFC 9728, section 3.3. + Resource string +} + +// protectedResourceMetadataURLs returns a list of URLs to try when looking for +// protected resource metadata as mandated by the MCP specification: +// https://modelcontextprotocol.io/specification/2025-11-25/basic/authorization#protected-resource-metadata-discovery-requirements +func protectedResourceMetadataURLs(metadataURL, resourceURL string) []prmURL { + var urls []prmURL + if metadataURL != "" { + urls = append(urls, prmURL{ + URL: metadataURL, + Resource: resourceURL, + }) + } + ru, err := url.Parse(resourceURL) + if err != nil { + return urls + } + mu := *ru + // "At the path of the server's MCP endpoint". + mu.Path = "/.well-known/oauth-protected-resource/" + strings.TrimLeft(ru.Path, "/") + urls = append(urls, prmURL{ + URL: mu.String(), + Resource: resourceURL, + }) + // "At the root". + mu.Path = "/.well-known/oauth-protected-resource" + ru.Path = "" + urls = append(urls, prmURL{ + URL: mu.String(), + Resource: ru.String(), + }) + return urls +} + +// getAuthServerMetadata returns the authorization server metadata. +// The provided Protected Resource Metadata must not be nil. +// It returns an error if the metadata request fails with non-4xx HTTP status code +// or the fetched metadata fails security checks. +// If no metadata was found, it returns a minimal set of endpoints +// as a fallback to 2025-03-26 spec. +func (h *AuthorizationCodeHandler) getAuthServerMetadata(ctx context.Context, prm *oauthex.ProtectedResourceMetadata) (*oauthex.AuthServerMeta, error) { + var authServerURL string + if len(prm.AuthorizationServers) > 0 { + // Use the first authorization server, similarly to other SDKs. + authServerURL = prm.AuthorizationServers[0] + } else { + // Fallback to 2025-03-26 spec: MCP server base URL acts as Authorization Server. + authURL, err := url.Parse(prm.Resource) + if err != nil { + return nil, fmt.Errorf("failed to parse resource URL: %v", err) + } + authURL.Path = "" + authServerURL = authURL.String() + } + + for _, u := range authorizationServerMetadataURLs(authServerURL) { + asm, err := oauthex.GetAuthServerMeta(ctx, u, authServerURL, http.DefaultClient) + if err != nil { + return nil, fmt.Errorf("failed to get authorization server metadata: %w", err) + } + if asm != nil { + return asm, nil + } + } + + // Fallback to 2025-03-26 spec: predefined endpoints. + // https://modelcontextprotocol.io/specification/2025-03-26/basic/authorization#fallbacks-for-servers-without-metadata-discovery + asm := &oauthex.AuthServerMeta{ + Issuer: authServerURL, + AuthorizationEndpoint: authServerURL + "/authorize", + TokenEndpoint: authServerURL + "/token", + RegistrationEndpoint: authServerURL + "/register", + } + return asm, nil +} + +// authorizationServerMetadataURLs returns a list of URLs to try when looking for +// authorization server metadata as mandated by the MCP specification: +// https://modelcontextprotocol.io/specification/2025-11-25/basic/authorization#authorization-server-metadata-discovery. +func authorizationServerMetadataURLs(issuerURL string) []string { + var urls []string + + baseURL, err := url.Parse(issuerURL) + if err != nil { + return nil + } + + if baseURL.Path == "" { + // "OAuth 2.0 Authorization Server Metadata". + baseURL.Path = "/.well-known/oauth-authorization-server" + urls = append(urls, baseURL.String()) + // "OpenID Connect Discovery 1.0". + baseURL.Path = "/.well-known/openid-configuration" + urls = append(urls, baseURL.String()) + return urls + } + + originalPath := baseURL.Path + // "OAuth 2.0 Authorization Server Metadata with path insertion". + baseURL.Path = "/.well-known/oauth-authorization-server/" + strings.TrimLeft(originalPath, "/") + urls = append(urls, baseURL.String()) + // "OpenID Connect Discovery 1.0 with path insertion". + baseURL.Path = "/.well-known/openid-configuration/" + strings.TrimLeft(originalPath, "/") + urls = append(urls, baseURL.String()) + // "OpenID Connect Discovery 1.0 with path appending". + baseURL.Path = "/" + strings.Trim(originalPath, "/") + "/.well-known/openid-configuration" + urls = append(urls, baseURL.String()) + return urls +} + +type registrationType int + +const ( + registrationTypeClientIDMetadataDocument registrationType = iota + registrationTypePreregistered + registrationTypeDynamic +) + +type resolvedClientConfig struct { + registrationType registrationType + clientID string + clientSecret string + authStyle oauth2.AuthStyle +} + +func selectTokenAuthMethod(supported []string) oauth2.AuthStyle { + prefOrder := []string{ + // Preferred in OAuth 2.1 draft: https://www.ietf.org/archive/id/draft-ietf-oauth-v2-1-14.html#name-client-secret. + "client_secret_post", + "client_secret_basic", + } + for _, method := range prefOrder { + if slices.Contains(supported, method) { + return authMethodToStyle(method) + } + } + return oauth2.AuthStyleAutoDetect +} + +func authMethodToStyle(method string) oauth2.AuthStyle { + switch method { + case "client_secret_post": + return oauth2.AuthStyleInParams + case "client_secret_basic": + return oauth2.AuthStyleInHeader + case "none": + // "none" is equivalent to "client_secret_post" but without sending client secret. + return oauth2.AuthStyleInParams + default: + // "client_secret_basic" is the default per https://datatracker.ietf.org/doc/html/rfc7591#section-2. + return oauth2.AuthStyleInHeader + } +} + +// handleRegistration handles client registration. +// The provided authorization server metadata must be non-nil. +// Support for different registration methods is defined as follows: +// - Client ID Metadata Document: metadata must have +// `ClientIDMetadataDocumentSupported` set to true. +// - Pre-registered client: assumed to be supported. +// - Dynamic client registration: metadata must have +// `RegistrationEndpoint` set to a non-empty value. +func (h *AuthorizationCodeHandler) handleRegistration(ctx context.Context, asm *oauthex.AuthServerMeta) (*resolvedClientConfig, error) { + // 1. Attempt to use Client ID Metadata Document (SEP-991). + cimdCfg := h.config.ClientIDMetadataDocumentConfig + if cimdCfg != nil && asm.ClientIDMetadataDocumentSupported { + return &resolvedClientConfig{ + registrationType: registrationTypeClientIDMetadataDocument, + clientID: cimdCfg.URL, + }, nil + } + // 2. Attempt to use pre-registered client configuration. + pCfg := h.config.PreregisteredClientConfig + if pCfg != nil { + authStyle := selectTokenAuthMethod(asm.TokenEndpointAuthMethodsSupported) + return &resolvedClientConfig{ + registrationType: registrationTypePreregistered, + clientID: pCfg.ClientSecretAuthConfig.ClientID, + clientSecret: pCfg.ClientSecretAuthConfig.ClientSecret, + authStyle: authStyle, + }, nil + } + // 3. Attempt to use dynamic client registration. + dcrCfg := h.config.DynamicClientRegistrationConfig + if dcrCfg != nil && asm.RegistrationEndpoint != "" { + regResp, err := oauthex.RegisterClient(ctx, asm.RegistrationEndpoint, dcrCfg.Metadata, http.DefaultClient) + if err != nil { + return nil, fmt.Errorf("failed to register client: %w", err) + } + cfg := &resolvedClientConfig{ + registrationType: registrationTypeDynamic, + clientID: regResp.ClientID, + clientSecret: regResp.ClientSecret, + authStyle: authMethodToStyle(regResp.TokenEndpointAuthMethod), + } + return cfg, nil + } + return nil, fmt.Errorf("no configured client registration methods are supported by the authorization server") +} + +type authResult struct { + *AuthorizationResult + // usedCodeVerifier is the PKCE code verifier used to obtain the authorization code. + // It is preserved for the token exchange step. + usedCodeVerifier string +} + +// getAuthorizationCode uses the [AuthorizationCodeHandler.AuthorizationCodeFetcher] +// to obtain an authorization code. +func (h *AuthorizationCodeHandler) getAuthorizationCode(ctx context.Context, cfg *oauth2.Config, resourceURL string) (*authResult, error) { + codeVerifier := oauth2.GenerateVerifier() + state := rand.Text() + + authURL := cfg.AuthCodeURL(state, + oauth2.S256ChallengeOption(codeVerifier), + oauth2.SetAuthURLParam("resource", resourceURL), + ) + + authRes, err := h.config.AuthorizationCodeFetcher(ctx, &AuthorizationArgs{URL: authURL}) + if err != nil { + // Purposefully leaving the error unwrappable so it can be handled by the caller. + return nil, err + } + if authRes.State != state { + return nil, fmt.Errorf("state mismatch") + } + return &authResult{ + AuthorizationResult: authRes, + usedCodeVerifier: codeVerifier, + }, nil +} + +// exchangeAuthorizationCode exchanges the authorization code for a token +// and stores it in a token source. +func (h *AuthorizationCodeHandler) exchangeAuthorizationCode(ctx context.Context, cfg *oauth2.Config, authResult *authResult, resourceURL string) error { + opts := []oauth2.AuthCodeOption{ + oauth2.VerifierOption(authResult.usedCodeVerifier), + oauth2.SetAuthURLParam("resource", resourceURL), + } + token, err := cfg.Exchange(ctx, authResult.Code, opts...) + if err != nil { + return fmt.Errorf("token exchange failed: %w", err) + } + h.tokenSource = cfg.TokenSource(ctx, token) + return nil +} diff --git a/mcp/dsx-exchange-mcp/vendor/github.com/modelcontextprotocol/go-sdk/auth/client.go b/mcp/dsx-exchange-mcp/vendor/github.com/modelcontextprotocol/go-sdk/auth/client.go new file mode 100644 index 0000000..0af6963 --- /dev/null +++ b/mcp/dsx-exchange-mcp/vendor/github.com/modelcontextprotocol/go-sdk/auth/client.go @@ -0,0 +1,42 @@ +// Copyright 2025 The Go MCP SDK Authors. All rights reserved. +// Use of this source code is governed by an MIT-style +// license that can be found in the LICENSE file. + +package auth + +import ( + "context" + "net/http" + + "golang.org/x/oauth2" +) + +// OAuthHandler is an interface for handling OAuth flows. +// +// If a transport wishes to support OAuth 2 authorization, it should support +// being configured with an OAuthHandler. It should call the handler's +// TokenSource method whenever it sends an HTTP request to set the +// Authorization header. If a request fails with a 401 or 403, it should call +// Authorize, and if that returns nil, it should retry the request. It should +// not call Authorize after the second failure. See +// [github.com/modelcontextprotocol/go-sdk/mcp.StreamableClientTransport] +// for an example. +type OAuthHandler interface { + isOAuthHandler() + + // TokenSource returns a token source to be used for outgoing requests. + // Returned token source might be nil. In that case, the transport will not + // add any authorization headers to the request. + TokenSource(context.Context) (oauth2.TokenSource, error) + + // Authorize is called when an HTTP request results in an error that may + // be addressed by the authorization flow (currently 401 Unauthorized and 403 Forbidden). + // It is responsible for performing the OAuth flow to obtain an access token. + // The arguments are the request that failed and the response that was received for it. + // The headers of the request are available, but the body will have already been consumed + // when Authorize is called. + // If the returned error is nil, TokenSource is expected to return a non-nil token source. + // After a successful call to Authorize, the HTTP request will be retried by the transport. + // The function is responsible for closing the response body. + Authorize(context.Context, *http.Request, *http.Response) error +} diff --git a/mcp/dsx-exchange-mcp/vendor/github.com/modelcontextprotocol/go-sdk/auth/client_private.go b/mcp/dsx-exchange-mcp/vendor/github.com/modelcontextprotocol/go-sdk/auth/client_private.go new file mode 100644 index 0000000..767c59e --- /dev/null +++ b/mcp/dsx-exchange-mcp/vendor/github.com/modelcontextprotocol/go-sdk/auth/client_private.go @@ -0,0 +1,135 @@ +// Copyright 2025 The Go MCP SDK Authors. All rights reserved. +// Use of this source code is governed by an MIT-style +// license that can be found in the LICENSE file. + +//go:build mcp_go_client_oauth + +package auth + +import ( + "bytes" + "errors" + "io" + "net/http" + "sync" + + "golang.org/x/oauth2" +) + +// An OAuthHandlerLegacy conducts an OAuth flow and returns a [oauth2.TokenSource] if the authorization +// is approved, or an error if not. +// The handler receives the HTTP request and response that triggered the authentication flow. +// To obtain the protected resource metadata, call [oauthex.GetProtectedResourceMetadataFromHeader]. +// +// Deprecated: Please use the new [OAuthHandler] abstraction that is built +// into the streamable transport. This struct will be removed in v1.5.0. +type OAuthHandlerLegacy func(req *http.Request, res *http.Response) (oauth2.TokenSource, error) + +// HTTPTransport is an [http.RoundTripper] that follows the MCP +// OAuth protocol when it encounters a 401 Unauthorized response. +// +// Deprecated: Please use the new [OAuthHandler] abstraction that is built +// into the streamable transport. This struct will be removed in v1.5.0. +type HTTPTransport struct { + handler OAuthHandlerLegacy + mu sync.Mutex // protects opts.Base + opts HTTPTransportOptions +} + +// NewHTTPTransport returns a new [*HTTPTransport]. +// The handler is invoked when an HTTP request results in a 401 Unauthorized status. +// It is called only once per transport. Once a TokenSource is obtained, it is used +// for the lifetime of the transport; subsequent 401s are not processed. +// +// Deprecated: Please use the new [OAuthHandler] abstraction that is built +// into the streamable transport. This struct will be removed in v1.5.0. +func NewHTTPTransport(handler OAuthHandlerLegacy, opts *HTTPTransportOptions) (*HTTPTransport, error) { + if handler == nil { + return nil, errors.New("handler cannot be nil") + } + t := &HTTPTransport{ + handler: handler, + } + if opts != nil { + t.opts = *opts + } + if t.opts.Base == nil { + t.opts.Base = http.DefaultTransport + } + return t, nil +} + +// HTTPTransportOptions are options to [NewHTTPTransport]. +// +// Deprecated: Please use the new [OAuthHandler] abstraction that is built +// into the streamable transport. This struct will be removed in v1.5.0. +type HTTPTransportOptions struct { + // Base is the [http.RoundTripper] to use. + // If nil, [http.DefaultTransport] is used. + Base http.RoundTripper +} + +func (t *HTTPTransport) RoundTrip(req *http.Request) (*http.Response, error) { + t.mu.Lock() + base := t.opts.Base + t.mu.Unlock() + + var ( + // If haveBody is set, the request has a nontrivial body, and we need avoid + // reading (or closing) it multiple times. In that case, bodyBytes is its + // content. + haveBody bool + bodyBytes []byte + ) + if req.Body != nil && req.Body != http.NoBody { + // if we're setting Body, we must mutate first. + req = req.Clone(req.Context()) + haveBody = true + var err error + bodyBytes, err = io.ReadAll(req.Body) + if err != nil { + return nil, err + } + // Now that we've read the request body, http.RoundTripper requires that we + // close it. + req.Body.Close() // ignore error + req.Body = io.NopCloser(bytes.NewReader(bodyBytes)) + } + + resp, err := base.RoundTrip(req) + if err != nil { + return nil, err + } + if resp.StatusCode != http.StatusUnauthorized { + return resp, nil + } + if _, ok := base.(*oauth2.Transport); ok { + // We failed to authorize even with a token source; give up. + return resp, nil + } + + resp.Body.Close() + // Try to authorize. + t.mu.Lock() + defer t.mu.Unlock() + // If we don't have a token source, get one by following the OAuth flow. + // (We may have obtained one while t.mu was not held above.) + // TODO: We hold the lock for the entire OAuth flow. This could be a long + // time. Is there a better way? + if _, ok := t.opts.Base.(*oauth2.Transport); !ok { + ts, err := t.handler(req, resp) + if err != nil { + return nil, err + } + t.opts.Base = &oauth2.Transport{Base: t.opts.Base, Source: ts} + } + + // If we don't have a body, the request is reusable, though it will be cloned + // by the base. However, if we've had to read the body, we must clone. + if haveBody { + req = req.Clone(req.Context()) + req.Body = io.NopCloser(bytes.NewReader(bodyBytes)) + } + + return t.opts.Base.RoundTrip(req) +} diff --git a/mcp/dsx-exchange-mcp/vendor/github.com/modelcontextprotocol/go-sdk/internal/json/json.go b/mcp/dsx-exchange-mcp/vendor/github.com/modelcontextprotocol/go-sdk/internal/json/json.go new file mode 100644 index 0000000..1148770 --- /dev/null +++ b/mcp/dsx-exchange-mcp/vendor/github.com/modelcontextprotocol/go-sdk/internal/json/json.go @@ -0,0 +1,19 @@ +// Copyright 2025 The Go MCP SDK Authors. All rights reserved. +// Use of this source code is governed by the license +// that can be found in the LICENSE file. + +// Package json provides internal JSON utilities. + +package json + +import ( + "bytes" + + "github.com/segmentio/encoding/json" +) + +func Unmarshal(data []byte, v any) error { + dec := json.NewDecoder(bytes.NewReader(data)) + dec.DontMatchCaseInsensitiveStructFields() + return dec.Decode(v) +} diff --git a/mcp/dsx-exchange-mcp/vendor/github.com/modelcontextprotocol/go-sdk/internal/jsonrpc2/conn.go b/mcp/dsx-exchange-mcp/vendor/github.com/modelcontextprotocol/go-sdk/internal/jsonrpc2/conn.go new file mode 100644 index 0000000..571df63 --- /dev/null +++ b/mcp/dsx-exchange-mcp/vendor/github.com/modelcontextprotocol/go-sdk/internal/jsonrpc2/conn.go @@ -0,0 +1,842 @@ +// Copyright 2018 The Go MCP SDK Authors. All rights reserved. +// Use of this source code is governed by an MIT-style +// license that can be found in the LICENSE file. + +package jsonrpc2 + +import ( + "context" + "errors" + "fmt" + "io" + "sync" + "sync/atomic" + "time" + + "github.com/modelcontextprotocol/go-sdk/internal/json" +) + +// Binder builds a connection configuration. +// This may be used in servers to generate a new configuration per connection. +// ConnectionOptions itself implements Binder returning itself unmodified, to +// allow for the simple cases where no per connection information is needed. +type Binder interface { + // Bind returns the ConnectionOptions to use when establishing the passed-in + // Connection. + // + // The connection is not ready to use when Bind is called, + // but Bind may close it without reading or writing to it. + Bind(context.Context, *Connection) ConnectionOptions +} + +// A BinderFunc implements the Binder interface for a standalone Bind function. +type BinderFunc func(context.Context, *Connection) ConnectionOptions + +func (f BinderFunc) Bind(ctx context.Context, c *Connection) ConnectionOptions { + return f(ctx, c) +} + +var _ Binder = BinderFunc(nil) + +// ConnectionOptions holds the options for new connections. +type ConnectionOptions struct { + // Framer allows control over the message framing and encoding. + // If nil, HeaderFramer will be used. + Framer Framer + // Preempter allows registration of a pre-queue message handler. + // If nil, no messages will be preempted. + Preempter Preempter + // Handler is used as the queued message handler for inbound messages. + // If nil, all responses will be ErrNotHandled. + Handler Handler + // OnInternalError, if non-nil, is called with any internal errors that occur + // while serving the connection, such as protocol errors or invariant + // violations. (If nil, internal errors result in panics.) + OnInternalError func(error) +} + +// Connection manages the jsonrpc2 protocol, connecting responses back to their +// calls. Connection is bidirectional; it does not have a designated server or +// client end. +// +// Note that the word 'Connection' is overloaded: the mcp.Connection represents +// the bidirectional stream of messages between client an server. The +// jsonrpc2.Connection layers RPC logic on top of that stream, dispatching RPC +// handlers, and correlating requests with responses from the peer. +// +// Some of the complexity of the Connection type is grown out of its usage in +// gopls: it could probably be simplified based on our usage in MCP. +type Connection struct { + seq int64 // must only be accessed using atomic operations + + stateMu sync.Mutex + state inFlightState // accessed only in updateInFlight + done chan struct{} // closed (under stateMu) when state.closed is true and all goroutines have completed + + writer Writer + handler Handler + + onInternalError func(error) + onDone func() +} + +// inFlightState records the state of the incoming and outgoing calls on a +// Connection. +type inFlightState struct { + connClosing bool // true when the Connection's Close method has been called + reading bool // true while the readIncoming goroutine is running + readErr error // non-nil when the readIncoming goroutine exits (typically io.EOF) + writeErr error // non-nil if a call to the Writer has failed with a non-canceled Context + + // closer shuts down and cleans up the Reader and Writer state, ideally + // interrupting any Read or Write call that is currently blocked. It is closed + // when the state is idle and one of: connClosing is true, readErr is non-nil, + // or writeErr is non-nil. + // + // After the closer has been invoked, the closer field is set to nil + // and the closeErr field is simultaneously set to its result. + closer io.Closer + closeErr error // error returned from closer.Close + + outgoingCalls map[ID]*AsyncCall // calls only + outgoingNotifications int // # of notifications awaiting "write" + + // incoming stores the total number of incoming calls and notifications + // that have not yet written or processed a result. + incoming int + + incomingByID map[ID]*incomingRequest // calls only + + // handlerQueue stores the backlog of calls and notifications that were not + // already handled by a preempter. + // The queue does not include the request currently being handled (if any). + handlerQueue []*incomingRequest + handlerRunning bool +} + +// updateInFlight locks the state of the connection's in-flight requests, allows +// f to mutate that state, and closes the connection if it is idle and either +// is closing or has a read or write error. +func (c *Connection) updateInFlight(f func(*inFlightState)) { + c.stateMu.Lock() + defer c.stateMu.Unlock() + + s := &c.state + + f(s) + + select { + case <-c.done: + // The connection was already completely done at the start of this call to + // updateInFlight, so it must remain so. (The call to f should have noticed + // that and avoided making any updates that would cause the state to be + // non-idle.) + if !s.idle() { + panic("jsonrpc2: updateInFlight transitioned to non-idle when already done") + } + return + default: + } + + if s.idle() && s.shuttingDown(ErrUnknown) != nil { + if s.closer != nil { + s.closeErr = s.closer.Close() + s.closer = nil // prevent duplicate Close calls + } + if s.reading { + // The readIncoming goroutine is still running. Our call to Close should + // cause it to exit soon, at which point it will make another call to + // updateInFlight, set s.reading to false, and mark the Connection done. + } else { + // The readIncoming goroutine has exited, or never started to begin with. + // Since everything else is idle, we're completely done. + if c.onDone != nil { + c.onDone() + } + close(c.done) + } + } +} + +// idle reports whether the connection is in a state with no pending calls or +// notifications. +// +// If idle returns true, the readIncoming goroutine may still be running, +// but no other goroutines are doing work on behalf of the connection. +func (s *inFlightState) idle() bool { + return len(s.outgoingCalls) == 0 && s.outgoingNotifications == 0 && s.incoming == 0 && !s.handlerRunning +} + +// shuttingDown reports whether the connection is in a state that should +// disallow new (incoming and outgoing) calls. It returns either nil or +// an error that is or wraps the provided errClosing. +func (s *inFlightState) shuttingDown(errClosing error) error { + if s.connClosing { + // If Close has been called explicitly, it doesn't matter what state the + // Reader and Writer are in: we shouldn't be starting new work because the + // caller told us not to start new work. + return errClosing + } + if s.readErr != nil { + // If the read side of the connection is broken, we cannot read new call + // requests, and cannot read responses to our outgoing calls. + return fmt.Errorf("%w: %v", errClosing, s.readErr) + } + if s.writeErr != nil { + // If the write side of the connection is broken, we cannot write responses + // for incoming calls, and cannot write requests for outgoing calls. + return fmt.Errorf("%w: %v", errClosing, s.writeErr) + } + return nil +} + +// incomingRequest is used to track an incoming request as it is being handled +type incomingRequest struct { + *Request // the request being processed + ctx context.Context + cancel context.CancelFunc +} + +// Bind returns the options unmodified. +func (o ConnectionOptions) Bind(context.Context, *Connection) ConnectionOptions { + return o +} + +// A ConnectionConfig configures a bidirectional jsonrpc2 connection. +type ConnectionConfig struct { + Reader Reader // required + Writer Writer // required + Closer io.Closer // required + Preempter Preempter // optional + Bind func(*Connection) Handler // required + OnDone func() // optional + OnInternalError func(error) // optional +} + +// NewConnection creates a new [Connection] object and starts processing +// incoming messages. +func NewConnection(ctx context.Context, cfg ConnectionConfig) *Connection { + ctx = notDone{ctx} + + c := &Connection{ + state: inFlightState{closer: cfg.Closer}, + done: make(chan struct{}), + writer: cfg.Writer, + onDone: cfg.OnDone, + onInternalError: cfg.OnInternalError, + } + c.handler = cfg.Bind(c) + c.start(ctx, cfg.Reader, cfg.Preempter) + return c +} + +// bindConnection creates a new connection and runs it. +// +// This is used by the Dial and Serve functions to build the actual connection. +// +// The connection is closed automatically (and its resources cleaned up) when +// the last request has completed after the underlying ReadWriteCloser breaks, +// but it may be stopped earlier by calling Close (for a clean shutdown). +func bindConnection(bindCtx context.Context, rwc io.ReadWriteCloser, binder Binder, onDone func()) *Connection { + // TODO: Should we create a new event span here? + // This will propagate cancellation from ctx; should it? + ctx := notDone{bindCtx} + + c := &Connection{ + state: inFlightState{closer: rwc}, + done: make(chan struct{}), + onDone: onDone, + } + // It's tempting to set a finalizer on c to verify that the state has gone + // idle when the connection becomes unreachable. Unfortunately, the Binder + // interface makes that unsafe: it allows the Handler to close over the + // Connection, which could create a reference cycle that would cause the + // Connection to become uncollectable. + + options := binder.Bind(bindCtx, c) + framer := options.Framer + if framer == nil { + framer = HeaderFramer() + } + c.handler = options.Handler + if c.handler == nil { + c.handler = defaultHandler{} + } + c.onInternalError = options.OnInternalError + + c.writer = framer.Writer(rwc) + reader := framer.Reader(rwc) + c.start(ctx, reader, options.Preempter) + return c +} + +func (c *Connection) start(ctx context.Context, reader Reader, preempter Preempter) { + c.updateInFlight(func(s *inFlightState) { + select { + case <-c.done: + // Bind already closed the connection; don't start a goroutine to read it. + return + default: + } + + // The goroutine started here will continue until the underlying stream is closed. + // + // (If the Binder closed the Connection already, this should error out and + // return almost immediately.) + s.reading = true + go c.readIncoming(ctx, reader, preempter) + }) +} + +// Notify invokes the target method but does not wait for a response. +// The params will be marshaled to JSON before sending over the wire, and will +// be handed to the method invoked. +func (c *Connection) Notify(ctx context.Context, method string, params any) (err error) { + attempted := false + + defer func() { + if attempted { + c.updateInFlight(func(s *inFlightState) { + s.outgoingNotifications-- + }) + } + }() + + c.updateInFlight(func(s *inFlightState) { + // If the connection is shutting down, allow outgoing notifications only if + // there is at least one call still in flight. The number of calls in flight + // cannot increase once shutdown begins, and allowing outgoing notifications + // may permit notifications that will cancel in-flight calls. + if len(s.outgoingCalls) == 0 && len(s.incomingByID) == 0 { + err = s.shuttingDown(ErrClientClosing) + if err != nil { + return + } + } + s.outgoingNotifications++ + attempted = true + }) + if err != nil { + return err + } + + notify, err := NewNotification(method, params) + if err != nil { + return fmt.Errorf("marshaling notify parameters: %v", err) + } + + return c.write(ctx, notify) +} + +// Call invokes the target method and returns an object that can be used to await the response. +// The params will be marshaled to JSON before sending over the wire, and will +// be handed to the method invoked. +// You do not have to wait for the response, it can just be ignored if not needed. +// If sending the call failed, the response will be ready and have the error in it. +func (c *Connection) Call(ctx context.Context, method string, params any) *AsyncCall { + // Generate a new request identifier. + id := Int64ID(atomic.AddInt64(&c.seq, 1)) + + ac := &AsyncCall{ + id: id, + ready: make(chan struct{}), + } + // When this method returns, either ac is retired, or the request has been + // written successfully and the call is awaiting a response (to be provided by + // the readIncoming goroutine). + + call, err := NewCall(ac.id, method, params) + if err != nil { + ac.retire(&Response{ID: id, Error: fmt.Errorf("marshaling call parameters: %w", err)}) + return ac + } + + c.updateInFlight(func(s *inFlightState) { + err = s.shuttingDown(ErrClientClosing) + if err != nil { + return + } + if s.outgoingCalls == nil { + s.outgoingCalls = make(map[ID]*AsyncCall) + } + s.outgoingCalls[ac.id] = ac + }) + if err != nil { + ac.retire(&Response{ID: id, Error: err}) + return ac + } + + if err := c.write(ctx, call); err != nil { + // Sending failed. We will never get a response, so deliver a fake one if it + // wasn't already retired by the connection breaking. + c.Retire(ac, err) + } + return ac +} + +// Retire stops tracking the call, and reports err as its terminal error. +// +// Retire is safe to call multiple times: if the call is already no longer +// tracked, Retire is a no op. +func (c *Connection) Retire(ac *AsyncCall, err error) { + c.updateInFlight(func(s *inFlightState) { + if s.outgoingCalls[ac.id] == ac { + delete(s.outgoingCalls, ac.id) + ac.retire(&Response{ID: ac.id, Error: err}) + } else { + // ac was already retired elsewhere. + } + }) +} + +// Async, signals that the current jsonrpc2 request may be handled +// asynchronously to subsequent requests, when ctx is the request context. +// +// Async must be called at most once on each request's context (and its +// descendants). +func Async(ctx context.Context) { + if r, ok := ctx.Value(asyncKey).(*releaser); ok { + r.release(false) + } +} + +type asyncKeyType struct{} + +var asyncKey = asyncKeyType{} + +// A releaser implements concurrency safe 'releasing' of async requests. (A +// request is released when it is allowed to run concurrent with other +// requests, via a call to [Async].) +type releaser struct { + mu sync.Mutex + ch chan struct{} + released bool +} + +// release closes the associated channel. If soft is set, multiple calls to +// release are allowed. +func (r *releaser) release(soft bool) { + r.mu.Lock() + defer r.mu.Unlock() + + if r.released { + if !soft { + panic("jsonrpc2.Async called multiple times") + } + } else { + close(r.ch) + r.released = true + } +} + +type AsyncCall struct { + id ID + ready chan struct{} // closed after response has been set + response *Response +} + +// ID used for this call. +// This can be used to cancel the call if needed. +func (ac *AsyncCall) ID() ID { return ac.id } + +// IsReady can be used to check if the result is already prepared. +// This is guaranteed to return true on a result for which Await has already +// returned, or a call that failed to send in the first place. +func (ac *AsyncCall) IsReady() bool { + select { + case <-ac.ready: + return true + default: + return false + } +} + +// retire processes the response to the call. +// +// It is an error to call retire more than once: retire is guarded by the +// connection's outgoingCalls map. +func (ac *AsyncCall) retire(response *Response) { + select { + case <-ac.ready: + panic(fmt.Sprintf("jsonrpc2: retire called twice for ID %v", ac.id)) + default: + } + + ac.response = response + close(ac.ready) +} + +// Await waits for (and decodes) the results of a Call. +// The response will be unmarshaled from JSON into the result. +// +// If the call is cancelled due to context cancellation, the result is +// ctx.Err(). +func (ac *AsyncCall) Await(ctx context.Context, result any) error { + select { + case <-ctx.Done(): + return ctx.Err() + case <-ac.ready: + } + if ac.response.Error != nil { + return ac.response.Error + } + if result == nil { + return nil + } + return json.Unmarshal(ac.response.Result, result) +} + +// Cancel cancels the Context passed to the Handle call for the inbound message +// with the given ID. +// +// Cancel will not complain if the ID is not a currently active message, and it +// will not cause any messages that have not arrived yet with that ID to be +// cancelled. +func (c *Connection) Cancel(id ID) { + var req *incomingRequest + c.updateInFlight(func(s *inFlightState) { + req = s.incomingByID[id] + }) + if req != nil { + req.cancel() + } +} + +// Wait blocks until the connection is fully closed, but does not close it. +func (c *Connection) Wait() error { + return c.wait(true) +} + +// wait for the connection to close, and aggregates the most cause of its +// termination, if abnormal. +// +// The fromWait argument allows this logic to be shared with Close, where we +// only want to expose the closeErr. +// +// (Previously, Wait also only returned the closeErr, which was misleading if +// the connection was broken for another reason). +func (c *Connection) wait(fromWait bool) error { + var err error + <-c.done + c.updateInFlight(func(s *inFlightState) { + if fromWait { + if !errors.Is(s.readErr, io.EOF) { + err = s.readErr + } + if err == nil && !errors.Is(s.writeErr, io.EOF) { + err = s.writeErr + } + } + if err == nil { + err = s.closeErr + } + }) + return err +} + +// Close stops accepting new requests, waits for in-flight requests and enqueued +// Handle calls to complete, and then closes the underlying stream. +// +// After the start of a Close, notification requests (that lack IDs and do not +// receive responses) will continue to be passed to the Preempter, but calls +// with IDs will receive immediate responses with ErrServerClosing, and no new +// requests (not even notifications!) will be enqueued to the Handler. +func (c *Connection) Close() error { + // Stop handling new requests, and interrupt the reader (by closing the + // connection) as soon as the active requests finish. + c.updateInFlight(func(s *inFlightState) { s.connClosing = true }) + return c.wait(false) +} + +// readIncoming collects inbound messages from the reader and delivers them, either responding +// to outgoing calls or feeding requests to the queue. +func (c *Connection) readIncoming(ctx context.Context, reader Reader, preempter Preempter) { + var err error + for { + var msg Message + msg, err = reader.Read(ctx) + if err != nil { + break + } + + switch msg := msg.(type) { + case *Request: + c.acceptRequest(ctx, msg, preempter) + + case *Response: + c.updateInFlight(func(s *inFlightState) { + if ac, ok := s.outgoingCalls[msg.ID]; ok { + delete(s.outgoingCalls, msg.ID) + ac.retire(msg) + } else { + // TODO: How should we report unexpected responses? + } + }) + + default: + c.internalErrorf("Read returned an unexpected message of type %T", msg) + } + } + + c.updateInFlight(func(s *inFlightState) { + s.reading = false + s.readErr = err + + // Retire any outgoing requests that were still in flight: with the Reader no + // longer being processed, they necessarily cannot receive a response. + for id, ac := range s.outgoingCalls { + ac.retire(&Response{ID: id, Error: err}) + } + s.outgoingCalls = nil + }) +} + +// acceptRequest either handles msg synchronously or enqueues it to be handled +// asynchronously. +func (c *Connection) acceptRequest(ctx context.Context, msg *Request, preempter Preempter) { + // In theory notifications cannot be cancelled, but we build them a cancel + // context anyway. + reqCtx, cancel := context.WithCancel(ctx) + req := &incomingRequest{ + Request: msg, + ctx: reqCtx, + cancel: cancel, + } + + // If the request is a call, add it to the incoming map so it can be + // cancelled (or responded) by ID. + var err error + c.updateInFlight(func(s *inFlightState) { + s.incoming++ + + if req.IsCall() { + if s.incomingByID[req.ID] != nil { + err = fmt.Errorf("%w: request ID %v already in use", ErrInvalidRequest, req.ID) + req.ID = ID{} // Don't misattribute this error to the existing request. + return + } + + if s.incomingByID == nil { + s.incomingByID = make(map[ID]*incomingRequest) + } + s.incomingByID[req.ID] = req + + // When shutting down, reject all new Call requests, even if they could + // theoretically be handled by the preempter. The preempter could return + // ErrAsyncResponse, which would increase the amount of work in flight + // when we're trying to ensure that it strictly decreases. + err = s.shuttingDown(ErrServerClosing) + } + }) + if err != nil { + c.processResult("acceptRequest", req, nil, err) + return + } + + if preempter != nil { + result, err := preempter.Preempt(req.ctx, req.Request) + + if !errors.Is(err, ErrNotHandled) { + c.processResult("Preempt", req, result, err) + return + } + } + + c.updateInFlight(func(s *inFlightState) { + // If the connection is shutting down, don't enqueue anything to the + // handler — not even notifications. That ensures that if the handler + // continues to make progress, it will eventually become idle and + // close the connection. + err = s.shuttingDown(ErrServerClosing) + if err != nil { + return + } + + // We enqueue requests that have not been preempted to an unbounded slice. + // Unfortunately, we cannot in general limit the size of the handler + // queue: we have to read every response that comes in on the wire + // (because it may be responding to a request issued by, say, an + // asynchronous handler), and in order to get to that response we have + // to read all of the requests that came in ahead of it. + s.handlerQueue = append(s.handlerQueue, req) + if !s.handlerRunning { + // We start the handleAsync goroutine when it has work to do, and let it + // exit when the queue empties. + // + // Otherwise, in order to synchronize the handler we would need some other + // goroutine (probably readIncoming?) to explicitly wait for handleAsync + // to finish, and that would complicate error reporting: either the error + // report from the goroutine would be blocked on the handler emptying its + // queue (which was tried, and introduced a deadlock detected by + // TestCloseCallRace), or the error would need to be reported separately + // from synchronizing completion. Allowing the handler goroutine to exit + // when idle seems simpler than trying to implement either of those + // alternatives correctly. + s.handlerRunning = true + go c.handleAsync() + } + }) + if err != nil { + c.processResult("acceptRequest", req, nil, err) + } +} + +// handleAsync invokes the handler on the requests in the handler queue +// sequentially until the queue is empty. +func (c *Connection) handleAsync() { + for { + var req *incomingRequest + c.updateInFlight(func(s *inFlightState) { + if len(s.handlerQueue) > 0 { + req, s.handlerQueue = s.handlerQueue[0], s.handlerQueue[1:] + } else { + s.handlerRunning = false + } + }) + if req == nil { + return + } + + // Only deliver to the Handler if not already canceled. + if err := req.ctx.Err(); err != nil { + c.updateInFlight(func(s *inFlightState) { + if s.writeErr != nil { + // Assume that req.ctx was canceled due to s.writeErr. + // TODO(#51365): use a Context API to plumb this through req.ctx. + err = fmt.Errorf("%w: %v", ErrServerClosing, s.writeErr) + } + }) + c.processResult("handleAsync", req, nil, err) + continue + } + + releaser := &releaser{ch: make(chan struct{})} + ctx := context.WithValue(req.ctx, asyncKey, releaser) + go func() { + defer releaser.release(true) + result, err := c.handler.Handle(ctx, req.Request) + c.processResult(c.handler, req, result, err) + }() + <-releaser.ch + } +} + +// processResult processes the result of a request and, if appropriate, sends a response. +func (c *Connection) processResult(from any, req *incomingRequest, result any, err error) error { + switch err { + case ErrNotHandled, ErrMethodNotFound: + // Add detail describing the unhandled method. + err = fmt.Errorf("%w: %q", ErrMethodNotFound, req.Method) + } + + if result != nil && err != nil { + c.internalErrorf("%#v returned a non-nil result with a non-nil error for %s:\n%v\n%#v", from, req.Method, err, result) + result = nil // Discard the spurious result and respond with err. + } + + if req.IsCall() { + if result == nil && err == nil { + err = c.internalErrorf("%#v returned a nil result and nil error for a %q Request that requires a Response", from, req.Method) + } + + response, respErr := NewResponse(req.ID, result, err) + + // The caller could theoretically reuse the request's ID as soon as we've + // sent the response, so ensure that it is removed from the incoming map + // before sending. + c.updateInFlight(func(s *inFlightState) { + delete(s.incomingByID, req.ID) + }) + if respErr == nil { + writeErr := c.write(notDone{req.ctx}, response) + if err == nil { + err = writeErr + } + } else { + err = c.internalErrorf("%#v returned a malformed result for %q: %w", from, req.Method, respErr) + } + } else { // req is a notification + if result != nil { + err = c.internalErrorf("%#v returned a non-nil result for a %q Request without an ID", from, req.Method) + } else if err != nil { + err = fmt.Errorf("%w: %q notification failed: %v", ErrInternal, req.Method, err) + } + } + if err != nil { + // TODO: can/should we do anything with this error beyond writing it to the event log? + // (Is this the right label to attach to the log?) + } + + // Cancel the request to free any associated resources. + req.cancel() + c.updateInFlight(func(s *inFlightState) { + if s.incoming == 0 { + panic("jsonrpc2: processResult called when incoming count is already zero") + } + s.incoming-- + }) + return nil +} + +// write is used by all things that write outgoing messages, including replies. +// it makes sure that writes are atomic +func (c *Connection) write(ctx context.Context, msg Message) error { + var err error + // Fail writes immediately if the connection is shutting down. + // + // TODO(rfindley): should we allow cancellation notifications through? It + // could be the case that writes can still succeed. + c.updateInFlight(func(s *inFlightState) { + err = s.shuttingDown(ErrServerClosing) + }) + if err == nil { + err = c.writer.Write(ctx, msg) + } + + // For cancelled or rejected requests, we don't set the writeErr (which would + // break the connection). They can just be returned to the caller. + if err != nil && ctx.Err() == nil && !errors.Is(err, ErrRejected) { + // The call to Write failed, and since ctx.Err() is nil we can't attribute + // the failure (even indirectly) to Context cancellation. The writer appears + // to be broken, and future writes are likely to also fail. + // + // If the read side of the connection is also broken, we might not even be + // able to receive cancellation notifications. Since we can't reliably write + // the results of incoming calls and can't receive explicit cancellations, + // cancel the calls now. + c.updateInFlight(func(s *inFlightState) { + if s.writeErr == nil { + s.writeErr = err + for _, r := range s.incomingByID { + r.cancel() + } + } + }) + } + + return err +} + +// internalErrorf reports an internal error. By default it panics, but if +// c.onInternalError is non-nil it instead calls that and returns an error +// wrapping ErrInternal. +func (c *Connection) internalErrorf(format string, args ...any) error { + err := fmt.Errorf(format, args...) + if c.onInternalError == nil { + panic("jsonrpc2: " + err.Error()) + } + c.onInternalError(err) + + return fmt.Errorf("%w: %v", ErrInternal, err) +} + +// notDone is a context.Context wrapper that returns a nil Done channel. +type notDone struct{ ctx context.Context } + +func (ic notDone) Value(key any) any { + return ic.ctx.Value(key) +} + +func (notDone) Done() <-chan struct{} { return nil } +func (notDone) Err() error { return nil } +func (notDone) Deadline() (time.Time, bool) { return time.Time{}, false } diff --git a/mcp/dsx-exchange-mcp/vendor/github.com/modelcontextprotocol/go-sdk/internal/jsonrpc2/frame.go b/mcp/dsx-exchange-mcp/vendor/github.com/modelcontextprotocol/go-sdk/internal/jsonrpc2/frame.go new file mode 100644 index 0000000..72527cb --- /dev/null +++ b/mcp/dsx-exchange-mcp/vendor/github.com/modelcontextprotocol/go-sdk/internal/jsonrpc2/frame.go @@ -0,0 +1,208 @@ +// Copyright 2018 The Go MCP SDK Authors. All rights reserved. +// Use of this source code is governed by an MIT-style +// license that can be found in the LICENSE file. + +package jsonrpc2 + +import ( + "bufio" + "context" + "encoding/json" + "fmt" + "io" + "strconv" + "strings" + "sync" +) + +// Reader abstracts the transport mechanics from the JSON RPC protocol. +// A Conn reads messages from the reader it was provided on construction, +// and assumes that each call to Read fully transfers a single message, +// or returns an error. +// +// A reader is not safe for concurrent use, it is expected it will be used by +// a single Conn in a safe manner. +type Reader interface { + // Read gets the next message from the stream. + Read(context.Context) (Message, error) +} + +// Writer abstracts the transport mechanics from the JSON RPC protocol. +// A Conn writes messages using the writer it was provided on construction, +// and assumes that each call to Write fully transfers a single message, +// or returns an error. +// +// A writer must be safe for concurrent use, as writes may occur concurrently +// in practice: libraries may make calls or respond to requests asynchronously. +type Writer interface { + // Write sends a message to the stream. + Write(context.Context, Message) error +} + +// Framer wraps low level byte readers and writers into jsonrpc2 message +// readers and writers. +// It is responsible for the framing and encoding of messages into wire form. +// +// TODO(rfindley): rethink the framer interface, as with JSONRPC2 batching +// there is a need for Reader and Writer to be correlated, and while the +// implementation of framing here allows that, it is not made explicit by the +// interface. +// +// Perhaps a better interface would be +// +// Frame(io.ReadWriteCloser) (Reader, Writer). +type Framer interface { + // Reader wraps a byte reader into a message reader. + Reader(io.Reader) Reader + // Writer wraps a byte writer into a message writer. + Writer(io.Writer) Writer +} + +// RawFramer returns a new Framer. +// The messages are sent with no wrapping, and rely on json decode consistency +// to determine message boundaries. +func RawFramer() Framer { return rawFramer{} } + +type rawFramer struct{} +type rawReader struct{ in *json.Decoder } +type rawWriter struct { + mu sync.Mutex + out io.Writer +} + +func (rawFramer) Reader(rw io.Reader) Reader { + return &rawReader{in: json.NewDecoder(rw)} +} + +func (rawFramer) Writer(rw io.Writer) Writer { + return &rawWriter{out: rw} +} + +func (r *rawReader) Read(ctx context.Context) (Message, error) { + select { + case <-ctx.Done(): + return nil, ctx.Err() + default: + } + var raw json.RawMessage + if err := r.in.Decode(&raw); err != nil { + return nil, err + } + msg, err := DecodeMessage(raw) + return msg, err +} + +func (w *rawWriter) Write(ctx context.Context, msg Message) error { + select { + case <-ctx.Done(): + return ctx.Err() + default: + } + + data, err := EncodeMessage(msg) + if err != nil { + return fmt.Errorf("marshaling message: %v", err) + } + + w.mu.Lock() + defer w.mu.Unlock() + _, err = w.out.Write(data) + return err +} + +// HeaderFramer returns a new Framer. +// The messages are sent with HTTP content length and MIME type headers. +// This is the format used by LSP and others. +func HeaderFramer() Framer { return headerFramer{} } + +type headerFramer struct{} +type headerReader struct{ in *bufio.Reader } +type headerWriter struct { + mu sync.Mutex + out io.Writer +} + +func (headerFramer) Reader(rw io.Reader) Reader { + return &headerReader{in: bufio.NewReader(rw)} +} + +func (headerFramer) Writer(rw io.Writer) Writer { + return &headerWriter{out: rw} +} + +func (r *headerReader) Read(ctx context.Context) (Message, error) { + select { + case <-ctx.Done(): + return nil, ctx.Err() + default: + } + + firstRead := true // to detect a clean EOF below + var contentLength int64 + // read the header, stop on the first empty line + for { + line, err := r.in.ReadString('\n') + if err != nil { + if err == io.EOF { + if firstRead && line == "" { + return nil, io.EOF // clean EOF + } + err = io.ErrUnexpectedEOF + } + return nil, fmt.Errorf("failed reading header line: %w", err) + } + firstRead = false + + line = strings.TrimSpace(line) + // check we have a header line + if line == "" { + break + } + colon := strings.IndexRune(line, ':') + if colon < 0 { + return nil, fmt.Errorf("invalid header line %q", line) + } + name, value := line[:colon], strings.TrimSpace(line[colon+1:]) + switch { + case strings.EqualFold(name, "Content-Length"): + if contentLength, err = strconv.ParseInt(value, 10, 32); err != nil { + return nil, fmt.Errorf("failed parsing Content-Length: %v", value) + } + if contentLength <= 0 { + return nil, fmt.Errorf("invalid Content-Length: %v", contentLength) + } + default: + // ignoring unknown headers + } + } + if contentLength == 0 { + return nil, fmt.Errorf("missing Content-Length header") + } + data := make([]byte, contentLength) + _, err := io.ReadFull(r.in, data) + if err != nil { + return nil, err + } + msg, err := DecodeMessage(data) + return msg, err +} + +func (w *headerWriter) Write(ctx context.Context, msg Message) error { + select { + case <-ctx.Done(): + return ctx.Err() + default: + } + w.mu.Lock() + defer w.mu.Unlock() + + data, err := EncodeMessage(msg) + if err != nil { + return fmt.Errorf("marshaling message: %v", err) + } + _, err = fmt.Fprintf(w.out, "Content-Length: %v\r\n\r\n", len(data)) + if err == nil { + _, err = w.out.Write(data) + } + return err +} diff --git a/mcp/dsx-exchange-mcp/vendor/github.com/modelcontextprotocol/go-sdk/internal/jsonrpc2/jsonrpc2.go b/mcp/dsx-exchange-mcp/vendor/github.com/modelcontextprotocol/go-sdk/internal/jsonrpc2/jsonrpc2.go new file mode 100644 index 0000000..234e6ee --- /dev/null +++ b/mcp/dsx-exchange-mcp/vendor/github.com/modelcontextprotocol/go-sdk/internal/jsonrpc2/jsonrpc2.go @@ -0,0 +1,121 @@ +// Copyright 2018 The Go MCP SDK Authors. All rights reserved. +// Use of this source code is governed by an MIT-style +// license that can be found in the LICENSE file. + +// Package jsonrpc2 is a minimal implementation of the JSON RPC 2 spec. +// https://www.jsonrpc.org/specification +// It is intended to be compatible with other implementations at the wire level. +package jsonrpc2 + +import ( + "context" + "errors" +) + +var ( + // ErrIdleTimeout is returned when serving timed out waiting for new connections. + ErrIdleTimeout = errors.New("timed out waiting for new connections") + + // ErrNotHandled is returned from a Handler or Preempter to indicate it did + // not handle the request. + // + // If a Handler returns ErrNotHandled, the server replies with + // ErrMethodNotFound. + ErrNotHandled = errors.New("JSON RPC not handled") +) + +// Preempter handles messages on a connection before they are queued to the main +// handler. +// Primarily this is used for cancel handlers or notifications for which out of +// order processing is not an issue. +type Preempter interface { + // Preempt is invoked for each incoming request before it is queued for handling. + // + // If Preempt returns ErrNotHandled, the request will be queued, + // and eventually passed to a Handle call. + // + // Otherwise, the result and error are processed as if returned by Handle. + // + // Preempt must not block. (The Context passed to it is for Values only.) + Preempt(ctx context.Context, req *Request) (result any, err error) +} + +// A PreempterFunc implements the Preempter interface for a standalone Preempt function. +type PreempterFunc func(ctx context.Context, req *Request) (any, error) + +func (f PreempterFunc) Preempt(ctx context.Context, req *Request) (any, error) { + return f(ctx, req) +} + +var _ Preempter = PreempterFunc(nil) + +// Handler handles messages on a connection. +type Handler interface { + // Handle is invoked sequentially for each incoming request that has not + // already been handled by a Preempter. + // + // If the Request has a nil ID, Handle must return a nil result, + // and any error may be logged but will not be reported to the caller. + // + // If the Request has a non-nil ID, Handle must return either a + // non-nil, JSON-marshalable result, or a non-nil error. + // + // The Context passed to Handle will be canceled if the + // connection is broken or the request is canceled or completed. + // (If Handle returns ErrAsyncResponse, ctx will remain uncanceled + // until either Cancel or Respond is called for the request's ID.) + Handle(ctx context.Context, req *Request) (result any, err error) +} + +type defaultHandler struct{} + +func (defaultHandler) Preempt(context.Context, *Request) (any, error) { + return nil, ErrNotHandled +} + +func (defaultHandler) Handle(context.Context, *Request) (any, error) { + return nil, ErrNotHandled +} + +// A HandlerFunc implements the Handler interface for a standalone Handle function. +type HandlerFunc func(ctx context.Context, req *Request) (any, error) + +func (f HandlerFunc) Handle(ctx context.Context, req *Request) (any, error) { + return f(ctx, req) +} + +var _ Handler = HandlerFunc(nil) + +// async is a small helper for operations with an asynchronous result that you +// can wait for. +type async struct { + ready chan struct{} // closed when done + firstErr chan error // 1-buffered; contains either nil or the first non-nil error +} + +func newAsync() *async { + var a async + a.ready = make(chan struct{}) + a.firstErr = make(chan error, 1) + a.firstErr <- nil + return &a +} + +func (a *async) done() { + close(a.ready) +} + +func (a *async) wait() error { + <-a.ready + err := <-a.firstErr + a.firstErr <- err + return err +} + +func (a *async) setError(err error) { + storedErr := <-a.firstErr + if storedErr == nil { + storedErr = err + } + a.firstErr <- storedErr +} diff --git a/mcp/dsx-exchange-mcp/vendor/github.com/modelcontextprotocol/go-sdk/internal/jsonrpc2/messages.go b/mcp/dsx-exchange-mcp/vendor/github.com/modelcontextprotocol/go-sdk/internal/jsonrpc2/messages.go new file mode 100644 index 0000000..b424780 --- /dev/null +++ b/mcp/dsx-exchange-mcp/vendor/github.com/modelcontextprotocol/go-sdk/internal/jsonrpc2/messages.go @@ -0,0 +1,242 @@ +// Copyright 2018 The Go MCP SDK Authors. All rights reserved. +// Use of this source code is governed by an MIT-style +// license that can be found in the LICENSE file. + +package jsonrpc2 + +import ( + "bytes" + "encoding/json" + "errors" + "fmt" + + internaljson "github.com/modelcontextprotocol/go-sdk/internal/json" + + "github.com/modelcontextprotocol/go-sdk/internal/mcpgodebug" +) + +// ID is a Request identifier, which is defined by the spec to be a string, integer, or null. +// https://www.jsonrpc.org/specification#request_object +type ID struct { + value any +} + +// MakeID coerces the given Go value to an ID. The value should be the +// default JSON marshaling of a Request identifier: nil, float64, or string. +// +// Returns an error if the value type was not a valid Request ID type. +// +// TODO: ID can't be a json.Marshaler/Unmarshaler, because we want to omitzero. +// Simplify this package by making ID json serializable once we can rely on +// omitzero. +func MakeID(v any) (ID, error) { + switch v := v.(type) { + case nil: + return ID{}, nil + case float64: + return Int64ID(int64(v)), nil + case string: + return StringID(v), nil + } + return ID{}, fmt.Errorf("%w: invalid ID type %T", ErrParse, v) +} + +// Message is the interface to all jsonrpc2 message types. +// They share no common functionality, but are a closed set of concrete types +// that are allowed to implement this interface. The message types are *Request +// and *Response. +type Message interface { + // marshal builds the wire form from the API form. + // It is private, which makes the set of Message implementations closed. + marshal(to *wireCombined) +} + +// Request is a Message sent to a peer to request behavior. +// If it has an ID it is a call, otherwise it is a notification. +type Request struct { + // ID of this request, used to tie the Response back to the request. + // This will be nil for notifications. + ID ID + // Method is a string containing the method name to invoke. + Method string + // Params is either a struct or an array with the parameters of the method. + Params json.RawMessage + // Extra is additional information that does not appear on the wire. It can be + // used to pass information from the application to the underlying transport. + Extra any +} + +// Response is a Message used as a reply to a call Request. +// It will have the same ID as the call it is a response to. +type Response struct { + // result is the content of the response. + Result json.RawMessage + // err is set only if the call failed. + Error error + // id of the request this is a response to. + ID ID + // Extra is additional information that does not appear on the wire. It can be + // used to pass information from the underlying transport to the application. + Extra any +} + +// StringID creates a new string request identifier. +func StringID(s string) ID { return ID{value: s} } + +// Int64ID creates a new integer request identifier. +func Int64ID(i int64) ID { return ID{value: i} } + +// IsValid returns true if the ID is a valid identifier. +// The default value for ID will return false. +func (id ID) IsValid() bool { return id.value != nil } + +// Raw returns the underlying value of the ID. +func (id ID) Raw() any { return id.value } + +// NewNotification constructs a new Notification message for the supplied +// method and parameters. +func NewNotification(method string, params any) (*Request, error) { + p, merr := marshalToRaw(params) + return &Request{Method: method, Params: p}, merr +} + +// NewCall constructs a new Call message for the supplied ID, method and +// parameters. +func NewCall(id ID, method string, params any) (*Request, error) { + p, merr := marshalToRaw(params) + return &Request{ID: id, Method: method, Params: p}, merr +} + +func (msg *Request) IsCall() bool { return msg.ID.IsValid() } + +func (msg *Request) marshal(to *wireCombined) { + to.ID = msg.ID.value + to.Method = msg.Method + to.Params = msg.Params +} + +// NewResponse constructs a new Response message that is a reply to the +// supplied. If err is set result may be ignored. +func NewResponse(id ID, result any, rerr error) (*Response, error) { + r, merr := marshalToRaw(result) + return &Response{ID: id, Result: r, Error: rerr}, merr +} + +func (msg *Response) marshal(to *wireCombined) { + to.ID = msg.ID.value + to.Error = toWireError(msg.Error) + to.Result = msg.Result +} + +func toWireError(err error) *WireError { + if err == nil { + // no error, the response is complete + return nil + } + if err, ok := err.(*WireError); ok { + // already a wire error, just use it + return err + } + result := &WireError{Message: err.Error()} + var wrapped *WireError + if errors.As(err, &wrapped) { + // if we wrapped a wire error, keep the code from the wrapped error + // but the message from the outer error + result.Code = wrapped.Code + } + return result +} + +func EncodeMessage(msg Message) ([]byte, error) { + wire := wireCombined{VersionTag: wireVersion} + msg.marshal(&wire) + data, err := jsonMarshal(&wire) + if err != nil { + return nil, fmt.Errorf("marshaling jsonrpc message: %w", err) + } + return data, nil +} + +// EncodeIndent is like EncodeMessage, but honors indents. +// TODO(rfindley): refactor so that this concern is handled independently. +// Perhaps we should pass in a json.Encoder? +func EncodeIndent(msg Message, prefix, indent string) ([]byte, error) { + wire := wireCombined{VersionTag: wireVersion} + msg.marshal(&wire) + var buf bytes.Buffer + enc := json.NewEncoder(&buf) + enc.SetEscapeHTML(false) + enc.SetIndent(prefix, indent) + if err := enc.Encode(&wire); err != nil { + return nil, fmt.Errorf("marshaling jsonrpc message: %w", err) + } + return bytes.TrimRight(buf.Bytes(), "\n"), nil +} + +func DecodeMessage(data []byte) (Message, error) { + msg := wireCombined{} + if err := internaljson.Unmarshal(data, &msg); err != nil { + return nil, fmt.Errorf("unmarshaling jsonrpc message: %w", err) + } + if msg.VersionTag != wireVersion { + return nil, fmt.Errorf("invalid message version tag %q; expected %q", msg.VersionTag, wireVersion) + } + id, err := MakeID(msg.ID) + if err != nil { + return nil, err + } + if msg.Method != "" { + // has a method, must be a call + return &Request{ + Method: msg.Method, + ID: id, + Params: msg.Params, + }, nil + } + // no method, should be a response + if !id.IsValid() { + return nil, ErrInvalidRequest + } + resp := &Response{ + ID: id, + Result: msg.Result, + } + // we have to check if msg.Error is nil to avoid a typed error + if msg.Error != nil { + resp.Error = msg.Error + } + return resp, nil +} + +func marshalToRaw(obj any) (json.RawMessage, error) { + if obj == nil { + return nil, nil + } + data, err := jsonMarshal(obj) + if err != nil { + return nil, err + } + return json.RawMessage(data), nil +} + +// jsonescaping is a compatibility parameter that allows to restore +// JSON escaping in the JSON marshaling, which stopped being the default +// in the 1.4.0 version of the SDK. See the documentation for the +// mcpgodebug package for instructions how to enable it. +// The option will be removed in the 1.6.0 version of the SDK. +var jsonescaping = mcpgodebug.Value("jsonescaping") + +// jsonMarshal marshals obj to JSON like json.Marshal but without HTML escaping. +func jsonMarshal(obj any) ([]byte, error) { + if jsonescaping == "1" { + return json.Marshal(obj) + } + var buf bytes.Buffer + enc := json.NewEncoder(&buf) + enc.SetEscapeHTML(false) + if err := enc.Encode(obj); err != nil { + return nil, err + } + // json.Encoder.Encode adds a trailing newline. Trim it to be consistent with json.Marshal. + return bytes.TrimRight(buf.Bytes(), "\n"), nil +} diff --git a/mcp/dsx-exchange-mcp/vendor/github.com/modelcontextprotocol/go-sdk/internal/jsonrpc2/net.go b/mcp/dsx-exchange-mcp/vendor/github.com/modelcontextprotocol/go-sdk/internal/jsonrpc2/net.go new file mode 100644 index 0000000..05db062 --- /dev/null +++ b/mcp/dsx-exchange-mcp/vendor/github.com/modelcontextprotocol/go-sdk/internal/jsonrpc2/net.go @@ -0,0 +1,138 @@ +// Copyright 2018 The Go MCP SDK Authors. All rights reserved. +// Use of this source code is governed by an MIT-style +// license that can be found in the LICENSE file. + +package jsonrpc2 + +import ( + "context" + "io" + "net" + "os" +) + +// This file contains implementations of the transport primitives that use the standard network +// package. + +// NetListenOptions is the optional arguments to the NetListen function. +type NetListenOptions struct { + NetListenConfig net.ListenConfig + NetDialer net.Dialer +} + +// NetListener returns a new Listener that listens on a socket using the net package. +func NetListener(ctx context.Context, network, address string, options NetListenOptions) (Listener, error) { + ln, err := options.NetListenConfig.Listen(ctx, network, address) + if err != nil { + return nil, err + } + return &netListener{net: ln}, nil +} + +// netListener is the implementation of Listener for connections made using the net package. +type netListener struct { + net net.Listener +} + +// Accept blocks waiting for an incoming connection to the listener. +func (l *netListener) Accept(context.Context) (io.ReadWriteCloser, error) { + return l.net.Accept() +} + +// Close will cause the listener to stop listening. It will not close any connections that have +// already been accepted. +func (l *netListener) Close() error { + addr := l.net.Addr() + err := l.net.Close() + if addr.Network() == "unix" { + rerr := os.Remove(addr.String()) + if rerr != nil && err == nil { + err = rerr + } + } + return err +} + +// Dialer returns a dialer that can be used to connect to the listener. +func (l *netListener) Dialer() Dialer { + return NetDialer(l.net.Addr().Network(), l.net.Addr().String(), net.Dialer{}) +} + +// NetDialer returns a Dialer using the supplied standard network dialer. +func NetDialer(network, address string, nd net.Dialer) Dialer { + return &netDialer{ + network: network, + address: address, + dialer: nd, + } +} + +type netDialer struct { + network string + address string + dialer net.Dialer +} + +func (n *netDialer) Dial(ctx context.Context) (io.ReadWriteCloser, error) { + return n.dialer.DialContext(ctx, n.network, n.address) +} + +// NetPipeListener returns a new Listener that listens using net.Pipe. +// It is only possibly to connect to it using the Dialer returned by the +// Dialer method, each call to that method will generate a new pipe the other +// side of which will be returned from the Accept call. +func NetPipeListener(ctx context.Context) (Listener, error) { + return &netPiper{ + done: make(chan struct{}), + dialed: make(chan io.ReadWriteCloser), + }, nil +} + +// netPiper is the implementation of Listener build on top of net.Pipes. +type netPiper struct { + done chan struct{} + dialed chan io.ReadWriteCloser +} + +// Accept blocks waiting for an incoming connection to the listener. +func (l *netPiper) Accept(context.Context) (io.ReadWriteCloser, error) { + // Block until the pipe is dialed or the listener is closed, + // preferring the latter if already closed at the start of Accept. + select { + case <-l.done: + return nil, net.ErrClosed + default: + } + select { + case rwc := <-l.dialed: + return rwc, nil + case <-l.done: + return nil, net.ErrClosed + } +} + +// Close will cause the listener to stop listening. It will not close any connections that have +// already been accepted. +func (l *netPiper) Close() error { + // unblock any accept calls that are pending + close(l.done) + return nil +} + +func (l *netPiper) Dialer() Dialer { + return l +} + +func (l *netPiper) Dial(ctx context.Context) (io.ReadWriteCloser, error) { + client, server := net.Pipe() + + select { + case l.dialed <- server: + return client, nil + + case <-l.done: + client.Close() + server.Close() + return nil, net.ErrClosed + } +} diff --git a/mcp/dsx-exchange-mcp/vendor/github.com/modelcontextprotocol/go-sdk/internal/jsonrpc2/serve.go b/mcp/dsx-exchange-mcp/vendor/github.com/modelcontextprotocol/go-sdk/internal/jsonrpc2/serve.go new file mode 100644 index 0000000..424163a --- /dev/null +++ b/mcp/dsx-exchange-mcp/vendor/github.com/modelcontextprotocol/go-sdk/internal/jsonrpc2/serve.go @@ -0,0 +1,330 @@ +// Copyright 2020 The Go MCP SDK Authors. All rights reserved. +// Use of this source code is governed by an MIT-style +// license that can be found in the LICENSE file. + +package jsonrpc2 + +import ( + "context" + "fmt" + "io" + "runtime" + "sync" + "sync/atomic" + "time" +) + +// Listener is implemented by protocols to accept new inbound connections. +type Listener interface { + // Accept accepts an inbound connection to a server. + // It blocks until either an inbound connection is made, or the listener is closed. + Accept(context.Context) (io.ReadWriteCloser, error) + + // Close closes the listener. + // Any blocked Accept or Dial operations will unblock and return errors. + Close() error + + // Dialer returns a dialer that can be used to connect to this listener + // locally. + // If a listener does not implement this it will return nil. + Dialer() Dialer +} + +// Dialer is used by clients to dial a server. +type Dialer interface { + // Dial returns a new communication byte stream to a listening server. + Dial(ctx context.Context) (io.ReadWriteCloser, error) +} + +// Server is a running server that is accepting incoming connections. +type Server struct { + listener Listener + binder Binder + async *async + + shutdownOnce sync.Once + closing int32 // atomic: set to nonzero when Shutdown is called +} + +// Dial uses the dialer to make a new connection, wraps the returned +// reader and writer using the framer to make a stream, and then builds +// a connection on top of that stream using the binder. +// +// The returned Connection will operate independently using the Preempter and/or +// Handler provided by the Binder, and will release its own resources when the +// connection is broken, but the caller may Close it earlier to stop accepting +// (or sending) new requests. +// +// If non-nil, the onDone function is called when the connection is closed. +func Dial(ctx context.Context, dialer Dialer, binder Binder, onDone func()) (*Connection, error) { + // dial a server + rwc, err := dialer.Dial(ctx) + if err != nil { + return nil, err + } + return bindConnection(ctx, rwc, binder, onDone), nil +} + +// NewServer starts a new server listening for incoming connections and returns +// it. +// This returns a fully running and connected server, it does not block on +// the listener. +// You can call Wait to block on the server, or Shutdown to get the sever to +// terminate gracefully. +// To notice incoming connections, use an intercepting Binder. +func NewServer(ctx context.Context, listener Listener, binder Binder) *Server { + server := &Server{ + listener: listener, + binder: binder, + async: newAsync(), + } + go server.run(ctx) + return server +} + +// Wait returns only when the server has shut down. +func (s *Server) Wait() error { + return s.async.wait() +} + +// Shutdown informs the server to stop accepting new connections. +func (s *Server) Shutdown() { + s.shutdownOnce.Do(func() { + atomic.StoreInt32(&s.closing, 1) + s.listener.Close() + }) +} + +// run accepts incoming connections from the listener, +// If IdleTimeout is non-zero, run exits after there are no clients for this +// duration, otherwise it exits only on error. +func (s *Server) run(ctx context.Context) { + defer s.async.done() + + var activeConns sync.WaitGroup + for { + rwc, err := s.listener.Accept(ctx) + if err != nil { + // Only Shutdown closes the listener. If we get an error after Shutdown is + // called, assume that was the cause and don't report the error; + // otherwise, report the error in case it is unexpected. + if atomic.LoadInt32(&s.closing) == 0 { + s.async.setError(err) + } + // We are done generating new connections for good. + break + } + + // A new inbound connection. + activeConns.Add(1) + _ = bindConnection(ctx, rwc, s.binder, activeConns.Done) // unregisters itself when done + } + activeConns.Wait() +} + +// NewIdleListener wraps a listener with an idle timeout. +// +// When there are no active connections for at least the timeout duration, +// calls to Accept will fail with ErrIdleTimeout. +// +// A connection is considered inactive as soon as its Close method is called. +func NewIdleListener(timeout time.Duration, wrap Listener) Listener { + l := &idleListener{ + wrapped: wrap, + timeout: timeout, + active: make(chan int, 1), + timedOut: make(chan struct{}), + idleTimer: make(chan *time.Timer, 1), + } + l.idleTimer <- time.AfterFunc(l.timeout, l.timerExpired) + return l +} + +type idleListener struct { + wrapped Listener + timeout time.Duration + + // Only one of these channels is receivable at any given time. + active chan int // count of active connections; closed when Close is called if not timed out + timedOut chan struct{} // closed when the idle timer expires + idleTimer chan *time.Timer // holds the timer only when idle +} + +// Accept accepts an incoming connection. +// +// If an incoming connection is accepted concurrent to the listener being closed +// due to idleness, the new connection is immediately closed. +func (l *idleListener) Accept(ctx context.Context) (io.ReadWriteCloser, error) { + rwc, err := l.wrapped.Accept(ctx) + + select { + case n, ok := <-l.active: + if err != nil { + if ok { + l.active <- n + } + return nil, err + } + if ok { + l.active <- n + 1 + } else { + // l.wrapped.Close Close has been called, but Accept returned a + // connection. This race can occur with concurrent Accept and Close calls + // with any net.Listener, and it is benign: since the listener was closed + // explicitly, it can't have also timed out. + } + return l.newConn(rwc), nil + + case <-l.timedOut: + if err == nil { + // Keeping the connection open would leave the listener simultaneously + // active and closed due to idleness, which would be contradictory and + // confusing. Close the connection and pretend that it never happened. + rwc.Close() + } else { + // In theory the timeout could have raced with an unrelated error return + // from Accept. However, ErrIdleTimeout is arguably still valid (since we + // would have closed due to the timeout independent of the error), and the + // harm from returning a spurious ErrIdleTimeout is negligible anyway. + } + return nil, ErrIdleTimeout + + case timer := <-l.idleTimer: + if err != nil { + // The idle timer doesn't run until it receives itself from the idleTimer + // channel, so it can't have called l.wrapped.Close yet and thus err can't + // be ErrIdleTimeout. Leave the idle timer as it was and return whatever + // error we got. + l.idleTimer <- timer + return nil, err + } + + if !timer.Stop() { + // Failed to stop the timer — the timer goroutine is in the process of + // firing. Send the timer back to the timer goroutine so that it can + // safely close the timedOut channel, and then wait for the listener to + // actually be closed before we return ErrIdleTimeout. + l.idleTimer <- timer + rwc.Close() + <-l.timedOut + return nil, ErrIdleTimeout + } + + l.active <- 1 + return l.newConn(rwc), nil + } +} + +func (l *idleListener) Close() error { + select { + case _, ok := <-l.active: + if ok { + close(l.active) + } + + case <-l.timedOut: + // Already closed by the timer; take care not to double-close if the caller + // only explicitly invokes this Close method once, since the io.Closer + // interface explicitly leaves doubled Close calls undefined. + return ErrIdleTimeout + + case timer := <-l.idleTimer: + if !timer.Stop() { + // Couldn't stop the timer. It shouldn't take long to run, so just wait + // (so that the Listener is guaranteed to be closed before we return) + // and pretend that this call happened afterward. + // That way we won't leak any timers or goroutines when Close returns. + l.idleTimer <- timer + <-l.timedOut + return ErrIdleTimeout + } + close(l.active) + } + + return l.wrapped.Close() +} + +func (l *idleListener) Dialer() Dialer { + return l.wrapped.Dialer() +} + +func (l *idleListener) timerExpired() { + select { + case n, ok := <-l.active: + if ok { + panic(fmt.Sprintf("jsonrpc2: idleListener idle timer fired with %d connections still active", n)) + } else { + panic("jsonrpc2: Close finished with idle timer still running") + } + + case <-l.timedOut: + panic("jsonrpc2: idleListener idle timer fired more than once") + + case <-l.idleTimer: + // The timer for this very call! + } + + // Close the Listener with all channels still blocked to ensure that this call + // to l.wrapped.Close doesn't race with the one in l.Close. + defer close(l.timedOut) + l.wrapped.Close() +} + +func (l *idleListener) connClosed() { + select { + case n, ok := <-l.active: + if !ok { + // l is already closed, so it can't close due to idleness, + // and we don't need to track the number of active connections any more. + return + } + n-- + if n == 0 { + l.idleTimer <- time.AfterFunc(l.timeout, l.timerExpired) + } else { + l.active <- n + } + + case <-l.timedOut: + panic("jsonrpc2: idleListener idle timer fired before last active connection was closed") + + case <-l.idleTimer: + panic("jsonrpc2: idleListener idle timer active before last active connection was closed") + } +} + +type idleListenerConn struct { + wrapped io.ReadWriteCloser + l *idleListener + closeOnce sync.Once +} + +func (l *idleListener) newConn(rwc io.ReadWriteCloser) *idleListenerConn { + c := &idleListenerConn{ + wrapped: rwc, + l: l, + } + + // A caller that forgets to call Close may disrupt the idleListener's + // accounting, even though the file descriptor for the underlying connection + // may eventually be garbage-collected anyway. + // + // Set a (best-effort) finalizer to verify that a Close call always occurs. + // (We will clear the finalizer explicitly in Close.) + runtime.SetFinalizer(c, func(c *idleListenerConn) { + panic("jsonrpc2: IdleListener connection became unreachable without a call to Close") + }) + + return c +} + +func (c *idleListenerConn) Read(p []byte) (int, error) { return c.wrapped.Read(p) } +func (c *idleListenerConn) Write(p []byte) (int, error) { return c.wrapped.Write(p) } + +func (c *idleListenerConn) Close() error { + defer c.closeOnce.Do(func() { + c.l.connClosed() + runtime.SetFinalizer(c, nil) + }) + return c.wrapped.Close() +} diff --git a/mcp/dsx-exchange-mcp/vendor/github.com/modelcontextprotocol/go-sdk/internal/jsonrpc2/wire.go b/mcp/dsx-exchange-mcp/vendor/github.com/modelcontextprotocol/go-sdk/internal/jsonrpc2/wire.go new file mode 100644 index 0000000..c0a41bf --- /dev/null +++ b/mcp/dsx-exchange-mcp/vendor/github.com/modelcontextprotocol/go-sdk/internal/jsonrpc2/wire.go @@ -0,0 +1,97 @@ +// Copyright 2018 The Go MCP SDK Authors. All rights reserved. +// Use of this source code is governed by an MIT-style +// license that can be found in the LICENSE file. + +package jsonrpc2 + +import ( + "encoding/json" +) + +// This file contains the go forms of the wire specification. +// see http://www.jsonrpc.org/specification for details + +var ( + // ErrParse is used when invalid JSON was received by the server. + ErrParse = NewError(-32700, "parse error") + // ErrInvalidRequest is used when the JSON sent is not a valid Request object. + ErrInvalidRequest = NewError(-32600, "invalid request") + // ErrMethodNotFound should be returned by the handler when the method does + // not exist / is not available. + ErrMethodNotFound = NewError(-32601, "method not found") + // ErrInvalidParams should be returned by the handler when method + // parameter(s) were invalid. + ErrInvalidParams = NewError(-32602, "invalid params") + // ErrInternal indicates a failure to process a call correctly + ErrInternal = NewError(-32603, "internal error") + + // The following errors are not part of the json specification, but + // compliant extensions specific to this implementation. + + // ErrServerOverloaded is returned when a message was refused due to a + // server being temporarily unable to accept any new messages. + ErrServerOverloaded = NewError(-32000, "overloaded") + // ErrUnknown should be used for all non coded errors. + ErrUnknown = NewError(-32001, "unknown error") + // ErrServerClosing is returned for calls that arrive while the server is closing. + ErrServerClosing = NewError(-32004, "server is closing") + // ErrClientClosing is a dummy error returned for calls initiated while the client is closing. + ErrClientClosing = NewError(-32003, "client is closing") + + // The following errors have special semantics for MCP transports + + // ErrRejected may be wrapped to return errors from calls to Writer.Write + // that signal that the request was rejected by the transport layer as + // invalid. + // + // Such failures do not indicate that the connection is broken, but rather + // should be returned to the caller to indicate that the specific request is + // invalid in the current context. + ErrRejected = NewError(-32005, "rejected by transport") +) + +const wireVersion = "2.0" + +// wireCombined has all the fields of both Request and Response. +// We can decode this and then work out which it is. +type wireCombined struct { + VersionTag string `json:"jsonrpc"` + ID any `json:"id,omitempty"` + Method string `json:"method,omitempty"` + Params json.RawMessage `json:"params,omitempty"` + Result json.RawMessage `json:"result,omitempty"` + Error *WireError `json:"error,omitempty"` +} + +// WireError represents a structured error in a Response. +type WireError struct { + // Code is an error code indicating the type of failure. + Code int64 `json:"code"` + // Message is a short description of the error. + Message string `json:"message"` + // Data is optional structured data containing additional information about the error. + Data json.RawMessage `json:"data,omitempty"` +} + +// NewError returns an error that will encode on the wire correctly. +// The standard codes are made available from this package, this function should +// only be used to build errors for application specific codes as allowed by the +// specification. +func NewError(code int64, message string) error { + return &WireError{ + Code: code, + Message: message, + } +} + +func (err *WireError) Error() string { + return err.Message +} + +func (err *WireError) Is(other error) bool { + w, ok := other.(*WireError) + if !ok { + return false + } + return err.Code == w.Code +} diff --git a/mcp/dsx-exchange-mcp/vendor/github.com/modelcontextprotocol/go-sdk/internal/mcpgodebug/mcpgodebug.go b/mcp/dsx-exchange-mcp/vendor/github.com/modelcontextprotocol/go-sdk/internal/mcpgodebug/mcpgodebug.go new file mode 100644 index 0000000..7f8f7ca --- /dev/null +++ b/mcp/dsx-exchange-mcp/vendor/github.com/modelcontextprotocol/go-sdk/internal/mcpgodebug/mcpgodebug.go @@ -0,0 +1,52 @@ +// Copyright 2025 The Go MCP SDK Authors. All rights reserved. +// Use of this source code is governed by the license +// that can be found in the LICENSE file. + +// Package mcpgodebug provides a mechanism to configure compatibility parameters +// via the MCPGODEBUG environment variable. +// +// The value of MCPGODEBUG is a comma-separated list of key=value pairs. +// For example: +// +// MCPGODEBUG=someoption=1,otheroption=value +package mcpgodebug + +import ( + "fmt" + "os" + "strings" +) + +const compatibilityEnvKey = "MCPGODEBUG" + +var compatibilityParams map[string]string + +func init() { + var err error + compatibilityParams, err = parseCompatibility(os.Getenv(compatibilityEnvKey)) + if err != nil { + panic(err) + } +} + +// Value returns the value of the compatibility parameter with the given key. +// It returns an empty string if the key is not set. +func Value(key string) string { + return compatibilityParams[key] +} + +func parseCompatibility(envValue string) (map[string]string, error) { + if envValue == "" { + return nil, nil + } + + params := make(map[string]string) + for part := range strings.SplitSeq(envValue, ",") { + k, v, ok := strings.Cut(part, "=") + if !ok { + return nil, fmt.Errorf("MCPGODEBUG: invalid format: %q", part) + } + params[strings.TrimSpace(k)] = strings.TrimSpace(v) + } + return params, nil +} diff --git a/mcp/dsx-exchange-mcp/vendor/github.com/modelcontextprotocol/go-sdk/internal/util/net.go b/mcp/dsx-exchange-mcp/vendor/github.com/modelcontextprotocol/go-sdk/internal/util/net.go new file mode 100644 index 0000000..6858614 --- /dev/null +++ b/mcp/dsx-exchange-mcp/vendor/github.com/modelcontextprotocol/go-sdk/internal/util/net.go @@ -0,0 +1,26 @@ +// Copyright 2025 The Go MCP SDK Authors. All rights reserved. +// Use of this source code is governed by the license +// that can be found in the LICENSE file. +package util + +import ( + "net" + "net/netip" + "strings" +) + +func IsLoopback(addr string) bool { + host, _, err := net.SplitHostPort(addr) + if err != nil { + // If SplitHostPort fails, it might be just a host without a port. + host = strings.Trim(addr, "[]") + } + if host == "localhost" { + return true + } + ip, err := netip.ParseAddr(host) + if err != nil { + return false + } + return ip.IsLoopback() +} diff --git a/mcp/dsx-exchange-mcp/vendor/github.com/modelcontextprotocol/go-sdk/internal/util/util.go b/mcp/dsx-exchange-mcp/vendor/github.com/modelcontextprotocol/go-sdk/internal/util/util.go new file mode 100644 index 0000000..4b5c325 --- /dev/null +++ b/mcp/dsx-exchange-mcp/vendor/github.com/modelcontextprotocol/go-sdk/internal/util/util.go @@ -0,0 +1,44 @@ +// Copyright 2025 The Go MCP SDK Authors. All rights reserved. +// Use of this source code is governed by an MIT-style +// license that can be found in the LICENSE file. + +package util + +import ( + "cmp" + "fmt" + "iter" + "slices" +) + +// Helpers below are copied from gopls' moremaps package. + +// Sorted returns an iterator over the entries of m in key order. +func Sorted[M ~map[K]V, K cmp.Ordered, V any](m M) iter.Seq2[K, V] { + // TODO(adonovan): use maps.Sorted if proposal #68598 is accepted. + return func(yield func(K, V) bool) { + keys := KeySlice(m) + slices.Sort(keys) + for _, k := range keys { + if !yield(k, m[k]) { + break + } + } + } +} + +// KeySlice returns the keys of the map M, like slices.Collect(maps.Keys(m)). +func KeySlice[M ~map[K]V, K comparable, V any](m M) []K { + r := make([]K, 0, len(m)) + for k := range m { + r = append(r, k) + } + return r +} + +// Wrapf wraps *errp with the given formatted message if *errp is not nil. +func Wrapf(errp *error, format string, args ...any) { + if *errp != nil { + *errp = fmt.Errorf("%s: %w", fmt.Sprintf(format, args...), *errp) + } +} diff --git a/mcp/dsx-exchange-mcp/vendor/github.com/modelcontextprotocol/go-sdk/internal/xcontext/xcontext.go b/mcp/dsx-exchange-mcp/vendor/github.com/modelcontextprotocol/go-sdk/internal/xcontext/xcontext.go new file mode 100644 index 0000000..849060d --- /dev/null +++ b/mcp/dsx-exchange-mcp/vendor/github.com/modelcontextprotocol/go-sdk/internal/xcontext/xcontext.go @@ -0,0 +1,23 @@ +// Copyright 2019 The Go MCP SDK Authors. All rights reserved. +// Use of this source code is governed by an MIT-style +// license that can be found in the LICENSE file. + +// Package xcontext is a package to offer the extra functionality we need +// from contexts that is not available from the standard context package. +package xcontext + +import ( + "context" + "time" +) + +// Detach returns a context that keeps all the values of its parent context +// but detaches from the cancellation and error handling. +func Detach(ctx context.Context) context.Context { return detachedContext{ctx} } + +type detachedContext struct{ parent context.Context } + +func (v detachedContext) Deadline() (time.Time, bool) { return time.Time{}, false } +func (v detachedContext) Done() <-chan struct{} { return nil } +func (v detachedContext) Err() error { return nil } +func (v detachedContext) Value(key any) any { return v.parent.Value(key) } diff --git a/mcp/dsx-exchange-mcp/vendor/github.com/modelcontextprotocol/go-sdk/jsonrpc/jsonrpc.go b/mcp/dsx-exchange-mcp/vendor/github.com/modelcontextprotocol/go-sdk/jsonrpc/jsonrpc.go new file mode 100644 index 0000000..a9ea78f --- /dev/null +++ b/mcp/dsx-exchange-mcp/vendor/github.com/modelcontextprotocol/go-sdk/jsonrpc/jsonrpc.go @@ -0,0 +1,56 @@ +// Copyright 2025 The Go MCP SDK Authors. All rights reserved. +// Use of this source code is governed by an MIT-style +// license that can be found in the LICENSE file. + +// Package jsonrpc exposes part of a JSON-RPC v2 implementation +// for use by mcp transport authors. +package jsonrpc + +import "github.com/modelcontextprotocol/go-sdk/internal/jsonrpc2" + +type ( + // ID is a JSON-RPC request ID. + ID = jsonrpc2.ID + // Message is a JSON-RPC message. + Message = jsonrpc2.Message + // Request is a JSON-RPC request. + Request = jsonrpc2.Request + // Response is a JSON-RPC response. + Response = jsonrpc2.Response + // Error is a structured error in a JSON-RPC response. + Error = jsonrpc2.WireError +) + +// MakeID coerces the given Go value to an ID. The value should be the +// default JSON marshaling of a Request identifier: nil, float64, or string. +// +// Returns an error if the value type was not a valid Request ID type. +func MakeID(v any) (ID, error) { + return jsonrpc2.MakeID(v) +} + +// EncodeMessage serializes a JSON-RPC message to its wire format. +func EncodeMessage(msg Message) ([]byte, error) { + return jsonrpc2.EncodeMessage(msg) +} + +// DecodeMessage deserializes JSON-RPC wire format data into a Message. +// It returns either a Request or Response based on the message content. +func DecodeMessage(data []byte) (Message, error) { + return jsonrpc2.DecodeMessage(data) +} + +// Standard JSON-RPC 2.0 error codes. +// See https://www.jsonrpc.org/specification#error_object +const ( + // CodeParseError indicates invalid JSON was received by the server. + CodeParseError = -32700 + // CodeInvalidRequest indicates the JSON sent is not a valid Request object. + CodeInvalidRequest = -32600 + // CodeMethodNotFound indicates the method does not exist or is not available. + CodeMethodNotFound = -32601 + // CodeInvalidParams indicates invalid method parameter(s). + CodeInvalidParams = -32602 + // CodeInternalError indicates an internal JSON-RPC error. + CodeInternalError = -32603 +) diff --git a/mcp/dsx-exchange-mcp/vendor/github.com/modelcontextprotocol/go-sdk/mcp/client.go b/mcp/dsx-exchange-mcp/vendor/github.com/modelcontextprotocol/go-sdk/mcp/client.go new file mode 100644 index 0000000..74900b1 --- /dev/null +++ b/mcp/dsx-exchange-mcp/vendor/github.com/modelcontextprotocol/go-sdk/mcp/client.go @@ -0,0 +1,1182 @@ +// Copyright 2025 The Go MCP SDK Authors. All rights reserved. +// Use of this source code is governed by an MIT-style +// license that can be found in the LICENSE file. + +package mcp + +import ( + "context" + "errors" + "fmt" + "iter" + "log/slog" + "slices" + "strings" + "sync" + "sync/atomic" + "time" + + "github.com/google/jsonschema-go/jsonschema" + "github.com/modelcontextprotocol/go-sdk/internal/json" + "github.com/modelcontextprotocol/go-sdk/internal/jsonrpc2" + "github.com/modelcontextprotocol/go-sdk/jsonrpc" +) + +// A Client is an MCP client, which may be connected to an MCP server +// using the [Client.Connect] method. +type Client struct { + impl *Implementation + opts ClientOptions + mu sync.Mutex + roots *featureSet[*Root] + sessions []*ClientSession + sendingMethodHandler_ MethodHandler + receivingMethodHandler_ MethodHandler +} + +// NewClient creates a new [Client]. +// +// Use [Client.Connect] to connect it to an MCP server. +// +// The first argument must not be nil. +// +// If non-nil, the provided options configure the Client. +func NewClient(impl *Implementation, options *ClientOptions) *Client { + if impl == nil { + panic("nil Implementation") + } + var opts ClientOptions + if options != nil { + opts = *options + } + options = nil // prevent reuse + + if opts.CreateMessageHandler != nil && opts.CreateMessageWithToolsHandler != nil { + panic("cannot set both CreateMessageHandler and CreateMessageWithToolsHandler; use CreateMessageWithToolsHandler for tool support, or CreateMessageHandler for basic sampling") + } + if opts.Logger == nil { // ensure we have a logger + opts.Logger = ensureLogger(nil) + } + + return &Client{ + impl: impl, + opts: opts, + roots: newFeatureSet(func(r *Root) string { return r.URI }), + sendingMethodHandler_: defaultSendingMethodHandler, + receivingMethodHandler_: defaultReceivingMethodHandler[*ClientSession], + } +} + +// ClientOptions configures the behavior of the client. +type ClientOptions struct { + // Logger may be set to a non-nil value to enable logging of client activity. + Logger *slog.Logger + // CreateMessageHandler handles incoming requests for sampling/createMessage. + // + // Setting CreateMessageHandler to a non-nil value automatically causes the + // client to advertise the sampling capability, with default value + // &SamplingCapabilities{}. If [ClientOptions.Capabilities] is set and has a + // non nil value for [ClientCapabilities.Sampling], that value overrides the + // inferred capability. + CreateMessageHandler func(context.Context, *CreateMessageRequest) (*CreateMessageResult, error) + // CreateMessageWithToolsHandler handles incoming sampling/createMessage + // requests that may involve tool use. It returns + // [CreateMessageWithToolsResult], which supports array content for parallel + // tool calls. + // + // Setting this handler causes the client to advertise the sampling + // capability with tools support (sampling.tools). As with + // [CreateMessageHandler], [ClientOptions.Capabilities].Sampling overrides + // the inferred capability. + // + // It is a panic to set both CreateMessageHandler and + // CreateMessageWithToolsHandler. + CreateMessageWithToolsHandler func(context.Context, *CreateMessageWithToolsRequest) (*CreateMessageWithToolsResult, error) + // ElicitationHandler handles incoming requests for elicitation/create. + // + // Setting ElicitationHandler to a non-nil value automatically causes the + // client to advertise the elicitation capability, with default value + // &ElicitationCapabilities{}. If [ClientOptions.Capabilities] is set and has + // a non nil value for [ClientCapabilities.ELicitattion], that value + // overrides the inferred capability. + ElicitationHandler func(context.Context, *ElicitRequest) (*ElicitResult, error) + // Capabilities optionally configures the client's default capabilities, + // before any capabilities are inferred from other configuration. + // + // If Capabilities is nil, the default client capabilities are + // {"roots":{"listChanged":true}}, for historical reasons. Setting + // Capabilities to a non-nil value overrides this default. As a special case, + // to work around #607, Capabilities.Roots is ignored: set + // Capabilities.RootsV2 to configure the roots capability. This allows the + // "roots" capability to be disabled entirely. + // + // For example: + // - To disable the "roots" capability, use &ClientCapabilities{} + // - To configure "roots", but disable "listChanged" notifications, use + // &ClientCapabilities{RootsV2:&RootCapabilities{}}. + // + // # Interaction with capability inference + // + // Sampling and elicitation capabilities are automatically added when their + // corresponding handlers are set, with the default value described at + // [ClientOptions.CreateMessageHandler] and + // [ClientOptions.ElicitationHandler]. If the Sampling or Elicitation fields + // are set in the Capabilities field, their values override the inferred + // value. + // + // For example, to advertise sampling with tools and context support: + // + // Capabilities: &ClientCapabilities{ + // Sampling: &SamplingCapabilities{ + // Tools: &SamplingToolsCapabilities{}, + // Context: &SamplingContextCapabilities{}, + // }, + // } + // + // Or to configure elicitation modes: + // + // Capabilities: &ClientCapabilities{ + // Elicitation: &ElicitationCapabilities{ + // Form: &FormElicitationCapabilities{}, + // URL: &URLElicitationCapabilities{}, + // }, + // } + // + // Conversely, if Capabilities does not set a field (for example, if the + // Elicitation field is nil), the inferred capability will be used. + Capabilities *ClientCapabilities + // ElicitationCompleteHandler handles incoming notifications for notifications/elicitation/complete. + ElicitationCompleteHandler func(context.Context, *ElicitationCompleteNotificationRequest) + // Handlers for notifications from the server. + ToolListChangedHandler func(context.Context, *ToolListChangedRequest) + PromptListChangedHandler func(context.Context, *PromptListChangedRequest) + ResourceListChangedHandler func(context.Context, *ResourceListChangedRequest) + ResourceUpdatedHandler func(context.Context, *ResourceUpdatedNotificationRequest) + LoggingMessageHandler func(context.Context, *LoggingMessageRequest) + ProgressNotificationHandler func(context.Context, *ProgressNotificationClientRequest) + // If non-zero, defines an interval for regular "ping" requests. + // If the peer fails to respond to pings originating from the keepalive check, + // the session is automatically closed. + KeepAlive time.Duration +} + +// bind implements the binder[*ClientSession] interface, so that Clients can +// be connected using [connect]. +func (c *Client) bind(mcpConn Connection, conn *jsonrpc2.Connection, state *clientSessionState, onClose func()) *ClientSession { + assert(mcpConn != nil && conn != nil, "nil connection") + cs := &ClientSession{conn: conn, mcpConn: mcpConn, client: c, onClose: onClose} + if state != nil { + cs.state = *state + } + c.mu.Lock() + defer c.mu.Unlock() + c.sessions = append(c.sessions, cs) + return cs +} + +// disconnect implements the binder[*Client] interface, so that +// Clients can be connected using [connect]. +func (c *Client) disconnect(cs *ClientSession) { + c.mu.Lock() + defer c.mu.Unlock() + c.sessions = slices.DeleteFunc(c.sessions, func(cs2 *ClientSession) bool { + return cs2 == cs + }) +} + +// TODO: Consider exporting this type and its field. +type unsupportedProtocolVersionError struct { + version string +} + +func (e unsupportedProtocolVersionError) Error() string { + return fmt.Sprintf("unsupported protocol version: %q", e.version) +} + +// ClientSessionOptions is reserved for future use. +type ClientSessionOptions struct { + // protocolVersion overrides the protocol version sent in the initialize + // request, for testing. If empty, latestProtocolVersion is used. + protocolVersion string +} + +func (c *Client) capabilities(protocolVersion string) *ClientCapabilities { + // Start with user-provided capabilities as defaults, or use SDK defaults. + var caps *ClientCapabilities + if c.opts.Capabilities != nil { + // Deep copy the user-provided capabilities to avoid mutation. + caps = c.opts.Capabilities.clone() + } else { + // SDK defaults: roots with listChanged. + // (this was the default behavior at v1.0.0, and so cannot be changed) + caps = &ClientCapabilities{ + RootsV2: &RootCapabilities{ + ListChanged: true, + }, + } + } + + // Sync Roots from RootsV2 for backward compatibility (#607). + if caps.RootsV2 != nil { + caps.Roots = *caps.RootsV2 + } + + // Augment with sampling capability if a handler is set. + if c.opts.CreateMessageHandler != nil || c.opts.CreateMessageWithToolsHandler != nil { + if caps.Sampling == nil { + caps.Sampling = &SamplingCapabilities{} + if c.opts.CreateMessageWithToolsHandler != nil { + caps.Sampling.Tools = &SamplingToolsCapabilities{} + } + } + } + + // Augment with elicitation capability if handler is set. + if c.opts.ElicitationHandler != nil { + if caps.Elicitation == nil { + caps.Elicitation = &ElicitationCapabilities{} + // Form elicitation was added in 2025-11-25; for older versions, + // {} is treated the same as {"form":{}}. + if protocolVersion >= protocolVersion20251125 { + caps.Elicitation.Form = &FormElicitationCapabilities{} + } + } + } + return caps +} + +// Connect begins an MCP session by connecting to a server over the given +// transport. The resulting session is initialized, and ready to use. +// +// Typically, it is the responsibility of the client to close the connection +// when it is no longer needed. However, if the connection is closed by the +// server, calls or notifications will return an error wrapping +// [ErrConnectionClosed]. +func (c *Client) Connect(ctx context.Context, t Transport, opts *ClientSessionOptions) (cs *ClientSession, err error) { + cs, err = connect(ctx, t, c, (*clientSessionState)(nil), nil) + if err != nil { + return nil, err + } + + protocolVersion := latestProtocolVersion + if opts != nil && opts.protocolVersion != "" { + protocolVersion = opts.protocolVersion + } + params := &InitializeParams{ + ProtocolVersion: protocolVersion, + ClientInfo: c.impl, + Capabilities: c.capabilities(protocolVersion), + } + req := &InitializeRequest{Session: cs, Params: params} + res, err := handleSend[*InitializeResult](ctx, methodInitialize, req) + if err != nil { + _ = cs.Close() + return nil, err + } + if !slices.Contains(supportedProtocolVersions, res.ProtocolVersion) { + return nil, unsupportedProtocolVersionError{res.ProtocolVersion} + } + cs.state.InitializeResult = res + if hc, ok := cs.mcpConn.(clientConnection); ok { + hc.sessionUpdated(cs.state) + } + req2 := &initializedClientRequest{Session: cs, Params: &InitializedParams{}} + if err := handleNotify(ctx, notificationInitialized, req2); err != nil { + _ = cs.Close() + return nil, err + } + + if c.opts.KeepAlive > 0 { + cs.startKeepalive(c.opts.KeepAlive) + } + + return cs, nil +} + +// A ClientSession is a logical connection with an MCP server. Its +// methods can be used to send requests or notifications to the server. Create +// a session by calling [Client.Connect]. +// +// Call [ClientSession.Close] to close the connection, or await server +// termination with [ClientSession.Wait]. +type ClientSession struct { + // Ensure that onClose is called at most once. + // We defensively use an atomic CompareAndSwap rather than a sync.Once, in case the + // onClose callback triggers a re-entrant call to Close. + calledOnClose atomic.Bool + onClose func() + + conn *jsonrpc2.Connection + client *Client + keepaliveCancel context.CancelFunc + mcpConn Connection + + // No mutex is (currently) required to guard the session state, because it is + // only set synchronously during Client.Connect. + state clientSessionState + + // Pending URL elicitations waiting for completion notifications. + pendingElicitationsMu sync.Mutex + pendingElicitations map[string]chan struct{} +} + +type clientSessionState struct { + InitializeResult *InitializeResult +} + +func (cs *ClientSession) InitializeResult() *InitializeResult { return cs.state.InitializeResult } + +func (cs *ClientSession) ID() string { + if c, ok := cs.mcpConn.(hasSessionID); ok { + return c.SessionID() + } + return "" +} + +// Close performs a graceful close of the connection, preventing new requests +// from being handled, and waiting for ongoing requests to return. Close then +// terminates the connection. +// +// Close is idempotent and concurrency safe. +func (cs *ClientSession) Close() error { + // Note: keepaliveCancel access is safe without a mutex because: + // 1. keepaliveCancel is only written once during startKeepalive (happens-before all Close calls) + // 2. context.CancelFunc is safe to call multiple times and from multiple goroutines + // 3. The keepalive goroutine calls Close on ping failure, but this is safe since + // Close is idempotent and conn.Close() handles concurrent calls correctly + if cs.keepaliveCancel != nil { + cs.keepaliveCancel() + } + err := cs.conn.Close() + + if cs.onClose != nil && cs.calledOnClose.CompareAndSwap(false, true) { + cs.onClose() + } + + return err +} + +// Wait waits for the connection to be closed by the server. +// Generally, clients should be responsible for closing the connection. +func (cs *ClientSession) Wait() error { + return cs.conn.Wait() +} + +// registerElicitationWaiter registers a waiter for an elicitation complete +// notification with the given elicitation ID. It returns two functions: an await +// function that waits for the notification or context cancellation, and a cleanup +// function that must be called to unregister the waiter. This must be called before +// triggering the elicitation to avoid a race condition where the notification +// arrives before the waiter is registered. +// +// The cleanup function must be called even if the await function is never called, +// to prevent leaking the registration. +func (cs *ClientSession) registerElicitationWaiter(elicitationID string) (await func(context.Context) error, cleanup func()) { + // Create a channel for this elicitation. + ch := make(chan struct{}, 1) + + // Register the channel. + cs.pendingElicitationsMu.Lock() + if cs.pendingElicitations == nil { + cs.pendingElicitations = make(map[string]chan struct{}) + } + cs.pendingElicitations[elicitationID] = ch + cs.pendingElicitationsMu.Unlock() + + // Return await and cleanup functions. + await = func(ctx context.Context) error { + select { + case <-ctx.Done(): + return fmt.Errorf("context cancelled while waiting for elicitation completion: %w", ctx.Err()) + case <-ch: + return nil + } + } + + cleanup = func() { + cs.pendingElicitationsMu.Lock() + delete(cs.pendingElicitations, elicitationID) + cs.pendingElicitationsMu.Unlock() + } + + return await, cleanup +} + +// startKeepalive starts the keepalive mechanism for this client session. +func (cs *ClientSession) startKeepalive(interval time.Duration) { + startKeepalive(cs, interval, &cs.keepaliveCancel) +} + +// AddRoots adds the given roots to the client, +// replacing any with the same URIs, +// and notifies any connected servers. +func (c *Client) AddRoots(roots ...*Root) { + // Only notify if something could change. + if len(roots) == 0 { + return + } + changeAndNotify(c, notificationRootsListChanged, &RootsListChangedParams{}, + func() bool { c.roots.add(roots...); return true }) +} + +// RemoveRoots removes the roots with the given URIs, +// and notifies any connected servers if the list has changed. +// It is not an error to remove a nonexistent root. +func (c *Client) RemoveRoots(uris ...string) { + changeAndNotify(c, notificationRootsListChanged, &RootsListChangedParams{}, + func() bool { return c.roots.remove(uris...) }) +} + +// changeAndNotify is called when a feature is added or removed. +// It calls change, which should do the work and report whether a change actually occurred. +// If there was a change, it notifies a snapshot of the sessions. +func changeAndNotify[P Params](c *Client, notification string, params P, change func() bool) { + var sessions []*ClientSession + // Lock for the change, but not for the notification. + c.mu.Lock() + if change() { + // Check if listChanged is enabled for this notification type. + if c.shouldSendListChangedNotification(notification) { + sessions = slices.Clone(c.sessions) + } + } + c.mu.Unlock() + notifySessions(sessions, notification, params, c.opts.Logger) +} + +// shouldSendListChangedNotification checks if the client's capabilities allow +// sending the given list-changed notification. +func (c *Client) shouldSendListChangedNotification(notification string) bool { + // Get effective capabilities (considering user-provided defaults). + caps := c.opts.Capabilities + + switch notification { + case notificationRootsListChanged: + // If user didn't specify capabilities, default behavior sends notifications. + if caps == nil { + return true + } + // Check RootsV2 first (preferred), then fall back to Roots. + if caps.RootsV2 != nil { + return caps.RootsV2.ListChanged + } + return caps.Roots.ListChanged + default: + // Unknown notification, allow by default. + return true + } +} + +func (c *Client) listRoots(_ context.Context, req *ListRootsRequest) (*ListRootsResult, error) { + c.mu.Lock() + defer c.mu.Unlock() + roots := slices.Collect(c.roots.all()) + if roots == nil { + roots = []*Root{} // avoid JSON null + } + return &ListRootsResult{ + Roots: roots, + }, nil +} + +func (c *Client) createMessage(ctx context.Context, req *CreateMessageWithToolsRequest) (*CreateMessageWithToolsResult, error) { + if c.opts.CreateMessageWithToolsHandler != nil { + return c.opts.CreateMessageWithToolsHandler(ctx, req) + } + if c.opts.CreateMessageHandler != nil { + // Downconvert the request for the basic handler. + baseParams, err := req.Params.toBase() + if err != nil { + return nil, err + } + baseReq := &CreateMessageRequest{ + Session: req.Session, + Params: baseParams, + } + res, err := c.opts.CreateMessageHandler(ctx, baseReq) + if err != nil { + return nil, err + } + return res.toWithTools(), nil + } + return nil, &jsonrpc.Error{Code: codeUnsupportedMethod, Message: "client does not support CreateMessage"} +} + +// urlElicitationMiddleware returns middleware that automatically handles URL elicitation +// required errors by executing the elicitation handler, waiting for completion notifications, +// and retrying the operation. +// +// This middleware should be added to clients that want automatic URL elicitation handling: +// +// client := mcp.NewClient(impl, opts) +// client.AddSendingMiddleware(mcp.urlElicitationMiddleware()) +// +// TODO(rfindley): this isn't strictly necessary for the SEP, but may be +// useful. Propose exporting it. +func urlElicitationMiddleware() Middleware { + return func(next MethodHandler) MethodHandler { + return func(ctx context.Context, method string, req Request) (Result, error) { + // Call the underlying handler. + res, err := next(ctx, method, req) + if err == nil { + return res, nil + } + + // Check if this is a URL elicitation required error. + var rpcErr *jsonrpc.Error + if !errors.As(err, &rpcErr) || rpcErr.Code != CodeURLElicitationRequired { + return res, err + } + + // Notifications don't support retries. + if strings.HasPrefix(method, "notifications/") { + return res, err + } + + // Extract the client session. + cs, ok := req.GetSession().(*ClientSession) + if !ok { + return res, err + } + + // Check if the client has an elicitation handler. + if cs.client.opts.ElicitationHandler == nil { + return res, err + } + + // Parse the elicitations from the error data. + var errorData struct { + Elicitations []*ElicitParams `json:"elicitations"` + } + if rpcErr.Data != nil { + if err := json.Unmarshal(rpcErr.Data, &errorData); err != nil { + return nil, fmt.Errorf("failed to parse URL elicitation error data: %w", err) + } + } + + // Validate that all elicitations are URL mode. + for _, elicit := range errorData.Elicitations { + mode := elicit.Mode + if mode == "" { + mode = "form" // Default mode. + } + if mode != "url" { + return nil, fmt.Errorf("URLElicitationRequired error must only contain URL mode elicitations, got %q", mode) + } + } + + // Register waiters for all elicitations before executing handlers + // to avoid race condition where notification arrives before waiter is registered. + type waiter struct { + await func(context.Context) error + cleanup func() + } + waiters := make([]waiter, 0, len(errorData.Elicitations)) + for _, elicitParams := range errorData.Elicitations { + await, cleanup := cs.registerElicitationWaiter(elicitParams.ElicitationID) + waiters = append(waiters, waiter{await: await, cleanup: cleanup}) + } + + // Ensure cleanup happens even if we return early. + defer func() { + for _, w := range waiters { + w.cleanup() + } + }() + + // Execute the elicitation handler for each elicitation. + for _, elicitParams := range errorData.Elicitations { + elicitReq := newClientRequest(cs, elicitParams) + _, elicitErr := cs.client.elicit(ctx, elicitReq) + if elicitErr != nil { + return nil, fmt.Errorf("URL elicitation failed: %w", elicitErr) + } + } + + // Wait for all elicitations to complete. + for _, w := range waiters { + if err := w.await(ctx); err != nil { + return nil, err + } + } + + // All elicitations complete, retry the original operation. + return next(ctx, method, req) + } + } +} + +func (c *Client) elicit(ctx context.Context, req *ElicitRequest) (*ElicitResult, error) { + if c.opts.ElicitationHandler == nil { + return nil, &jsonrpc.Error{Code: jsonrpc.CodeInvalidParams, Message: "client does not support elicitation"} + } + + // Validate the elicitation parameters based on the mode. + mode := req.Params.Mode + if mode == "" { + mode = "form" + } + + switch mode { + case "form": + if req.Params.URL != "" { + return nil, &jsonrpc.Error{Code: jsonrpc.CodeInvalidParams, Message: "URL must not be set for form elicitation"} + } + schema, err := validateElicitSchema(req.Params.RequestedSchema) + if err != nil { + return nil, &jsonrpc.Error{Code: jsonrpc.CodeInvalidParams, Message: err.Error()} + } + res, err := c.opts.ElicitationHandler(ctx, req) + if err != nil { + return nil, err + } + // Validate elicitation result content against requested schema. + if res.Action == "accept" && schema != nil && res.Content != nil { + resolved, err := schema.Resolve(nil) + if err != nil { + return nil, &jsonrpc.Error{Code: jsonrpc.CodeInvalidParams, Message: fmt.Sprintf("failed to resolve requested schema: %v", err)} + } + if err := resolved.Validate(res.Content); err != nil { + return nil, &jsonrpc.Error{Code: jsonrpc.CodeInvalidParams, Message: fmt.Sprintf("elicitation result content does not match requested schema: %v", err)} + } + err = resolved.ApplyDefaults(&res.Content) + if err != nil { + return nil, &jsonrpc.Error{Code: jsonrpc.CodeInvalidParams, Message: fmt.Sprintf("failed to apply schema defalts to elicitation result: %v", err)} + } + } + return res, nil + case "url": + if req.Params.RequestedSchema != nil { + return nil, &jsonrpc.Error{Code: jsonrpc.CodeInvalidParams, Message: "requestedSchema must not be set for URL elicitation"} + } + if req.Params.URL == "" { + return nil, &jsonrpc.Error{Code: jsonrpc.CodeInvalidParams, Message: "URL must be set for URL elicitation"} + } + // No schema validation for URL mode, just pass through to handler. + return c.opts.ElicitationHandler(ctx, req) + default: + return nil, &jsonrpc.Error{Code: jsonrpc.CodeInvalidParams, Message: fmt.Sprintf("unsupported elicitation mode: %q", mode)} + } +} + +// validateElicitSchema validates that the schema conforms to MCP elicitation schema requirements. +// Per the MCP specification, elicitation schemas are limited to flat objects with primitive properties only. +func validateElicitSchema(wireSchema any) (*jsonschema.Schema, error) { + if wireSchema == nil { + return nil, nil // nil schema is allowed + } + + var schema *jsonschema.Schema + if err := remarshal(wireSchema, &schema); err != nil { + return nil, err + } + if schema == nil { + return nil, nil + } + + // The root schema must be of type "object" if specified + if schema.Type != "" && schema.Type != "object" { + return nil, fmt.Errorf("elicit schema must be of type 'object', got %q", schema.Type) + } + + // Check if the schema has properties + if schema.Properties != nil { + for propName, propSchema := range schema.Properties { + if propSchema == nil { + continue + } + + if err := validateElicitProperty(propName, propSchema); err != nil { + return nil, err + } + } + } + + return schema, nil +} + +// validateElicitProperty validates a single property in an elicitation schema. +func validateElicitProperty(propName string, propSchema *jsonschema.Schema) error { + // Check if this property has nested properties (not allowed) + if len(propSchema.Properties) > 0 { + return fmt.Errorf("elicit schema property %q contains nested properties, only primitive properties are allowed", propName) + } + // Validate based on the property type - only primitives are supported + switch propSchema.Type { + case "string": + return validateElicitStringProperty(propName, propSchema) + case "number", "integer": + return validateElicitNumberProperty(propName, propSchema) + case "boolean": + return validateElicitBooleanProperty(propName, propSchema) + case "array": + return validateElicitArrayProperty(propName, propSchema) + default: + return fmt.Errorf("elicit schema property %q has unsupported type %q, only string, number, integer, boolean, and array are allowed", propName, propSchema.Type) + } +} + +// validateElicitStringProperty validates string-type properties, including enums. +func validateElicitStringProperty(propName string, propSchema *jsonschema.Schema) error { + // Handle enum validation (enums are a special case of strings) + if len(propSchema.Enum) > 0 { + // Enums must be string type (or untyped which defaults to string) + if propSchema.Type != "" && propSchema.Type != "string" { + return fmt.Errorf("elicit schema property %q has enum values but type is %q, enums are only supported for string type", propName, propSchema.Type) + } + // Enum values themselves are validated by the JSON schema library + // Validate legacy enumNames if present - must match enum length. + if propSchema.Extra != nil { + if enumNamesRaw, exists := propSchema.Extra["enumNames"]; exists { + // Type check enumNames - should be a slice + if enumNamesSlice, ok := enumNamesRaw.([]any); ok { + if len(enumNamesSlice) != len(propSchema.Enum) { + return fmt.Errorf("elicit schema property %q has %d enum values but %d enumNames, they must match", propName, len(propSchema.Enum), len(enumNamesSlice)) + } + } else { + return fmt.Errorf("elicit schema property %q has invalid enumNames type, must be an array", propName) + } + } + } + return nil + } + // Handle new style of titled enums. + if propSchema.OneOf != nil { + for _, entry := range propSchema.OneOf { + if err := validateTitledEnumEntry(entry); err != nil { + return fmt.Errorf("elicit schema property %q oneOf has invalid entry: %v", propName, err) + } + } + return nil + } + + // Validate format if specified - only specific formats are allowed + if propSchema.Format != "" { + allowedFormats := map[string]bool{ + "email": true, + "uri": true, + "date": true, + "date-time": true, + } + if !allowedFormats[propSchema.Format] { + return fmt.Errorf("elicit schema property %q has unsupported format %q, only email, uri, date, and date-time are allowed", propName, propSchema.Format) + } + } + + // Validate minLength constraint if specified + if propSchema.MinLength != nil { + if *propSchema.MinLength < 0 { + return fmt.Errorf("elicit schema property %q has invalid minLength %d, must be non-negative", propName, *propSchema.MinLength) + } + } + + // Validate maxLength constraint if specified + if propSchema.MaxLength != nil { + if *propSchema.MaxLength < 0 { + return fmt.Errorf("elicit schema property %q has invalid maxLength %d, must be non-negative", propName, *propSchema.MaxLength) + } + // Check that maxLength >= minLength if both are specified + if propSchema.MinLength != nil && *propSchema.MaxLength < *propSchema.MinLength { + return fmt.Errorf("elicit schema property %q has maxLength %d less than minLength %d", propName, *propSchema.MaxLength, *propSchema.MinLength) + } + } + + return validateDefaultProperty[string](propName, propSchema) +} + +// validateElicitNumberProperty validates number and integer-type properties. +func validateElicitNumberProperty(propName string, propSchema *jsonschema.Schema) error { + if propSchema.Minimum != nil && propSchema.Maximum != nil { + if *propSchema.Maximum < *propSchema.Minimum { + return fmt.Errorf("elicit schema property %q has maximum %g less than minimum %g", propName, *propSchema.Maximum, *propSchema.Minimum) + } + } + + intDefaultError := validateDefaultProperty[int](propName, propSchema) + floatDefaultError := validateDefaultProperty[float64](propName, propSchema) + if intDefaultError != nil && floatDefaultError != nil { + return fmt.Errorf("elicit schema property %q has default value that cannot be interpreted as an int or float", propName) + } + + return nil +} + +// validateElicitArrayProperty validates multi-select enum properties. +func validateElicitArrayProperty(propName string, propSchema *jsonschema.Schema) error { + if propSchema.Items == nil { + return fmt.Errorf("elicit schema property %q is array but missing 'items' definition", propName) + } + + items := propSchema.Items + switch items.Type { + case "string": + // Untitled enums. + if items.Enum == nil { + return fmt.Errorf("elicit schema property %q items must specify enum for untitled enums", propName) + } + return nil + case "": + // Titled enums. + if len(items.AnyOf) == 0 { + return fmt.Errorf("elicit schema property %q items must specify anyOf for titled enums", propName) + } + for _, entry := range items.AnyOf { + if err := validateTitledEnumEntry(entry); err != nil { + return fmt.Errorf("elicit schema property %q items has invalid entry: %v", propName, err) + } + } + return nil + default: + return fmt.Errorf("elicit schema property %q items have unsupported type %q", propName, items.Type) + } +} + +func validateTitledEnumEntry(entry *jsonschema.Schema) error { + if entry.Const == nil { + return fmt.Errorf("const is required for titled enum entries") + } + constVal, ok := (*entry.Const).(string) + if !ok { + return fmt.Errorf("const must be a string for titled enum entries") + } + if constVal == "" { + return fmt.Errorf("const cannot be empty for titled enum entries") + } + if entry.Title == "" { + return fmt.Errorf("title is required for titled enum entries") + } + return nil +} + +// validateElicitBooleanProperty validates boolean-type properties. +func validateElicitBooleanProperty(propName string, propSchema *jsonschema.Schema) error { + return validateDefaultProperty[bool](propName, propSchema) +} + +func validateDefaultProperty[T any](propName string, propSchema *jsonschema.Schema) error { + // Validate default value if specified - must be a valid T + if propSchema.Default != nil { + var defaultValue T + if err := json.Unmarshal(propSchema.Default, &defaultValue); err != nil { + return fmt.Errorf("elicit schema property %q has invalid default value, must be a %T: %v", propName, defaultValue, err) + } + } + return nil +} + +// AddSendingMiddleware wraps the current sending method handler using the provided +// middleware. Middleware is applied from right to left, so that the first one is +// executed first. +// +// For example, AddSendingMiddleware(m1, m2, m3) augments the method handler as +// m1(m2(m3(handler))). +// +// Sending middleware is called when a request is sent. It is useful for tasks +// such as tracing, metrics, and adding progress tokens. +func (c *Client) AddSendingMiddleware(middleware ...Middleware) { + c.mu.Lock() + defer c.mu.Unlock() + addMiddleware(&c.sendingMethodHandler_, middleware) +} + +// AddReceivingMiddleware wraps the current receiving method handler using +// the provided middleware. Middleware is applied from right to left, so that the +// first one is executed first. +// +// For example, AddReceivingMiddleware(m1, m2, m3) augments the method handler as +// m1(m2(m3(handler))). +// +// Receiving middleware is called when a request is received. It is useful for tasks +// such as authentication, request logging and metrics. +func (c *Client) AddReceivingMiddleware(middleware ...Middleware) { + c.mu.Lock() + defer c.mu.Unlock() + addMiddleware(&c.receivingMethodHandler_, middleware) +} + +// clientMethodInfos maps from the RPC method name to serverMethodInfos. +// +// The 'allowMissingParams' values are extracted from the protocol schema. +// TODO(rfindley): actually load and validate the protocol schema, rather than +// curating these method flags. +var clientMethodInfos = map[string]methodInfo{ + methodComplete: newClientMethodInfo(clientSessionMethod((*ClientSession).Complete), 0), + methodPing: newClientMethodInfo(clientSessionMethod((*ClientSession).ping), missingParamsOK), + methodListRoots: newClientMethodInfo(clientMethod((*Client).listRoots), missingParamsOK), + methodCreateMessage: newClientMethodInfo(clientMethod((*Client).createMessage), 0), + methodElicit: newClientMethodInfo(clientMethod((*Client).elicit), missingParamsOK), + notificationCancelled: newClientMethodInfo(clientSessionMethod((*ClientSession).cancel), notification|missingParamsOK), + notificationToolListChanged: newClientMethodInfo(clientMethod((*Client).callToolChangedHandler), notification|missingParamsOK), + notificationPromptListChanged: newClientMethodInfo(clientMethod((*Client).callPromptChangedHandler), notification|missingParamsOK), + notificationResourceListChanged: newClientMethodInfo(clientMethod((*Client).callResourceChangedHandler), notification|missingParamsOK), + notificationResourceUpdated: newClientMethodInfo(clientMethod((*Client).callResourceUpdatedHandler), notification|missingParamsOK), + notificationLoggingMessage: newClientMethodInfo(clientMethod((*Client).callLoggingHandler), notification), + notificationProgress: newClientMethodInfo(clientSessionMethod((*ClientSession).callProgressNotificationHandler), notification), + notificationElicitationComplete: newClientMethodInfo(clientMethod((*Client).callElicitationCompleteHandler), notification|missingParamsOK), +} + +func (cs *ClientSession) sendingMethodInfos() map[string]methodInfo { + return serverMethodInfos +} + +func (cs *ClientSession) receivingMethodInfos() map[string]methodInfo { + return clientMethodInfos +} + +func (cs *ClientSession) handle(ctx context.Context, req *jsonrpc.Request) (any, error) { + if req.IsCall() { + jsonrpc2.Async(ctx) + } + return handleReceive(ctx, cs, req) +} + +func (cs *ClientSession) sendingMethodHandler() MethodHandler { + cs.client.mu.Lock() + defer cs.client.mu.Unlock() + return cs.client.sendingMethodHandler_ +} + +func (cs *ClientSession) receivingMethodHandler() MethodHandler { + cs.client.mu.Lock() + defer cs.client.mu.Unlock() + return cs.client.receivingMethodHandler_ +} + +// getConn implements [Session.getConn]. +func (cs *ClientSession) getConn() *jsonrpc2.Connection { return cs.conn } + +func (*ClientSession) ping(context.Context, *PingParams) (*emptyResult, error) { + return &emptyResult{}, nil +} + +// cancel is a placeholder: cancellation is handled the jsonrpc2 package. +// +// It should never be invoked in practice because cancellation is preempted, +// but having its signature here facilitates the construction of methodInfo +// that can be used to validate incoming cancellation notifications. +func (*ClientSession) cancel(context.Context, *CancelledParams) (Result, error) { + return nil, nil +} + +func newClientRequest[P Params](cs *ClientSession, params P) *ClientRequest[P] { + return &ClientRequest[P]{Session: cs, Params: params} +} + +// Ping makes an MCP "ping" request to the server. +func (cs *ClientSession) Ping(ctx context.Context, params *PingParams) error { + _, err := handleSend[*emptyResult](ctx, methodPing, newClientRequest(cs, orZero[Params](params))) + return err +} + +// ListPrompts lists prompts that are currently available on the server. +func (cs *ClientSession) ListPrompts(ctx context.Context, params *ListPromptsParams) (*ListPromptsResult, error) { + return handleSend[*ListPromptsResult](ctx, methodListPrompts, newClientRequest(cs, orZero[Params](params))) +} + +// GetPrompt gets a prompt from the server. +func (cs *ClientSession) GetPrompt(ctx context.Context, params *GetPromptParams) (*GetPromptResult, error) { + return handleSend[*GetPromptResult](ctx, methodGetPrompt, newClientRequest(cs, orZero[Params](params))) +} + +// ListTools lists tools that are currently available on the server. +func (cs *ClientSession) ListTools(ctx context.Context, params *ListToolsParams) (*ListToolsResult, error) { + return handleSend[*ListToolsResult](ctx, methodListTools, newClientRequest(cs, orZero[Params](params))) +} + +// CallTool calls the tool with the given parameters. +// +// The params.Arguments can be any value that marshals into a JSON object. +func (cs *ClientSession) CallTool(ctx context.Context, params *CallToolParams) (*CallToolResult, error) { + if params == nil { + params = new(CallToolParams) + } + if params.Arguments == nil { + // Avoid sending nil over the wire. + params.Arguments = map[string]any{} + } + return handleSend[*CallToolResult](ctx, methodCallTool, newClientRequest(cs, orZero[Params](params))) +} + +func (cs *ClientSession) SetLoggingLevel(ctx context.Context, params *SetLoggingLevelParams) error { + _, err := handleSend[*emptyResult](ctx, methodSetLevel, newClientRequest(cs, orZero[Params](params))) + return err +} + +// ListResources lists the resources that are currently available on the server. +func (cs *ClientSession) ListResources(ctx context.Context, params *ListResourcesParams) (*ListResourcesResult, error) { + return handleSend[*ListResourcesResult](ctx, methodListResources, newClientRequest(cs, orZero[Params](params))) +} + +// ListResourceTemplates lists the resource templates that are currently available on the server. +func (cs *ClientSession) ListResourceTemplates(ctx context.Context, params *ListResourceTemplatesParams) (*ListResourceTemplatesResult, error) { + return handleSend[*ListResourceTemplatesResult](ctx, methodListResourceTemplates, newClientRequest(cs, orZero[Params](params))) +} + +// ReadResource asks the server to read a resource and return its contents. +func (cs *ClientSession) ReadResource(ctx context.Context, params *ReadResourceParams) (*ReadResourceResult, error) { + return handleSend[*ReadResourceResult](ctx, methodReadResource, newClientRequest(cs, orZero[Params](params))) +} + +func (cs *ClientSession) Complete(ctx context.Context, params *CompleteParams) (*CompleteResult, error) { + return handleSend[*CompleteResult](ctx, methodComplete, newClientRequest(cs, orZero[Params](params))) +} + +// Subscribe sends a "resources/subscribe" request to the server, asking for +// notifications when the specified resource changes. +func (cs *ClientSession) Subscribe(ctx context.Context, params *SubscribeParams) error { + _, err := handleSend[*emptyResult](ctx, methodSubscribe, newClientRequest(cs, orZero[Params](params))) + return err +} + +// Unsubscribe sends a "resources/unsubscribe" request to the server, cancelling +// a previous subscription. +func (cs *ClientSession) Unsubscribe(ctx context.Context, params *UnsubscribeParams) error { + _, err := handleSend[*emptyResult](ctx, methodUnsubscribe, newClientRequest(cs, orZero[Params](params))) + return err +} + +func (c *Client) callToolChangedHandler(ctx context.Context, req *ToolListChangedRequest) (Result, error) { + if h := c.opts.ToolListChangedHandler; h != nil { + h(ctx, req) + } + return nil, nil +} + +func (c *Client) callPromptChangedHandler(ctx context.Context, req *PromptListChangedRequest) (Result, error) { + if h := c.opts.PromptListChangedHandler; h != nil { + h(ctx, req) + } + return nil, nil +} + +func (c *Client) callResourceChangedHandler(ctx context.Context, req *ResourceListChangedRequest) (Result, error) { + if h := c.opts.ResourceListChangedHandler; h != nil { + h(ctx, req) + } + return nil, nil +} + +func (c *Client) callResourceUpdatedHandler(ctx context.Context, req *ResourceUpdatedNotificationRequest) (Result, error) { + if h := c.opts.ResourceUpdatedHandler; h != nil { + h(ctx, req) + } + return nil, nil +} + +func (c *Client) callLoggingHandler(ctx context.Context, req *LoggingMessageRequest) (Result, error) { + if h := c.opts.LoggingMessageHandler; h != nil { + h(ctx, req) + } + return nil, nil +} + +func (cs *ClientSession) callProgressNotificationHandler(ctx context.Context, params *ProgressNotificationParams) (Result, error) { + if h := cs.client.opts.ProgressNotificationHandler; h != nil { + h(ctx, clientRequestFor(cs, params)) + } + return nil, nil +} + +func (c *Client) callElicitationCompleteHandler(ctx context.Context, req *ElicitationCompleteNotificationRequest) (Result, error) { + // Check if there's a pending elicitation waiting for this notification. + if cs, ok := req.GetSession().(*ClientSession); ok { + cs.pendingElicitationsMu.Lock() + if ch, exists := cs.pendingElicitations[req.Params.ElicitationID]; exists { + select { + case ch <- struct{}{}: + default: + // Channel already signaled. + } + } + cs.pendingElicitationsMu.Unlock() + } + + // Call the user's handler if provided. + if h := c.opts.ElicitationCompleteHandler; h != nil { + h(ctx, req) + } + return nil, nil +} + +// NotifyProgress sends a progress notification from the client to the server +// associated with this session. +// This can be used if the client is performing a long-running task that was +// initiated by the server. +func (cs *ClientSession) NotifyProgress(ctx context.Context, params *ProgressNotificationParams) error { + return handleNotify(ctx, notificationProgress, newClientRequest(cs, orZero[Params](params))) +} + +// Tools provides an iterator for all tools available on the server, +// automatically fetching pages and managing cursors. +// The params argument can set the initial cursor. +// Iteration stops at the first encountered error, which will be yielded. +func (cs *ClientSession) Tools(ctx context.Context, params *ListToolsParams) iter.Seq2[*Tool, error] { + if params == nil { + params = &ListToolsParams{} + } + return paginate(ctx, params, cs.ListTools, func(res *ListToolsResult) []*Tool { + return res.Tools + }) +} + +// Resources provides an iterator for all resources available on the server, +// automatically fetching pages and managing cursors. +// The params argument can set the initial cursor. +// Iteration stops at the first encountered error, which will be yielded. +func (cs *ClientSession) Resources(ctx context.Context, params *ListResourcesParams) iter.Seq2[*Resource, error] { + if params == nil { + params = &ListResourcesParams{} + } + return paginate(ctx, params, cs.ListResources, func(res *ListResourcesResult) []*Resource { + return res.Resources + }) +} + +// ResourceTemplates provides an iterator for all resource templates available on the server, +// automatically fetching pages and managing cursors. +// The params argument can set the initial cursor. +// Iteration stops at the first encountered error, which will be yielded. +func (cs *ClientSession) ResourceTemplates(ctx context.Context, params *ListResourceTemplatesParams) iter.Seq2[*ResourceTemplate, error] { + if params == nil { + params = &ListResourceTemplatesParams{} + } + return paginate(ctx, params, cs.ListResourceTemplates, func(res *ListResourceTemplatesResult) []*ResourceTemplate { + return res.ResourceTemplates + }) +} + +// Prompts provides an iterator for all prompts available on the server, +// automatically fetching pages and managing cursors. +// The params argument can set the initial cursor. +// Iteration stops at the first encountered error, which will be yielded. +func (cs *ClientSession) Prompts(ctx context.Context, params *ListPromptsParams) iter.Seq2[*Prompt, error] { + if params == nil { + params = &ListPromptsParams{} + } + return paginate(ctx, params, cs.ListPrompts, func(res *ListPromptsResult) []*Prompt { + return res.Prompts + }) +} + +// paginate is a generic helper function to provide a paginated iterator. +func paginate[P listParams, R listResult[T], T any](ctx context.Context, params P, listFunc func(context.Context, P) (R, error), items func(R) []*T) iter.Seq2[*T, error] { + return func(yield func(*T, error) bool) { + for { + res, err := listFunc(ctx, params) + if err != nil { + yield(nil, err) + return + } + for _, r := range items(res) { + if !yield(r, nil) { + return + } + } + nextCursorVal := res.nextCursorPtr() + if nextCursorVal == nil || *nextCursorVal == "" { + return + } + *params.cursorPtr() = *nextCursorVal + } + } +} diff --git a/mcp/dsx-exchange-mcp/vendor/github.com/modelcontextprotocol/go-sdk/mcp/cmd.go b/mcp/dsx-exchange-mcp/vendor/github.com/modelcontextprotocol/go-sdk/mcp/cmd.go new file mode 100644 index 0000000..b531eaf --- /dev/null +++ b/mcp/dsx-exchange-mcp/vendor/github.com/modelcontextprotocol/go-sdk/mcp/cmd.go @@ -0,0 +1,108 @@ +// Copyright 2025 The Go MCP SDK Authors. All rights reserved. +// Use of this source code is governed by an MIT-style +// license that can be found in the LICENSE file. + +package mcp + +import ( + "context" + "fmt" + "io" + "os/exec" + "syscall" + "time" +) + +var defaultTerminateDuration = 5 * time.Second // mutable for testing + +// A CommandTransport is a [Transport] that runs a command and communicates +// with it over stdin/stdout, using newline-delimited JSON. +type CommandTransport struct { + Command *exec.Cmd + // TerminateDuration controls how long Close waits after closing stdin + // for the process to exit before sending SIGTERM. + // If zero or negative, the default of 5s is used. + TerminateDuration time.Duration +} + +// Connect starts the command, and connects to it over stdin/stdout. +func (t *CommandTransport) Connect(ctx context.Context) (Connection, error) { + stdout, err := t.Command.StdoutPipe() + if err != nil { + return nil, err + } + stdout = io.NopCloser(stdout) // close the connection by closing stdin, not stdout + stdin, err := t.Command.StdinPipe() + if err != nil { + return nil, err + } + if err := t.Command.Start(); err != nil { + return nil, err + } + td := t.TerminateDuration + if td <= 0 { + td = defaultTerminateDuration + } + return newIOConn(&pipeRWC{t.Command, stdout, stdin, td}), nil +} + +// A pipeRWC is an io.ReadWriteCloser that communicates with a subprocess over +// stdin/stdout pipes. +type pipeRWC struct { + cmd *exec.Cmd + stdout io.ReadCloser + stdin io.WriteCloser + terminateDuration time.Duration +} + +func (s *pipeRWC) Read(p []byte) (n int, err error) { + return s.stdout.Read(p) +} + +func (s *pipeRWC) Write(p []byte) (n int, err error) { + return s.stdin.Write(p) +} + +// Close closes the input stream to the child process, and awaits normal +// termination of the command. If the command does not exit, it is signalled to +// terminate, and then eventually killed. +func (s *pipeRWC) Close() error { + // Spec: + // "For the stdio transport, the client SHOULD initiate shutdown by:... + + // "...First, closing the input stream to the child process (the server)" + if err := s.stdin.Close(); err != nil { + return fmt.Errorf("closing stdin: %v", err) + } + resChan := make(chan error, 1) + go func() { + resChan <- s.cmd.Wait() + }() + // "...Waiting for the server to exit, or sending SIGTERM if the server does not exit within a reasonable time" + wait := func() (error, bool) { + select { + case err := <-resChan: + return err, true + case <-time.After(s.terminateDuration): + } + return nil, false + } + if err, ok := wait(); ok { + return err + } + // Note the condition here: if sending SIGTERM fails, don't wait and just + // move on to SIGKILL. + if err := s.cmd.Process.Signal(syscall.SIGTERM); err == nil { + if err, ok := wait(); ok { + return err + } + } + // "...Sending SIGKILL if the server does not exit within a reasonable time after SIGTERM" + if err := s.cmd.Process.Kill(); err != nil { + return err + } + if err, ok := wait(); ok { + return err + } + return fmt.Errorf("unresponsive subprocess") +} diff --git a/mcp/dsx-exchange-mcp/vendor/github.com/modelcontextprotocol/go-sdk/mcp/content.go b/mcp/dsx-exchange-mcp/vendor/github.com/modelcontextprotocol/go-sdk/mcp/content.go new file mode 100644 index 0000000..95ea40d --- /dev/null +++ b/mcp/dsx-exchange-mcp/vendor/github.com/modelcontextprotocol/go-sdk/mcp/content.go @@ -0,0 +1,410 @@ +// Copyright 2025 The Go MCP SDK Authors. All rights reserved. +// Use of this source code is governed by an MIT-style +// license that can be found in the LICENSE file. + +// TODO(findleyr): update JSON marshalling of all content types to preserve required fields. +// (See [TextContent.MarshalJSON], which handles this for text content). + +package mcp + +import ( + "encoding/json" + "fmt" + + internaljson "github.com/modelcontextprotocol/go-sdk/internal/json" +) + +// A Content is a [TextContent], [ImageContent], [AudioContent], +// [ResourceLink], [EmbeddedResource], [ToolUseContent], or [ToolResultContent]. +// +// Note: [ToolUseContent] and [ToolResultContent] are only valid in sampling +// message contexts (CreateMessageParams/CreateMessageResult). +type Content interface { + MarshalJSON() ([]byte, error) + fromWire(*wireContent) +} + +// TextContent is a textual content. +type TextContent struct { + Text string + Meta Meta + Annotations *Annotations +} + +func (c *TextContent) MarshalJSON() ([]byte, error) { + // Custom wire format to ensure the required "text" field is always included, even when empty. + wire := struct { + Type string `json:"type"` + Text string `json:"text"` + Meta Meta `json:"_meta,omitempty"` + Annotations *Annotations `json:"annotations,omitempty"` + }{ + Type: "text", + Text: c.Text, + Meta: c.Meta, + Annotations: c.Annotations, + } + return json.Marshal(wire) +} + +func (c *TextContent) fromWire(wire *wireContent) { + c.Text = wire.Text + c.Meta = wire.Meta + c.Annotations = wire.Annotations +} + +// ImageContent contains base64-encoded image data. +type ImageContent struct { + Meta Meta + Annotations *Annotations + Data []byte // base64-encoded + MIMEType string +} + +func (c *ImageContent) MarshalJSON() ([]byte, error) { + // Custom wire format to ensure required fields are always included, even when empty. + data := c.Data + if data == nil { + data = []byte{} + } + wire := imageAudioWire{ + Type: "image", + MIMEType: c.MIMEType, + Data: data, + Meta: c.Meta, + Annotations: c.Annotations, + } + return json.Marshal(wire) +} + +func (c *ImageContent) fromWire(wire *wireContent) { + c.MIMEType = wire.MIMEType + c.Data = wire.Data + c.Meta = wire.Meta + c.Annotations = wire.Annotations +} + +// AudioContent contains base64-encoded audio data. +type AudioContent struct { + Data []byte + MIMEType string + Meta Meta + Annotations *Annotations +} + +func (c AudioContent) MarshalJSON() ([]byte, error) { + // Custom wire format to ensure required fields are always included, even when empty. + data := c.Data + if data == nil { + data = []byte{} + } + wire := imageAudioWire{ + Type: "audio", + MIMEType: c.MIMEType, + Data: data, + Meta: c.Meta, + Annotations: c.Annotations, + } + return json.Marshal(wire) +} + +func (c *AudioContent) fromWire(wire *wireContent) { + c.MIMEType = wire.MIMEType + c.Data = wire.Data + c.Meta = wire.Meta + c.Annotations = wire.Annotations +} + +// Custom wire format to ensure required fields are always included, even when empty. +type imageAudioWire struct { + Type string `json:"type"` + MIMEType string `json:"mimeType"` + Data []byte `json:"data"` + Meta Meta `json:"_meta,omitempty"` + Annotations *Annotations `json:"annotations,omitempty"` +} + +// ResourceLink is a link to a resource +type ResourceLink struct { + URI string + Name string + Title string + Description string + MIMEType string + Size *int64 + Meta Meta + Annotations *Annotations + // Icons for the resource link, if any. + Icons []Icon `json:"icons,omitempty"` +} + +func (c *ResourceLink) MarshalJSON() ([]byte, error) { + return json.Marshal(&wireContent{ + Type: "resource_link", + URI: c.URI, + Name: c.Name, + Title: c.Title, + Description: c.Description, + MIMEType: c.MIMEType, + Size: c.Size, + Meta: c.Meta, + Annotations: c.Annotations, + Icons: c.Icons, + }) +} + +func (c *ResourceLink) fromWire(wire *wireContent) { + c.URI = wire.URI + c.Name = wire.Name + c.Title = wire.Title + c.Description = wire.Description + c.MIMEType = wire.MIMEType + c.Size = wire.Size + c.Meta = wire.Meta + c.Annotations = wire.Annotations + c.Icons = wire.Icons +} + +// EmbeddedResource contains embedded resources. +type EmbeddedResource struct { + Resource *ResourceContents + Meta Meta + Annotations *Annotations +} + +func (c *EmbeddedResource) MarshalJSON() ([]byte, error) { + return json.Marshal(&wireContent{ + Type: "resource", + Resource: c.Resource, + Meta: c.Meta, + Annotations: c.Annotations, + }) +} + +func (c *EmbeddedResource) fromWire(wire *wireContent) { + c.Resource = wire.Resource + c.Meta = wire.Meta + c.Annotations = wire.Annotations +} + +// ToolUseContent represents a request from the assistant to invoke a tool. +// This content type is only valid in sampling messages. +type ToolUseContent struct { + // ID is a unique identifier for this tool use, used to match with ToolResultContent. + ID string + // Name is the name of the tool to invoke. + Name string + // Input contains the tool arguments as a JSON object. + Input map[string]any + Meta Meta +} + +func (c *ToolUseContent) MarshalJSON() ([]byte, error) { + input := c.Input + if input == nil { + input = map[string]any{} + } + wire := struct { + Type string `json:"type"` + ID string `json:"id"` + Name string `json:"name"` + Input map[string]any `json:"input"` + Meta Meta `json:"_meta,omitempty"` + }{ + Type: "tool_use", + ID: c.ID, + Name: c.Name, + Input: input, + Meta: c.Meta, + } + return json.Marshal(wire) +} + +func (c *ToolUseContent) fromWire(wire *wireContent) { + c.ID = wire.ID + c.Name = wire.Name + c.Input = wire.Input + c.Meta = wire.Meta +} + +// ToolResultContent represents the result of a tool invocation. +// This content type is only valid in sampling messages with role "user". +type ToolResultContent struct { + // ToolUseID references the ID from the corresponding ToolUseContent. + ToolUseID string + // Content holds the unstructured result of the tool call. + Content []Content + // StructuredContent holds an optional structured result as a JSON object. + StructuredContent any + // IsError indicates whether the tool call ended in an error. + IsError bool + Meta Meta +} + +func (c *ToolResultContent) MarshalJSON() ([]byte, error) { + // Marshal nested content + var contentWire []*wireContent + for _, content := range c.Content { + data, err := content.MarshalJSON() + if err != nil { + return nil, err + } + var w wireContent + if err := internaljson.Unmarshal(data, &w); err != nil { + return nil, err + } + contentWire = append(contentWire, &w) + } + if contentWire == nil { + contentWire = []*wireContent{} // avoid JSON null + } + + wire := struct { + Type string `json:"type"` + ToolUseID string `json:"toolUseId"` + Content []*wireContent `json:"content"` + StructuredContent any `json:"structuredContent,omitempty"` + IsError bool `json:"isError,omitempty"` + Meta Meta `json:"_meta,omitempty"` + }{ + Type: "tool_result", + ToolUseID: c.ToolUseID, + Content: contentWire, + StructuredContent: c.StructuredContent, + IsError: c.IsError, + Meta: c.Meta, + } + return json.Marshal(wire) +} + +func (c *ToolResultContent) fromWire(wire *wireContent) { + c.ToolUseID = wire.ToolUseID + c.StructuredContent = wire.StructuredContent + c.IsError = wire.IsError + c.Meta = wire.Meta + // Content is handled separately in contentFromWire due to nested content +} + +// ResourceContents contains the contents of a specific resource or +// sub-resource. +type ResourceContents struct { + URI string `json:"uri"` + MIMEType string `json:"mimeType,omitempty"` + Text string `json:"text,omitempty"` + Blob []byte `json:"blob,omitzero"` + Meta Meta `json:"_meta,omitempty"` +} + +// wireContent is the wire format for content. +// It represents the protocol types TextContent, ImageContent, AudioContent, +// ResourceLink, EmbeddedResource, ToolUseContent, and ToolResultContent. +// The Type field distinguishes them. In the protocol, each type has a constant +// value for the field. +type wireContent struct { + Type string `json:"type"` + Text string `json:"text,omitempty"` // TextContent + MIMEType string `json:"mimeType,omitempty"` // ImageContent, AudioContent, ResourceLink + Data []byte `json:"data,omitempty"` // ImageContent, AudioContent + Resource *ResourceContents `json:"resource,omitempty"` // EmbeddedResource + URI string `json:"uri,omitempty"` // ResourceLink + Name string `json:"name,omitempty"` // ResourceLink, ToolUseContent + Title string `json:"title,omitempty"` // ResourceLink + Description string `json:"description,omitempty"` // ResourceLink + Size *int64 `json:"size,omitempty"` // ResourceLink + Meta Meta `json:"_meta,omitempty"` // all types + Annotations *Annotations `json:"annotations,omitempty"` // all types except ToolUseContent, ToolResultContent + Icons []Icon `json:"icons,omitempty"` // ResourceLink + ID string `json:"id,omitempty"` // ToolUseContent + Input map[string]any `json:"input,omitempty"` // ToolUseContent + ToolUseID string `json:"toolUseId,omitempty"` // ToolResultContent + NestedContent []*wireContent `json:"content,omitempty"` // ToolResultContent + StructuredContent any `json:"structuredContent,omitempty"` // ToolResultContent + IsError bool `json:"isError,omitempty"` // ToolResultContent +} + +// unmarshalContent unmarshals JSON that is either a single content object or +// an array of content objects. A single object is wrapped in a one-element slice. +func unmarshalContent(raw json.RawMessage, allow map[string]bool) ([]Content, error) { + if len(raw) == 0 || string(raw) == "null" { + return nil, fmt.Errorf("nil content") + } + // Try array first, then fall back to single object. + var wires []*wireContent + if err := internaljson.Unmarshal(raw, &wires); err == nil { + return contentsFromWire(wires, allow) + } + var wire wireContent + if err := internaljson.Unmarshal(raw, &wire); err != nil { + return nil, err + } + c, err := contentFromWire(&wire, allow) + if err != nil { + return nil, err + } + return []Content{c}, nil +} + +func contentsFromWire(wires []*wireContent, allow map[string]bool) ([]Content, error) { + blocks := make([]Content, 0, len(wires)) + for _, wire := range wires { + block, err := contentFromWire(wire, allow) + if err != nil { + return nil, err + } + blocks = append(blocks, block) + } + return blocks, nil +} + +func contentFromWire(wire *wireContent, allow map[string]bool) (Content, error) { + if wire == nil { + return nil, fmt.Errorf("nil content") + } + if allow != nil && !allow[wire.Type] { + return nil, fmt.Errorf("invalid content type %q", wire.Type) + } + switch wire.Type { + case "text": + v := new(TextContent) + v.fromWire(wire) + return v, nil + case "image": + v := new(ImageContent) + v.fromWire(wire) + return v, nil + case "audio": + v := new(AudioContent) + v.fromWire(wire) + return v, nil + case "resource_link": + v := new(ResourceLink) + v.fromWire(wire) + return v, nil + case "resource": + v := new(EmbeddedResource) + v.fromWire(wire) + return v, nil + case "tool_use": + v := new(ToolUseContent) + v.fromWire(wire) + return v, nil + case "tool_result": + v := new(ToolResultContent) + v.fromWire(wire) + // Handle nested content - tool_result content can contain text, image, audio, + // resource_link, and resource (same as CallToolResult.content) + if wire.NestedContent != nil { + toolResultContentAllow := map[string]bool{ + "text": true, "image": true, "audio": true, + "resource_link": true, "resource": true, + } + nestedContent, err := contentsFromWire(wire.NestedContent, toolResultContentAllow) + if err != nil { + return nil, fmt.Errorf("tool_result nested content: %w", err) + } + v.Content = nestedContent + } + return v, nil + } + return nil, fmt.Errorf("unrecognized content type %q", wire.Type) +} diff --git a/mcp/dsx-exchange-mcp/vendor/github.com/modelcontextprotocol/go-sdk/mcp/event.go b/mcp/dsx-exchange-mcp/vendor/github.com/modelcontextprotocol/go-sdk/mcp/event.go new file mode 100644 index 0000000..62dd2ad --- /dev/null +++ b/mcp/dsx-exchange-mcp/vendor/github.com/modelcontextprotocol/go-sdk/mcp/event.go @@ -0,0 +1,436 @@ +// Copyright 2025 The Go MCP SDK Authors. All rights reserved. +// Use of this source code is governed by an MIT-style +// license that can be found in the LICENSE file. + +// This file is for SSE events. +// See https://developer.mozilla.org/en-US/docs/Web/API/Server-sent_events/Using_server-sent_events. + +package mcp + +import ( + "bufio" + "bytes" + "context" + "errors" + "fmt" + "io" + "iter" + "maps" + "net/http" + "slices" + "strings" + "sync" +) + +// If true, MemoryEventStore will do frequent validation to check invariants, slowing it down. +// Enable for debugging. +const validateMemoryEventStore = false + +// An Event is a server-sent event. +// See https://developer.mozilla.org/en-US/docs/Web/API/Server-sent_events/Using_server-sent_events#fields. +type Event struct { + Name string // the "event" field + ID string // the "id" field + Data []byte // the "data" field + Retry string // the "retry" field +} + +// Empty reports whether the Event is empty. +func (e Event) Empty() bool { + return e.Name == "" && e.ID == "" && len(e.Data) == 0 && e.Retry == "" +} + +// writeEvent writes the event to w, and flushes. +func writeEvent(w io.Writer, evt Event) (int, error) { + var b bytes.Buffer + if evt.Name != "" { + fmt.Fprintf(&b, "event: %s\n", evt.Name) + } + if evt.ID != "" { + fmt.Fprintf(&b, "id: %s\n", evt.ID) + } + if evt.Retry != "" { + fmt.Fprintf(&b, "retry: %s\n", evt.Retry) + } + fmt.Fprintf(&b, "data: %s\n\n", string(evt.Data)) + n, err := w.Write(b.Bytes()) + if f, ok := w.(http.Flusher); ok { + f.Flush() + } + return n, err +} + +// scanEvents iterates SSE events in the given scanner. The iterated error is +// terminal: if encountered, the stream is corrupt or broken and should no +// longer be used. +// +// TODO(rfindley): consider a different API here that makes failure modes more +// apparent. +func scanEvents(r io.Reader) iter.Seq2[Event, error] { + reader := bufio.NewReader(r) + + // TODO: investigate proper behavior when events are out of order, or have + // non-standard names. + var ( + eventKey = []byte("event") + idKey = []byte("id") + dataKey = []byte("data") + retryKey = []byte("retry") + ) + + return func(yield func(Event, error) bool) { + // iterate event from the wire. + // https://developer.mozilla.org/en-US/docs/Web/API/Server-sent_events/Using_server-sent_events#examples + // + // - `key: value` line records. + // - Consecutive `data: ...` fields are joined with newlines. + // - Unrecognized fields are ignored. Since we only care about 'event', 'id', and + // 'data', these are the only three we consider. + // - Lines starting with ":" are ignored. + // - Records are terminated with two consecutive newlines. + var ( + evt Event + dataBuf *bytes.Buffer // if non-nil, preceding field was also data + ) + yieldEvent := func() bool { + if dataBuf != nil { + evt.Data = dataBuf.Bytes() + dataBuf = nil + } + if evt.Empty() { + return true + } + if !yield(evt, nil) { + return false + } + evt = Event{} + return true + } + for { + line, err := reader.ReadBytes('\n') + if err != nil && !errors.Is(err, io.EOF) { + yield(Event{}, fmt.Errorf("error reading event: %v", err)) + return + } + line = bytes.TrimRight(line, "\r\n") + isEOF := errors.Is(err, io.EOF) + + if len(line) == 0 { + if !yieldEvent() { + return + } + if isEOF { + return + } + continue + } + before, after, found := bytes.Cut(line, []byte{':'}) + if !found { + yield(Event{}, fmt.Errorf("%w: malformed line in SSE stream: %q", errMalformedEvent, string(line))) + return + } + switch { + case bytes.Equal(before, eventKey): + evt.Name = strings.TrimSpace(string(after)) + case bytes.Equal(before, idKey): + evt.ID = strings.TrimSpace(string(after)) + case bytes.Equal(before, retryKey): + evt.Retry = strings.TrimSpace(string(after)) + case bytes.Equal(before, dataKey): + data := bytes.TrimSpace(after) + if dataBuf == nil { + dataBuf = new(bytes.Buffer) + } else { + dataBuf.WriteByte('\n') + } + dataBuf.Write(data) + } + + if isEOF { + yieldEvent() + return + } + } + } +} + +// An EventStore tracks data for SSE streams. +// A single EventStore suffices for all sessions, since session IDs are +// globally unique. So one EventStore can be created per process, for +// all Servers in the process. +// Such a store is able to bound resource usage for the entire process. +// +// All of an EventStore's methods must be safe for use by multiple goroutines. +type EventStore interface { + // Open is called when a new stream is created. It may be used to ensure that + // the underlying data structure for the stream is initialized, making it + // ready to store and replay event streams. + Open(_ context.Context, sessionID, streamID string) error + + // Append appends data for an outgoing event to given stream, which is part of the + // given session. + Append(_ context.Context, sessionID, streamID string, data []byte) error + + // After returns an iterator over the data for the given session and stream, beginning + // just after the given index. + // + // Once the iterator yields a non-nil error, it will stop. + // After's iterator must return an error immediately if any data after index was + // dropped; it must not return partial results. + // The stream must have been opened previously (see [EventStore.Open]). + After(_ context.Context, sessionID, streamID string, index int) iter.Seq2[[]byte, error] + + // SessionClosed informs the store that the given session is finished, along + // with all of its streams. + // + // A store cannot rely on this method being called for cleanup. It should institute + // additional mechanisms, such as timeouts, to reclaim storage. + SessionClosed(_ context.Context, sessionID string) error + + // There is no StreamClosed method. A server doesn't know when a stream is finished, because + // the client can always send a GET with a Last-Event-ID referring to the stream. +} + +// A dataList is a list of []byte. +// The zero dataList is ready to use. +type dataList struct { + size int // total size of data bytes + first int // the stream index of the first element in data + data [][]byte +} + +func (dl *dataList) appendData(d []byte) { + // Empty data consumes memory but doesn't increment size. However, it should + // be rare. + dl.data = append(dl.data, d) + dl.size += len(d) +} + +// removeFirst removes the first data item in dl, returning the size of the item. +// It panics if dl is empty. +func (dl *dataList) removeFirst() int { + if len(dl.data) == 0 { + panic("empty dataList") + } + r := len(dl.data[0]) + dl.size -= r + dl.data[0] = nil // help GC + dl.data = dl.data[1:] + dl.first++ + return r +} + +// A MemoryEventStore is an [EventStore] backed by memory. +type MemoryEventStore struct { + mu sync.Mutex + maxBytes int // max total size of all data + nBytes int // current total size of all data + store map[string]map[string]*dataList // session ID -> stream ID -> *dataList +} + +// MemoryEventStoreOptions are options for a [MemoryEventStore]. +type MemoryEventStoreOptions struct{} + +// MaxBytes returns the maximum number of bytes that the store will retain before +// purging data. +func (s *MemoryEventStore) MaxBytes() int { + s.mu.Lock() + defer s.mu.Unlock() + return s.maxBytes +} + +// SetMaxBytes sets the maximum number of bytes the store will retain before purging +// data. The argument must not be negative. If it is zero, a suitable default will be used. +// SetMaxBytes can be called at any time. The size of the store will be adjusted +// immediately. +func (s *MemoryEventStore) SetMaxBytes(n int) { + s.mu.Lock() + defer s.mu.Unlock() + switch { + case n < 0: + panic("negative argument") + case n == 0: + s.maxBytes = defaultMaxBytes + default: + s.maxBytes = n + } + s.purge() +} + +const defaultMaxBytes = 10 << 20 // 10 MiB + +// NewMemoryEventStore creates a [MemoryEventStore] with the default value +// for MaxBytes. +func NewMemoryEventStore(opts *MemoryEventStoreOptions) *MemoryEventStore { + return &MemoryEventStore{ + maxBytes: defaultMaxBytes, + store: make(map[string]map[string]*dataList), + } +} + +// Open implements [EventStore.Open]. It ensures that the underlying data +// structures for the given session are initialized and ready for use. +func (s *MemoryEventStore) Open(_ context.Context, sessionID, streamID string) error { + s.mu.Lock() + defer s.mu.Unlock() + s.init(sessionID, streamID) + return nil +} + +// init is an internal helper function that ensures the nested map structure for a +// given sessionID and streamID exists, creating it if necessary. It returns the +// dataList associated with the specified IDs. +// Requires s.mu. +func (s *MemoryEventStore) init(sessionID, streamID string) *dataList { + streamMap, ok := s.store[sessionID] + if !ok { + streamMap = make(map[string]*dataList) + s.store[sessionID] = streamMap + } + dl, ok := streamMap[streamID] + if !ok { + dl = &dataList{} + streamMap[streamID] = dl + } + return dl +} + +// Append implements [EventStore.Append] by recording data in memory. +func (s *MemoryEventStore) Append(_ context.Context, sessionID, streamID string, data []byte) error { + s.mu.Lock() + defer s.mu.Unlock() + dl := s.init(sessionID, streamID) + // Purge before adding, so at least the current data item will be present. + // (That could result in nBytes > maxBytes, but we'll live with that.) + s.purge() + dl.appendData(data) + s.nBytes += len(data) + return nil +} + +// ErrEventsPurged is the error that [EventStore.After] should return if the event just after the +// index is no longer available. +var ErrEventsPurged = errors.New("data purged") + +// errMalformedEvent is returned when an SSE event cannot be parsed due to format violations. +// This is a hard error indicating corrupted data or protocol violations, as opposed to +// transient I/O errors which may be retryable. +var errMalformedEvent = errors.New("malformed event") + +// After implements [EventStore.After]. +func (s *MemoryEventStore) After(_ context.Context, sessionID, streamID string, index int) iter.Seq2[[]byte, error] { + // Return the data items to yield. + // We must copy, because dataList.removeFirst nils out slice elements. + copyData := func() ([][]byte, error) { + s.mu.Lock() + defer s.mu.Unlock() + streamMap, ok := s.store[sessionID] + if !ok { + return nil, fmt.Errorf("MemoryEventStore.After: unknown session ID %q", sessionID) + } + dl, ok := streamMap[streamID] + if !ok { + return nil, fmt.Errorf("MemoryEventStore.After: unknown stream ID %v in session %q", streamID, sessionID) + } + start := index + 1 + if dl.first > start { + return nil, fmt.Errorf("MemoryEventStore.After: index %d, stream ID %v, session %q: %w", + index, streamID, sessionID, ErrEventsPurged) + } + return slices.Clone(dl.data[start-dl.first:]), nil + } + + return func(yield func([]byte, error) bool) { + ds, err := copyData() + if err != nil { + yield(nil, err) + return + } + for _, d := range ds { + if !yield(d, nil) { + return + } + } + } +} + +// SessionClosed implements [EventStore.SessionClosed]. +func (s *MemoryEventStore) SessionClosed(_ context.Context, sessionID string) error { + s.mu.Lock() + defer s.mu.Unlock() + for _, dl := range s.store[sessionID] { + s.nBytes -= dl.size + } + delete(s.store, sessionID) + s.validate() + return nil +} + +// purge removes data until no more than s.maxBytes bytes are in use. +// It must be called with s.mu held. +func (s *MemoryEventStore) purge() { + // Remove the first element of every dataList until below the max. + for s.nBytes > s.maxBytes { + changed := false + for _, sm := range s.store { + for _, dl := range sm { + if dl.size > 0 { + r := dl.removeFirst() + if r > 0 { + changed = true + s.nBytes -= r + } + } + } + } + if !changed { + panic("no progress during purge") + } + } + s.validate() +} + +// validate checks that the store's data structures are valid. +// It must be called with s.mu held. +func (s *MemoryEventStore) validate() { + if !validateMemoryEventStore { + return + } + // Check that we're accounting for the size correctly. + n := 0 + for _, sm := range s.store { + for _, dl := range sm { + for _, d := range dl.data { + n += len(d) + } + } + } + if n != s.nBytes { + panic("sizes don't add up") + } +} + +// debugString returns a string containing the state of s. +// Used in tests. +func (s *MemoryEventStore) debugString() string { + s.mu.Lock() + defer s.mu.Unlock() + var b strings.Builder + for i, sess := range slices.Sorted(maps.Keys(s.store)) { + if i > 0 { + fmt.Fprintf(&b, "; ") + } + sm := s.store[sess] + for i, sid := range slices.Sorted(maps.Keys(sm)) { + if i > 0 { + fmt.Fprintf(&b, "; ") + } + dl := sm[sid] + fmt.Fprintf(&b, "%s %s first=%d", sess, sid, dl.first) + for _, d := range dl.data { + fmt.Fprintf(&b, " %s", d) + } + } + } + return b.String() +} diff --git a/mcp/dsx-exchange-mcp/vendor/github.com/modelcontextprotocol/go-sdk/mcp/features.go b/mcp/dsx-exchange-mcp/vendor/github.com/modelcontextprotocol/go-sdk/mcp/features.go new file mode 100644 index 0000000..438370f --- /dev/null +++ b/mcp/dsx-exchange-mcp/vendor/github.com/modelcontextprotocol/go-sdk/mcp/features.go @@ -0,0 +1,114 @@ +// Copyright 2025 The Go MCP SDK Authors. All rights reserved. +// Use of this source code is governed by an MIT-style +// license that can be found in the LICENSE file. + +package mcp + +import ( + "iter" + "maps" + "slices" +) + +// This file contains implementations that are common to all features. +// A feature is an item provided to a peer. In the 2025-03-26 spec, +// the features are prompt, tool, resource and root. + +// A featureSet is a collection of features of type T. +// Every feature has a unique ID, and the spec never mentions +// an ordering for the List calls, so what it calls a "list" is actually a set. +// +// An alternative implementation would use an ordered map, but that's probably +// not necessary as adds and removes are rare, and usually batched. +type featureSet[T any] struct { + uniqueID func(T) string + features map[string]T + sortedKeys []string // lazily computed; nil after add or remove +} + +// newFeatureSet creates a new featureSet for features of type T. +// The argument function should return the unique ID for a single feature. +func newFeatureSet[T any](uniqueIDFunc func(T) string) *featureSet[T] { + return &featureSet[T]{ + uniqueID: uniqueIDFunc, + features: make(map[string]T), + } +} + +// add adds each feature to the set if it is not present, +// or replaces an existing feature. +func (s *featureSet[T]) add(fs ...T) { + for _, f := range fs { + s.features[s.uniqueID(f)] = f + } + s.sortedKeys = nil +} + +// remove removes all features with the given uids from the set if present, +// and returns whether any were removed. +// It is not an error to remove a nonexistent feature. +func (s *featureSet[T]) remove(uids ...string) bool { + changed := false + for _, uid := range uids { + if _, ok := s.features[uid]; ok { + changed = true + delete(s.features, uid) + } + } + if changed { + s.sortedKeys = nil + } + return changed +} + +// get returns the feature with the given uid. +// If there is none, it returns zero, false. +func (s *featureSet[T]) get(uid string) (T, bool) { + t, ok := s.features[uid] + return t, ok +} + +// len returns the number of features in the set. +func (s *featureSet[T]) len() int { return len(s.features) } + +// all returns an iterator over of all the features in the set +// sorted by unique ID. +func (s *featureSet[T]) all() iter.Seq[T] { + s.sortKeys() + return func(yield func(T) bool) { + s.yieldFrom(0, yield) + } +} + +// above returns an iterator over features in the set whose unique IDs are +// greater than `uid`, in ascending ID order. +func (s *featureSet[T]) above(uid string) iter.Seq[T] { + s.sortKeys() + index, found := slices.BinarySearch(s.sortedKeys, uid) + if found { + index++ + } + return func(yield func(T) bool) { + s.yieldFrom(index, yield) + } +} + +// sortKeys is a helper that maintains a sorted list of feature IDs. It +// computes this list lazily upon its first call after a modification, or +// if it's nil. +func (s *featureSet[T]) sortKeys() { + if s.sortedKeys != nil { + return + } + s.sortedKeys = slices.Sorted(maps.Keys(s.features)) +} + +// yieldFrom is a helper that iterates over the features in the set, +// starting at the given index, and calls the yield function for each one. +func (s *featureSet[T]) yieldFrom(index int, yield func(T) bool) { + for i := index; i < len(s.sortedKeys); i++ { + if !yield(s.features[s.sortedKeys[i]]) { + return + } + } +} diff --git a/mcp/dsx-exchange-mcp/vendor/github.com/modelcontextprotocol/go-sdk/mcp/logging.go b/mcp/dsx-exchange-mcp/vendor/github.com/modelcontextprotocol/go-sdk/mcp/logging.go new file mode 100644 index 0000000..b1bd82b --- /dev/null +++ b/mcp/dsx-exchange-mcp/vendor/github.com/modelcontextprotocol/go-sdk/mcp/logging.go @@ -0,0 +1,201 @@ +// Copyright 2025 The Go MCP SDK Authors. All rights reserved. +// Use of this source code is governed by an MIT-style +// license that can be found in the LICENSE file. + +package mcp + +import ( + "bytes" + "cmp" + "context" + "encoding/json" + "log/slog" + "slices" + "sync" + "time" +) + +// Logging levels. +const ( + LevelDebug = slog.LevelDebug + LevelInfo = slog.LevelInfo + LevelNotice = (slog.LevelInfo + slog.LevelWarn) / 2 + LevelWarning = slog.LevelWarn + LevelError = slog.LevelError + LevelCritical = slog.LevelError + 4 + LevelAlert = slog.LevelError + 8 + LevelEmergency = slog.LevelError + 12 +) + +var slogToMCP = map[slog.Level]LoggingLevel{ + LevelDebug: "debug", + LevelInfo: "info", + LevelNotice: "notice", + LevelWarning: "warning", + LevelError: "error", + LevelCritical: "critical", + LevelAlert: "alert", + LevelEmergency: "emergency", +} + +var mcpToSlog = make(map[LoggingLevel]slog.Level) + +func init() { + for sl, ml := range slogToMCP { + mcpToSlog[ml] = sl + } +} + +func slogLevelToMCP(sl slog.Level) LoggingLevel { + if ml, ok := slogToMCP[sl]; ok { + return ml + } + return "debug" // for lack of a better idea +} + +func mcpLevelToSlog(ll LoggingLevel) slog.Level { + if sl, ok := mcpToSlog[ll]; ok { + return sl + } + // TODO: is there a better default? + return LevelDebug +} + +// compareLevels behaves like [cmp.Compare] for [LoggingLevel]s. +func compareLevels(l1, l2 LoggingLevel) int { + return cmp.Compare(mcpLevelToSlog(l1), mcpLevelToSlog(l2)) +} + +// LoggingHandlerOptions are options for a LoggingHandler. +type LoggingHandlerOptions struct { + // The value for the "logger" field of logging notifications. + LoggerName string + // Limits the rate at which log messages are sent. + // Excess messages are dropped. + // If zero, there is no rate limiting. + MinInterval time.Duration +} + +// A LoggingHandler is a [slog.Handler] for MCP. +type LoggingHandler struct { + opts LoggingHandlerOptions + ss *ServerSession + // Ensures that the buffer reset is atomic with the write (see Handle). + // A pointer so that clones share the mutex. See + // https://github.com/golang/example/blob/master/slog-handler-guide/README.md#getting-the-mutex-right. + mu *sync.Mutex + lastMessageSent time.Time // for rate-limiting + buf *bytes.Buffer + handler slog.Handler +} + +// ensureLogger returns l if non-nil, otherwise a discard logger. +func ensureLogger(l *slog.Logger) *slog.Logger { + if l != nil { + return l + } + return slog.New(slog.DiscardHandler) +} + +// NewLoggingHandler creates a [LoggingHandler] that logs to the given [ServerSession] using a +// [slog.JSONHandler]. +func NewLoggingHandler(ss *ServerSession, opts *LoggingHandlerOptions) *LoggingHandler { + var buf bytes.Buffer + jsonHandler := slog.NewJSONHandler(&buf, &slog.HandlerOptions{ + ReplaceAttr: func(_ []string, a slog.Attr) slog.Attr { + // Remove level: it appears in LoggingMessageParams. + if a.Key == slog.LevelKey { + return slog.Attr{} + } + return a + }, + }) + lh := &LoggingHandler{ + ss: ss, + mu: new(sync.Mutex), + buf: &buf, + handler: jsonHandler, + } + if opts != nil { + lh.opts = *opts + } + return lh +} + +// Enabled implements [slog.Handler.Enabled] by comparing level to the [ServerSession]'s level. +func (h *LoggingHandler) Enabled(ctx context.Context, level slog.Level) bool { + // This is also checked in ServerSession.LoggingMessage, so checking it here + // is just an optimization that skips building the JSON. + h.ss.mu.Lock() + mcpLevel := h.ss.state.LogLevel + h.ss.mu.Unlock() + return level >= mcpLevelToSlog(mcpLevel) +} + +// WithAttrs implements [slog.Handler.WithAttrs]. +func (h *LoggingHandler) WithAttrs(as []slog.Attr) slog.Handler { + h2 := *h + h2.handler = h.handler.WithAttrs(as) + return &h2 +} + +// WithGroup implements [slog.Handler.WithGroup]. +func (h *LoggingHandler) WithGroup(name string) slog.Handler { + h2 := *h + h2.handler = h.handler.WithGroup(name) + return &h2 +} + +// Handle implements [slog.Handler.Handle] by writing the Record to a JSONHandler, +// then calling [ServerSession.LoggingMessage] with the result. +func (h *LoggingHandler) Handle(ctx context.Context, r slog.Record) error { + err := h.handle(ctx, r) + // TODO(jba): find a way to surface the error. + // The return value will probably be ignored. + return err +} + +func (h *LoggingHandler) handle(ctx context.Context, r slog.Record) error { + // Observe the rate limit. + // TODO(jba): use golang.org/x/time/rate. + h.mu.Lock() + skip := time.Since(h.lastMessageSent) < h.opts.MinInterval + h.mu.Unlock() + if skip { + return nil + } + + var err error + var data json.RawMessage + // Make the buffer reset atomic with the record write. + // We are careful here in the unlikely event that the handler panics. + // We don't want to hold the lock for the entire function, because Notify is + // an I/O operation. + // This can result in out-of-order delivery. + func() { + h.mu.Lock() + defer h.mu.Unlock() + h.buf.Reset() + err = h.handler.Handle(ctx, r) + // Clone the buffer as Bytes() references the internal buffer. + data = json.RawMessage(slices.Clone(h.buf.Bytes())) + }() + if err != nil { + return err + } + + h.mu.Lock() + h.lastMessageSent = time.Now() + h.mu.Unlock() + + params := &LoggingMessageParams{ + Logger: h.opts.LoggerName, + Level: slogLevelToMCP(r.Level), + Data: data, + } + // We pass the argument context to Notify, even though slog.Handler.Handle's + // documentation says not to. + // In this case logging is a service to clients, not a means for debugging the + // server, so we want to cancel the log message. + return h.ss.Log(ctx, params) +} diff --git a/mcp/dsx-exchange-mcp/vendor/github.com/modelcontextprotocol/go-sdk/mcp/mcp.go b/mcp/dsx-exchange-mcp/vendor/github.com/modelcontextprotocol/go-sdk/mcp/mcp.go new file mode 100644 index 0000000..56e950b --- /dev/null +++ b/mcp/dsx-exchange-mcp/vendor/github.com/modelcontextprotocol/go-sdk/mcp/mcp.go @@ -0,0 +1,88 @@ +// Copyright 2025 The Go MCP SDK Authors. All rights reserved. +// Use of this source code is governed by an MIT-style +// license that can be found in the LICENSE file. + +// The mcp package provides an SDK for writing model context protocol clients +// and servers. +// +// To get started, create either a [Client] or [Server], add features to it +// using `AddXXX` functions, and connect it to a peer using a [Transport]. +// +// For example, to run a simple server on the [StdioTransport]: +// +// server := mcp.NewServer(&mcp.Implementation{Name: "greeter"}, nil) +// +// // Using the generic AddTool automatically populates the the input and output +// // schema of the tool. +// type args struct { +// Name string `json:"name" jsonschema:"the person to greet"` +// } +// mcp.AddTool(server, &mcp.Tool{ +// Name: "greet", +// Description: "say hi", +// }, func(ctx context.Context, req *mcp.CallToolRequest, args args) (*mcp.CallToolResult, any, error) { +// return &mcp.CallToolResult{ +// Content: []mcp.Content{ +// &mcp.TextContent{Text: "Hi " + args.Name}, +// }, +// }, nil, nil +// }) +// +// // Run the server on the stdio transport. +// if err := server.Run(context.Background(), &mcp.StdioTransport{}); err != nil { +// log.Printf("Server failed: %v", err) +// } +// +// To connect to this server, use the [CommandTransport]: +// +// client := mcp.NewClient(&mcp.Implementation{Name: "mcp-client", Version: "v1.0.0"}, nil) +// transport := &mcp.CommandTransport{Command: exec.Command("myserver")} +// session, err := client.Connect(ctx, transport, nil) +// if err != nil { +// log.Fatal(err) +// } +// defer session.Close() +// +// params := &mcp.CallToolParams{ +// Name: "greet", +// Arguments: map[string]any{"name": "you"}, +// } +// res, err := session.CallTool(ctx, params) +// if err != nil { +// log.Fatalf("CallTool failed: %v", err) +// } +// +// # Clients, servers, and sessions +// +// In this SDK, both a [Client] and [Server] may handle many concurrent +// connections. Each time a client or server is connected to a peer using a +// [Transport], it creates a new session (either a [ClientSession] or +// [ServerSession]): +// +// Client Server +// ⇅ (jsonrpc2) ⇅ +// ClientSession ⇄ Client Transport ⇄ Server Transport ⇄ ServerSession +// +// The session types expose an API to interact with its peer. For example, +// [ClientSession.CallTool] or [ServerSession.ListRoots]. +// +// # Adding features +// +// Add MCP servers to your Client or Server using AddXXX methods (for example +// [Client.AddRoot] or [Server.AddPrompt]). If any peers are connected when +// AddXXX is called, they will receive a corresponding change notification +// (for example notifications/roots/list_changed). +// +// Adding tools is special: tools may be bound to ordinary Go functions by +// using the top-level generic [AddTool] function, which allows specifying an +// input and output type. When AddTool is used, the tool's input schema and +// output schema are automatically populated, and inputs are automatically +// validated. As a special case, if the output type is 'any', no output schema +// is generated. +// +// func double(_ context.Context, _ *mcp.CallToolRequest, in In) (*mcp.CallToolResult, Out, error) { +// return nil, Out{Answer: 2*in.Number}, nil +// } +// ... +// mcp.AddTool(server, &mcp.Tool{Name: "double"}, double) +package mcp diff --git a/mcp/dsx-exchange-mcp/vendor/github.com/modelcontextprotocol/go-sdk/mcp/prompt.go b/mcp/dsx-exchange-mcp/vendor/github.com/modelcontextprotocol/go-sdk/mcp/prompt.go new file mode 100644 index 0000000..62f38a3 --- /dev/null +++ b/mcp/dsx-exchange-mcp/vendor/github.com/modelcontextprotocol/go-sdk/mcp/prompt.go @@ -0,0 +1,17 @@ +// Copyright 2025 The Go MCP SDK Authors. All rights reserved. +// Use of this source code is governed by an MIT-style +// license that can be found in the LICENSE file. + +package mcp + +import ( + "context" +) + +// A PromptHandler handles a call to prompts/get. +type PromptHandler func(context.Context, *GetPromptRequest) (*GetPromptResult, error) + +type serverPrompt struct { + prompt *Prompt + handler PromptHandler +} diff --git a/mcp/dsx-exchange-mcp/vendor/github.com/modelcontextprotocol/go-sdk/mcp/protocol.go b/mcp/dsx-exchange-mcp/vendor/github.com/modelcontextprotocol/go-sdk/mcp/protocol.go new file mode 100644 index 0000000..837ce78 --- /dev/null +++ b/mcp/dsx-exchange-mcp/vendor/github.com/modelcontextprotocol/go-sdk/mcp/protocol.go @@ -0,0 +1,1622 @@ +// Copyright 2025 The Go MCP SDK Authors. All rights reserved. +// Use of this source code is governed by an MIT-style +// license that can be found in the LICENSE file. + +package mcp + +// Protocol types for version 2025-06-18. +// To see the schema changes from the previous version, run: +// +// prefix=https://raw.githubusercontent.com/modelcontextprotocol/modelcontextprotocol/refs/heads/main/schema +// sdiff -l <(curl $prefix/2025-03-26/schema.ts) <(curl $prefix/2025/06-18/schema.ts) + +import ( + "encoding/json" + "fmt" + "maps" + + internaljson "github.com/modelcontextprotocol/go-sdk/internal/json" +) + +// Optional annotations for the client. The client can use annotations to inform +// how objects are used or displayed. +type Annotations struct { + // Describes who the intended customer of this object or data is. + // + // It can include multiple entries to indicate content useful for multiple + // audiences (e.g., []Role{"user", "assistant"}). + Audience []Role `json:"audience,omitempty"` + // The moment the resource was last modified, as an ISO 8601 formatted string. + // + // Should be an ISO 8601 formatted string (e.g., "2025-01-12T15:00:58Z"). + // + // Examples: last activity timestamp in an open file, timestamp when the + // resource was attached, etc. + LastModified string `json:"lastModified,omitempty"` + // Describes how important this data is for operating the server. + // + // A value of 1 means "most important," and indicates that the data is + // effectively required, while 0 means "least important," and indicates that the + // data is entirely optional. + Priority float64 `json:"priority,omitempty"` +} + +// CallToolParams is used by clients to call a tool. +type CallToolParams struct { + // Meta is reserved by the protocol to allow clients and servers to + // attach additional metadata to their responses. + Meta `json:"_meta,omitempty"` + // Name is the name of the tool to call. + Name string `json:"name"` + // Arguments holds the tool arguments. It can hold any value that can be + // marshaled to JSON. + Arguments any `json:"arguments,omitempty"` +} + +// CallToolParamsRaw is passed to tool handlers on the server. Its arguments +// are not yet unmarshaled (hence "raw"), so that the handlers can perform +// unmarshaling themselves. +type CallToolParamsRaw struct { + // This property is reserved by the protocol to allow clients and servers to + // attach additional metadata to their responses. + Meta `json:"_meta,omitempty"` + // Name is the name of the tool being called. + Name string `json:"name"` + // Arguments is the raw arguments received over the wire from the client. It + // is the responsibility of the tool handler to unmarshal and validate the + // Arguments (see [AddTool]). + Arguments json.RawMessage `json:"arguments,omitempty"` +} + +// A CallToolResult is the server's response to a tool call. +// +// The [ToolHandler] and [ToolHandlerFor] handler functions return this result, +// though [ToolHandlerFor] populates much of it automatically as documented at +// each field. +type CallToolResult struct { + // This property is reserved by the protocol to allow clients and servers to + // attach additional metadata to their responses. + Meta `json:"_meta,omitempty"` + + // A list of content objects that represent the unstructured result of the tool + // call. + // + // When using a [ToolHandlerFor] with structured output, if Content is unset + // it will be populated with JSON text content corresponding to the + // structured output value. + Content []Content `json:"content"` + + // StructuredContent is an optional value that represents the structured + // result of the tool call. It must marshal to a JSON object. + // + // When using a [ToolHandlerFor] with structured output, you should not + // populate this field. It will be automatically populated with the typed Out + // value. + StructuredContent any `json:"structuredContent,omitempty"` + + // IsError reports whether the tool call ended in an error. + // + // If not set, this is assumed to be false (the call was successful). + // + // Any errors that originate from the tool should be reported inside the + // Content field, with IsError set to true, not as an MCP protocol-level + // error response. Otherwise, the LLM would not be able to see that an error + // occurred and self-correct. + // + // However, any errors in finding the tool, an error indicating that the + // server does not support tool calls, or any other exceptional conditions, + // should be reported as an MCP error response. + // + // When using a [ToolHandlerFor], this field is automatically set when the + // tool handler returns an error, and the error string is included as text in + // the Content field. + IsError bool `json:"isError,omitempty"` + + // The error passed to setError, if any. + // It is not marshaled, and therefore it is only visible on the server. + // Its only use is in server sending middleware, where it can be accessed + // with getError. + err error +} + +// SetError sets the error for the tool result and populates the Content field +// with the error text. It also sets IsError to true. +func (r *CallToolResult) SetError(err error) { + r.Content = []Content{&TextContent{Text: err.Error()}} + r.IsError = true + r.err = err +} + +// GetError returns the error set with SetError, or nil if none. +// This function always returns nil on clients. +func (r *CallToolResult) GetError() error { + return r.err +} + +func (*CallToolResult) isResult() {} + +// UnmarshalJSON handles the unmarshalling of content into the Content +// interface. +func (x *CallToolResult) UnmarshalJSON(data []byte) error { + type res CallToolResult // avoid recursion + var wire struct { + res + Content []*wireContent `json:"content"` + } + if err := internaljson.Unmarshal(data, &wire); err != nil { + return err + } + var err error + if wire.res.Content, err = contentsFromWire(wire.Content, nil); err != nil { + return err + } + *x = CallToolResult(wire.res) + return nil +} + +func (x *CallToolParams) isParams() {} +func (x *CallToolParams) GetProgressToken() any { return getProgressToken(x) } +func (x *CallToolParams) SetProgressToken(t any) { setProgressToken(x, t) } + +func (x *CallToolParamsRaw) isParams() {} +func (x *CallToolParamsRaw) GetProgressToken() any { return getProgressToken(x) } +func (x *CallToolParamsRaw) SetProgressToken(t any) { setProgressToken(x, t) } + +type CancelledParams struct { + // This property is reserved by the protocol to allow clients and servers to + // attach additional metadata to their responses. + Meta `json:"_meta,omitempty"` + // An optional string describing the reason for the cancellation. This may be + // logged or presented to the user. + Reason string `json:"reason,omitempty"` + // The ID of the request to cancel. + // + // This must correspond to the ID of a request previously issued in the same + // direction. + RequestID any `json:"requestId"` +} + +func (x *CancelledParams) isParams() {} +func (x *CancelledParams) GetProgressToken() any { return getProgressToken(x) } +func (x *CancelledParams) SetProgressToken(t any) { setProgressToken(x, t) } + +// RootCapabilities describes a client's support for roots. +type RootCapabilities struct { + // ListChanged reports whether the client supports notifications for + // changes to the roots list. + ListChanged bool `json:"listChanged,omitempty"` +} + +// Capabilities a client may support. Known capabilities are defined here, in +// this schema, but this is not a closed set: any client can define its own, +// additional capabilities. +type ClientCapabilities struct { + // NOTE: any addition to ClientCapabilities must also be reflected in + // [ClientCapabilities.clone]. + + // Experimental reports non-standard capabilities that the client supports. + // The caller should not modify the map after assigning it. + Experimental map[string]any `json:"experimental,omitempty"` + // Extensions reports extensions that the client supports. + // Keys are extension identifiers in "{vendor-prefix}/{extension-name}" format. + // Values are per-extension settings objects; use [ClientCapabilities.AddExtension] + // to ensure nil settings are normalized to empty objects. + // The caller should not modify the map or its values after assigning it. + Extensions map[string]any `json:"extensions,omitempty"` + // Roots describes the client's support for roots. + // + // Deprecated: use RootsV2. As described in #607, Roots should have been a + // pointer to a RootCapabilities value. Roots will be continue to be + // populated, but any new fields will only be added in the RootsV2 field. + Roots struct { + // ListChanged reports whether the client supports notifications for + // changes to the roots list. + ListChanged bool `json:"listChanged,omitempty"` + } `json:"roots,omitempty"` + // RootsV2 is present if the client supports roots. When capabilities are explicitly configured via [ClientOptions.Capabilities] + RootsV2 *RootCapabilities `json:"-"` + // Sampling is present if the client supports sampling from an LLM. + Sampling *SamplingCapabilities `json:"sampling,omitempty"` + // Elicitation is present if the client supports elicitation from the server. + Elicitation *ElicitationCapabilities `json:"elicitation,omitempty"` +} + +// AddExtension adds an extension with the given name and settings. +// If settings is nil, an empty map is used to ensure valid JSON serialization +// (the spec requires an object, not null). +// The settings map should not be modified after the call. +func (c *ClientCapabilities) AddExtension(name string, settings map[string]any) { + if c.Extensions == nil { + c.Extensions = make(map[string]any) + } + if settings == nil { + settings = map[string]any{} + } + c.Extensions[name] = settings +} + +// clone returns a copy of the ClientCapabilities. +// Values in the Extensions and Experimental maps are shallow-copied. +func (c *ClientCapabilities) clone() *ClientCapabilities { + cp := *c + cp.Experimental = maps.Clone(c.Experimental) + cp.Extensions = maps.Clone(c.Extensions) + cp.RootsV2 = shallowClone(c.RootsV2) + if c.Sampling != nil { + x := *c.Sampling + x.Tools = shallowClone(c.Sampling.Tools) + x.Context = shallowClone(c.Sampling.Context) + cp.Sampling = &x + } + if c.Elicitation != nil { + x := *c.Elicitation + x.Form = shallowClone(c.Elicitation.Form) + x.URL = shallowClone(c.Elicitation.URL) + cp.Elicitation = &x + } + return &cp +} + +// shallowClone returns a shallow clone of *p, or nil if p is nil. +func shallowClone[T any](p *T) *T { + if p == nil { + return nil + } + x := *p + return &x +} + +func (c *ClientCapabilities) toV2() *clientCapabilitiesV2 { + return &clientCapabilitiesV2{ + ClientCapabilities: *c, + Roots: c.RootsV2, + } +} + +// clientCapabilitiesV2 is a version of ClientCapabilities that fixes the bug +// described in #607: Roots should have been a pointer to value type +// RootCapabilities. +type clientCapabilitiesV2 struct { + ClientCapabilities + Roots *RootCapabilities `json:"roots,omitempty"` +} + +func (c *clientCapabilitiesV2) toV1() *ClientCapabilities { + caps := c.ClientCapabilities + caps.RootsV2 = c.Roots + // Sync Roots from RootsV2 for backward compatibility (#607). + if caps.RootsV2 != nil { + caps.Roots = *caps.RootsV2 + } + return &caps +} + +type CompleteParamsArgument struct { + // The name of the argument + Name string `json:"name"` + // The value of the argument to use for completion matching. + Value string `json:"value"` +} + +// CompleteContext represents additional, optional context for completions. +type CompleteContext struct { + // Previously-resolved variables in a URI template or prompt. + Arguments map[string]string `json:"arguments,omitempty"` +} + +// CompleteReference represents a completion reference type (ref/prompt ref/resource). +// The Type field determines which other fields are relevant. +type CompleteReference struct { + Type string `json:"type"` + // Name is relevant when Type is "ref/prompt". + Name string `json:"name,omitempty"` + // URI is relevant when Type is "ref/resource". + URI string `json:"uri,omitempty"` +} + +func (r *CompleteReference) UnmarshalJSON(data []byte) error { + type wireCompleteReference CompleteReference // for naive unmarshaling + var r2 wireCompleteReference + if err := internaljson.Unmarshal(data, &r2); err != nil { + return err + } + switch r2.Type { + case "ref/prompt", "ref/resource": + if r2.Type == "ref/prompt" && r2.URI != "" { + return fmt.Errorf("reference of type %q must not have a URI set", r2.Type) + } + if r2.Type == "ref/resource" && r2.Name != "" { + return fmt.Errorf("reference of type %q must not have a Name set", r2.Type) + } + default: + return fmt.Errorf("unrecognized content type %q", r2.Type) + } + *r = CompleteReference(r2) + return nil +} + +func (r *CompleteReference) MarshalJSON() ([]byte, error) { + // Validation for marshalling: ensure consistency before converting to JSON. + switch r.Type { + case "ref/prompt": + if r.URI != "" { + return nil, fmt.Errorf("reference of type %q must not have a URI set for marshalling", r.Type) + } + case "ref/resource": + if r.Name != "" { + return nil, fmt.Errorf("reference of type %q must not have a Name set for marshalling", r.Type) + } + default: + return nil, fmt.Errorf("unrecognized reference type %q for marshalling", r.Type) + } + + type wireReference CompleteReference + return json.Marshal(wireReference(*r)) +} + +type CompleteParams struct { + // This property is reserved by the protocol to allow clients and servers to + // attach additional metadata to their responses. + Meta `json:"_meta,omitempty"` + // The argument's information + Argument CompleteParamsArgument `json:"argument"` + Context *CompleteContext `json:"context,omitempty"` + Ref *CompleteReference `json:"ref"` +} + +func (*CompleteParams) isParams() {} + +type CompletionResultDetails struct { + HasMore bool `json:"hasMore,omitempty"` + Total int `json:"total,omitempty"` + Values []string `json:"values"` +} + +// The server's response to a completion/complete request +type CompleteResult struct { + // This property is reserved by the protocol to allow clients and servers to + // attach additional metadata to their responses. + Meta `json:"_meta,omitempty"` + Completion CompletionResultDetails `json:"completion"` +} + +func (*CompleteResult) isResult() {} + +type CreateMessageParams struct { + // This property is reserved by the protocol to allow clients and servers to + // attach additional metadata to their responses. + Meta `json:"_meta,omitempty"` + // A request to include context from one or more MCP servers (including the + // caller), to be attached to the prompt. The client may ignore this request. + // + // The default is "none". Values "thisServer" and + // "allServers" are soft-deprecated. Servers SHOULD only use these values if + // the client declares ClientCapabilities.sampling.context. These values may + // be removed in future spec releases. + IncludeContext string `json:"includeContext,omitempty"` + // The maximum number of tokens to sample, as requested by the server. The + // client may choose to sample fewer tokens than requested. + MaxTokens int64 `json:"maxTokens"` + Messages []*SamplingMessage `json:"messages"` + // Optional metadata to pass through to the LLM provider. The format of this + // metadata is provider-specific. + Metadata any `json:"metadata,omitempty"` + // The server's preferences for which model to select. The client may ignore + // these preferences. + ModelPreferences *ModelPreferences `json:"modelPreferences,omitempty"` + StopSequences []string `json:"stopSequences,omitempty"` + // An optional system prompt the server wants to use for sampling. The client + // may modify or omit this prompt. + SystemPrompt string `json:"systemPrompt,omitempty"` + Temperature float64 `json:"temperature,omitempty"` +} + +func (x *CreateMessageParams) isParams() {} +func (x *CreateMessageParams) GetProgressToken() any { return getProgressToken(x) } +func (x *CreateMessageParams) SetProgressToken(t any) { setProgressToken(x, t) } + +// CreateMessageWithToolsParams is a sampling request that includes tools. +// It extends the basic [CreateMessageParams] fields with tools, tool choice, +// and messages that support array content (for parallel tool calls). +// +// Use with [ServerSession.CreateMessageWithTools]. +type CreateMessageWithToolsParams struct { + Meta `json:"_meta,omitempty"` + IncludeContext string `json:"includeContext,omitempty"` + MaxTokens int64 `json:"maxTokens"` + // Messages supports array content for tool_use and tool_result blocks. + Messages []*SamplingMessageV2 `json:"messages"` + Metadata any `json:"metadata,omitempty"` + ModelPreferences *ModelPreferences `json:"modelPreferences,omitempty"` + StopSequences []string `json:"stopSequences,omitempty"` + SystemPrompt string `json:"systemPrompt,omitempty"` + Temperature float64 `json:"temperature,omitempty"` + // Tools is the list of tools available for the model to use. + Tools []*Tool `json:"tools,omitempty"` + // ToolChoice controls how the model should use tools. + ToolChoice *ToolChoice `json:"toolChoice,omitempty"` +} + +func (x *CreateMessageWithToolsParams) isParams() {} +func (x *CreateMessageWithToolsParams) GetProgressToken() any { return getProgressToken(x) } +func (x *CreateMessageWithToolsParams) SetProgressToken(t any) { setProgressToken(x, t) } + +// toBase converts to CreateMessageParams by taking the content block from each +// message. Tools and ToolChoice are dropped. Returns an error if any message +// has multiple content blocks, since SamplingMessage only supports one. +func (p *CreateMessageWithToolsParams) toBase() (*CreateMessageParams, error) { + var msgs []*SamplingMessage + for _, m := range p.Messages { + if len(m.Content) > 1 { + return nil, fmt.Errorf("message has %d content blocks; use CreateMessageWithToolsHandler to support multiple content", len(m.Content)) + } + var content Content + if len(m.Content) > 0 { + content = m.Content[0] + } + msgs = append(msgs, &SamplingMessage{Content: content, Role: m.Role}) + } + return &CreateMessageParams{ + Meta: p.Meta, + IncludeContext: p.IncludeContext, + MaxTokens: p.MaxTokens, + Messages: msgs, + Metadata: p.Metadata, + ModelPreferences: p.ModelPreferences, + StopSequences: p.StopSequences, + SystemPrompt: p.SystemPrompt, + Temperature: p.Temperature, + }, nil +} + +// SamplingMessageV2 describes a message issued to or received from an +// LLM API, supporting array content for parallel tool calls. The "V2" refers +// to the 2025-11-25 spec, which changed content from a single block to +// single-or-array. In v2 of the SDK, this will replace [SamplingMessage]. +// +// When marshaling, a single-element Content slice is marshaled as a single +// object for compatibility with pre-2025-11-25 implementations. When +// unmarshaling, a single JSON content object is accepted and wrapped in a +// one-element slice. +type SamplingMessageV2 struct { + Content []Content `json:"content"` + Role Role `json:"role"` +} + +var samplingWithToolsAllow = map[string]bool{ + "text": true, "image": true, "audio": true, + "tool_use": true, "tool_result": true, +} + +// MarshalJSON marshals the message. A single-element Content slice is marshaled +// as a single object for backward compatibility. +func (m *SamplingMessageV2) MarshalJSON() ([]byte, error) { + if len(m.Content) == 1 { + return json.Marshal(&SamplingMessage{Content: m.Content[0], Role: m.Role}) + } + type msg SamplingMessageV2 // avoid recursion + return json.Marshal((*msg)(m)) +} + +func (m *SamplingMessageV2) UnmarshalJSON(data []byte) error { + type msg SamplingMessageV2 // avoid recursion + var wire struct { + msg + Content json.RawMessage `json:"content"` + } + if err := internaljson.Unmarshal(data, &wire); err != nil { + return err + } + var err error + if wire.msg.Content, err = unmarshalContent(wire.Content, samplingWithToolsAllow); err != nil { + return err + } + *m = SamplingMessageV2(wire.msg) + return nil +} + +// The client's response to a sampling/create_message request from the server. +// The client should inform the user before returning the sampled message, to +// allow them to inspect the response (human in the loop) and decide whether to +// allow the server to see it. +type CreateMessageResult struct { + // This property is reserved by the protocol to allow clients and servers to + // attach additional metadata to their responses. + Meta `json:"_meta,omitempty"` + Content Content `json:"content"` + // The name of the model that generated the message. + Model string `json:"model"` + Role Role `json:"role"` + // The reason why sampling stopped, if known. + // + // Standard values: + // - "endTurn": natural end of the assistant's turn + // - "stopSequence": a stop sequence was encountered + // - "maxTokens": reached the maximum token limit + // - "toolUse": the model wants to use one or more tools + StopReason string `json:"stopReason,omitempty"` +} + +func (*CreateMessageResult) isResult() {} +func (r *CreateMessageResult) UnmarshalJSON(data []byte) error { + type result CreateMessageResult // avoid recursion + var wire struct { + result + Content *wireContent `json:"content"` + } + if err := internaljson.Unmarshal(data, &wire); err != nil { + return err + } + var err error + if wire.result.Content, err = contentFromWire(wire.Content, map[string]bool{"text": true, "image": true, "audio": true}); err != nil { + return err + } + *r = CreateMessageResult(wire.result) + return nil +} + +// CreateMessageWithToolsResult is the client's response to a +// sampling/create_message request that included tools. Content is a slice to +// support parallel tool calls (multiple tool_use blocks in one response). +// +// Use [ServerSession.CreateMessageWithTools] to send a sampling request with +// tools and receive this result type. +// +// When unmarshaling, a single JSON content object is accepted and wrapped in a +// one-element slice, for compatibility with clients that return a single block. +type CreateMessageWithToolsResult struct { + Meta `json:"_meta,omitempty"` + Content []Content `json:"content"` + Model string `json:"model"` + Role Role `json:"role"` + // The reason why sampling stopped. + // + // Standard values: "endTurn", "stopSequence", "maxTokens", "toolUse". + StopReason string `json:"stopReason,omitempty"` +} + +// createMessageWithToolsResultAllow lists content types valid in assistant responses. +// tool_result is excluded: it only appears in user messages. +var createMessageWithToolsResultAllow = map[string]bool{ + "text": true, "image": true, "audio": true, + "tool_use": true, +} + +func (*CreateMessageWithToolsResult) isResult() {} + +// MarshalJSON marshals the result. When Content has a single element, it is +// marshaled as a single object for compatibility with pre-2025-11-25 +// implementations that expect a single content block. +func (r *CreateMessageWithToolsResult) MarshalJSON() ([]byte, error) { + if len(r.Content) == 1 { + return json.Marshal(&CreateMessageResult{ + Meta: r.Meta, + Content: r.Content[0], + Model: r.Model, + Role: r.Role, + StopReason: r.StopReason, + }) + } + type result CreateMessageWithToolsResult // avoid recursion + return json.Marshal((*result)(r)) +} + +func (r *CreateMessageWithToolsResult) UnmarshalJSON(data []byte) error { + type result CreateMessageWithToolsResult // avoid recursion + var wire struct { + result + Content json.RawMessage `json:"content"` + } + if err := internaljson.Unmarshal(data, &wire); err != nil { + return err + } + var err error + if wire.result.Content, err = unmarshalContent(wire.Content, createMessageWithToolsResultAllow); err != nil { + return err + } + *r = CreateMessageWithToolsResult(wire.result) + return nil +} + +// toWithTools converts a CreateMessageResult to CreateMessageWithToolsResult. +func (r *CreateMessageResult) toWithTools() *CreateMessageWithToolsResult { + var content []Content + if r.Content != nil { + content = []Content{r.Content} + } + return &CreateMessageWithToolsResult{ + Meta: r.Meta, + Content: content, + Model: r.Model, + Role: r.Role, + StopReason: r.StopReason, + } +} + +type GetPromptParams struct { + // This property is reserved by the protocol to allow clients and servers to + // attach additional metadata to their responses. + Meta `json:"_meta,omitempty"` + // Arguments to use for templating the prompt. + Arguments map[string]string `json:"arguments,omitempty"` + // The name of the prompt or prompt template. + Name string `json:"name"` +} + +func (x *GetPromptParams) isParams() {} +func (x *GetPromptParams) GetProgressToken() any { return getProgressToken(x) } +func (x *GetPromptParams) SetProgressToken(t any) { setProgressToken(x, t) } + +// The server's response to a prompts/get request from the client. +type GetPromptResult struct { + // This property is reserved by the protocol to allow clients and servers to + // attach additional metadata to their responses. + Meta `json:"_meta,omitempty"` + // An optional description for the prompt. + Description string `json:"description,omitempty"` + Messages []*PromptMessage `json:"messages"` +} + +func (*GetPromptResult) isResult() {} + +// InitializeParams is sent by the client to initialize the session. +type InitializeParams struct { + // This property is reserved by the protocol to allow clients and servers to + // attach additional metadata to their responses. + Meta `json:"_meta,omitempty"` + // Capabilities describes the client's capabilities. + Capabilities *ClientCapabilities `json:"capabilities"` + // ClientInfo provides information about the client. + ClientInfo *Implementation `json:"clientInfo"` + // ProtocolVersion is the latest version of the Model Context Protocol that + // the client supports. + ProtocolVersion string `json:"protocolVersion"` +} + +func (p *InitializeParams) toV2() *initializeParamsV2 { + return &initializeParamsV2{ + InitializeParams: *p, + Capabilities: p.Capabilities.toV2(), + } +} + +// initializeParamsV2 works around the mistake in #607: Capabilities.Roots +// should have been a pointer. +type initializeParamsV2 struct { + InitializeParams + Capabilities *clientCapabilitiesV2 `json:"capabilities"` +} + +func (p *initializeParamsV2) toV1() *InitializeParams { + p1 := p.InitializeParams + if p.Capabilities != nil { + p1.Capabilities = p.Capabilities.toV1() + } + return &p1 +} + +func (x *InitializeParams) isParams() {} +func (x *InitializeParams) GetProgressToken() any { return getProgressToken(x) } +func (x *InitializeParams) SetProgressToken(t any) { setProgressToken(x, t) } + +// InitializeResult is sent by the server in response to an initialize request +// from the client. +type InitializeResult struct { + // This property is reserved by the protocol to allow clients and servers to + // attach additional metadata to their responses. + Meta `json:"_meta,omitempty"` + // Capabilities describes the server's capabilities. + Capabilities *ServerCapabilities `json:"capabilities"` + // Instructions describing how to use the server and its features. + // + // This can be used by clients to improve the LLM's understanding of available + // tools, resources, etc. It can be thought of like a "hint" to the model. For + // example, this information may be added to the system prompt. + Instructions string `json:"instructions,omitempty"` + // The version of the Model Context Protocol that the server wants to use. This + // may not match the version that the client requested. If the client cannot + // support this version, it must disconnect. + ProtocolVersion string `json:"protocolVersion"` + ServerInfo *Implementation `json:"serverInfo"` +} + +func (*InitializeResult) isResult() {} + +type InitializedParams struct { + // Meta is reserved by the protocol to allow clients and servers to attach + // additional metadata to their responses. + Meta `json:"_meta,omitempty"` +} + +func (x *InitializedParams) isParams() {} +func (x *InitializedParams) GetProgressToken() any { return getProgressToken(x) } +func (x *InitializedParams) SetProgressToken(t any) { setProgressToken(x, t) } + +type ListPromptsParams struct { + // This property is reserved by the protocol to allow clients and servers to + // attach additional metadata to their responses. + Meta `json:"_meta,omitempty"` + // An opaque token representing the current pagination position. If provided, + // the server should return results starting after this cursor. + Cursor string `json:"cursor,omitempty"` +} + +func (x *ListPromptsParams) isParams() {} +func (x *ListPromptsParams) GetProgressToken() any { return getProgressToken(x) } +func (x *ListPromptsParams) SetProgressToken(t any) { setProgressToken(x, t) } +func (x *ListPromptsParams) cursorPtr() *string { return &x.Cursor } + +// The server's response to a prompts/list request from the client. +type ListPromptsResult struct { + // This property is reserved by the protocol to allow clients and servers to + // attach additional metadata to their responses. + Meta `json:"_meta,omitempty"` + // An opaque token representing the pagination position after the last returned + // result. If present, there may be more results available. + NextCursor string `json:"nextCursor,omitempty"` + Prompts []*Prompt `json:"prompts"` +} + +func (x *ListPromptsResult) isResult() {} +func (x *ListPromptsResult) nextCursorPtr() *string { return &x.NextCursor } + +type ListResourceTemplatesParams struct { + // This property is reserved by the protocol to allow clients and servers to + // attach additional metadata to their responses. + Meta `json:"_meta,omitempty"` + // An opaque token representing the current pagination position. If provided, + // the server should return results starting after this cursor. + Cursor string `json:"cursor,omitempty"` +} + +func (x *ListResourceTemplatesParams) isParams() {} +func (x *ListResourceTemplatesParams) GetProgressToken() any { return getProgressToken(x) } +func (x *ListResourceTemplatesParams) SetProgressToken(t any) { setProgressToken(x, t) } +func (x *ListResourceTemplatesParams) cursorPtr() *string { return &x.Cursor } + +// The server's response to a resources/templates/list request from the client. +type ListResourceTemplatesResult struct { + // This property is reserved by the protocol to allow clients and servers to + // attach additional metadata to their responses. + Meta `json:"_meta,omitempty"` + // An opaque token representing the pagination position after the last returned + // result. If present, there may be more results available. + NextCursor string `json:"nextCursor,omitempty"` + ResourceTemplates []*ResourceTemplate `json:"resourceTemplates"` +} + +func (x *ListResourceTemplatesResult) isResult() {} +func (x *ListResourceTemplatesResult) nextCursorPtr() *string { return &x.NextCursor } + +type ListResourcesParams struct { + // This property is reserved by the protocol to allow clients and servers to + // attach additional metadata to their responses. + Meta `json:"_meta,omitempty"` + // An opaque token representing the current pagination position. If provided, + // the server should return results starting after this cursor. + Cursor string `json:"cursor,omitempty"` +} + +func (x *ListResourcesParams) isParams() {} +func (x *ListResourcesParams) GetProgressToken() any { return getProgressToken(x) } +func (x *ListResourcesParams) SetProgressToken(t any) { setProgressToken(x, t) } +func (x *ListResourcesParams) cursorPtr() *string { return &x.Cursor } + +// The server's response to a resources/list request from the client. +type ListResourcesResult struct { + // This property is reserved by the protocol to allow clients and servers to + // attach additional metadata to their responses. + Meta `json:"_meta,omitempty"` + // An opaque token representing the pagination position after the last returned + // result. If present, there may be more results available. + NextCursor string `json:"nextCursor,omitempty"` + Resources []*Resource `json:"resources"` +} + +func (x *ListResourcesResult) isResult() {} +func (x *ListResourcesResult) nextCursorPtr() *string { return &x.NextCursor } + +type ListRootsParams struct { + // This property is reserved by the protocol to allow clients and servers to + // attach additional metadata to their responses. + Meta `json:"_meta,omitempty"` +} + +func (x *ListRootsParams) isParams() {} +func (x *ListRootsParams) GetProgressToken() any { return getProgressToken(x) } +func (x *ListRootsParams) SetProgressToken(t any) { setProgressToken(x, t) } + +// The client's response to a roots/list request from the server. This result +// contains an array of Root objects, each representing a root directory or file +// that the server can operate on. +type ListRootsResult struct { + // This property is reserved by the protocol to allow clients and servers to + // attach additional metadata to their responses. + Meta `json:"_meta,omitempty"` + Roots []*Root `json:"roots"` +} + +func (*ListRootsResult) isResult() {} + +type ListToolsParams struct { + // This property is reserved by the protocol to allow clients and servers to + // attach additional metadata to their responses. + Meta `json:"_meta,omitempty"` + // An opaque token representing the current pagination position. If provided, + // the server should return results starting after this cursor. + Cursor string `json:"cursor,omitempty"` +} + +func (x *ListToolsParams) isParams() {} +func (x *ListToolsParams) GetProgressToken() any { return getProgressToken(x) } +func (x *ListToolsParams) SetProgressToken(t any) { setProgressToken(x, t) } +func (x *ListToolsParams) cursorPtr() *string { return &x.Cursor } + +// The server's response to a tools/list request from the client. +type ListToolsResult struct { + // This property is reserved by the protocol to allow clients and servers to + // attach additional metadata to their responses. + Meta `json:"_meta,omitempty"` + // An opaque token representing the pagination position after the last returned + // result. If present, there may be more results available. + NextCursor string `json:"nextCursor,omitempty"` + Tools []*Tool `json:"tools"` +} + +func (x *ListToolsResult) isResult() {} +func (x *ListToolsResult) nextCursorPtr() *string { return &x.NextCursor } + +// The severity of a log message. +// +// These map to syslog message severities, as specified in RFC-5424: +// https://datatracker.ietf.org/doc/html/rfc5424#section-6.2.1 +type LoggingLevel string + +type LoggingMessageParams struct { + // This property is reserved by the protocol to allow clients and servers to + // attach additional metadata to their responses. + Meta `json:"_meta,omitempty"` + // The data to be logged, such as a string message or an object. Any JSON + // serializable type is allowed here. + Data any `json:"data"` + // The severity of this log message. + Level LoggingLevel `json:"level"` + // An optional name of the logger issuing this message. + Logger string `json:"logger,omitempty"` +} + +func (x *LoggingMessageParams) isParams() {} +func (x *LoggingMessageParams) GetProgressToken() any { return getProgressToken(x) } +func (x *LoggingMessageParams) SetProgressToken(t any) { setProgressToken(x, t) } + +// Hints to use for model selection. +// +// Keys not declared here are currently left unspecified by the spec and are up +// to the client to interpret. +type ModelHint struct { + // A hint for a model name. + // + // The client should treat this as a substring of a model name; for example: - + // `claude-3-5-sonnet` should match `claude-3-5-sonnet-20241022` - `sonnet` + // should match `claude-3-5-sonnet-20241022`, `claude-3-sonnet-20240229`, etc. - + // `claude` should match any Claude model + // + // The client may also map the string to a different provider's model name or a + // different model family, as long as it fills a similar niche; for example: - + // `gemini-1.5-flash` could match `claude-3-haiku-20240307` + Name string `json:"name,omitempty"` +} + +// The server's preferences for model selection, requested of the client during +// sampling. +// +// Because LLMs can vary along multiple dimensions, choosing the "best" model is +// rarely straightforward. Different models excel in different areas—some are +// faster but less capable, others are more capable but more expensive, and so +// on. This interface allows servers to express their priorities across multiple +// dimensions to help clients make an appropriate selection for their use case. +// +// These preferences are always advisory. The client may ignore them. It is also +// up to the client to decide how to interpret these preferences and how to +// balance them against other considerations. +type ModelPreferences struct { + // How much to prioritize cost when selecting a model. A value of 0 means cost + // is not important, while a value of 1 means cost is the most important factor. + CostPriority float64 `json:"costPriority,omitempty"` + // Optional hints to use for model selection. + // + // If multiple hints are specified, the client must evaluate them in order (such + // that the first match is taken). + // + // The client should prioritize these hints over the numeric priorities, but may + // still use the priorities to select from ambiguous matches. + Hints []*ModelHint `json:"hints,omitempty"` + // How much to prioritize intelligence and capabilities when selecting a model. + // A value of 0 means intelligence is not important, while a value of 1 means + // intelligence is the most important factor. + IntelligencePriority float64 `json:"intelligencePriority,omitempty"` + // How much to prioritize sampling speed (latency) when selecting a model. A + // value of 0 means speed is not important, while a value of 1 means speed is + // the most important factor. + SpeedPriority float64 `json:"speedPriority,omitempty"` +} + +type PingParams struct { + // This property is reserved by the protocol to allow clients and servers to + // attach additional metadata to their responses. + Meta `json:"_meta,omitempty"` +} + +func (x *PingParams) isParams() {} +func (x *PingParams) GetProgressToken() any { return getProgressToken(x) } +func (x *PingParams) SetProgressToken(t any) { setProgressToken(x, t) } + +type ProgressNotificationParams struct { + // This property is reserved by the protocol to allow clients and servers to + // attach additional metadata to their responses. + Meta `json:"_meta,omitempty"` + // The progress token which was given in the initial request, used to associate + // this notification with the request that is proceeding. + ProgressToken any `json:"progressToken"` + // An optional message describing the current progress. + Message string `json:"message,omitempty"` + // The progress thus far. This should increase every time progress is made, even + // if the total is unknown. + Progress float64 `json:"progress"` + // Total number of items to process (or total progress required), if known. + // Zero means unknown. + Total float64 `json:"total,omitempty"` +} + +func (*ProgressNotificationParams) isParams() {} + +// IconTheme specifies the theme an icon is designed for. +type IconTheme string + +const ( + // IconThemeLight indicates the icon is designed for a light background. + IconThemeLight IconTheme = "light" + // IconThemeDark indicates the icon is designed for a dark background. + IconThemeDark IconTheme = "dark" +) + +// Icon provides visual identifiers for their resources, tools, prompts, and implementations +// See [/specification/draft/basic/index#icons] for notes on icons +// +// TODO(iamsurajbobade): update specification url from draft. +type Icon struct { + // Source is A URI pointing to the icon resource (required). This can be: + // - An HTTP/HTTPS URL pointing to an image file + // - A data URI with base64-encoded image data + Source string `json:"src"` + // Optional MIME type if the server's type is missing or generic + MIMEType string `json:"mimeType,omitempty"` + // Optional size specification (e.g., ["48x48"], ["any"] for scalable formats like SVG, or ["48x48", "96x96"] for multiple sizes) + Sizes []string `json:"sizes,omitempty"` + // Optional theme specifier. "light" indicates the icon is designed for a light + // background, "dark" indicates the icon is designed for a dark background. + Theme IconTheme `json:"theme,omitempty"` +} + +// A prompt or prompt template that the server offers. +type Prompt struct { + // See [specification/2025-06-18/basic/index#general-fields] for notes on _meta + // usage. + Meta `json:"_meta,omitempty"` + // A list of arguments to use for templating the prompt. + Arguments []*PromptArgument `json:"arguments,omitempty"` + // An optional description of what this prompt provides + Description string `json:"description,omitempty"` + // Intended for programmatic or logical use, but used as a display name in past + // specs or fallback (if title isn't present). + Name string `json:"name"` + // Intended for UI and end-user contexts — optimized to be human-readable and + // easily understood, even by those unfamiliar with domain-specific terminology. + Title string `json:"title,omitempty"` + // Icons for the prompt, if any. + Icons []Icon `json:"icons,omitempty"` +} + +// Describes an argument that a prompt can accept. +type PromptArgument struct { + // Intended for programmatic or logical use, but used as a display name in past + // specs or fallback (if title isn't present). + Name string `json:"name"` + // Intended for UI and end-user contexts — optimized to be human-readable and + // easily understood, even by those unfamiliar with domain-specific terminology. + Title string `json:"title,omitempty"` + // A human-readable description of the argument. + Description string `json:"description,omitempty"` + // Whether this argument must be provided. + Required bool `json:"required,omitempty"` +} + +type PromptListChangedParams struct { + // This property is reserved by the protocol to allow clients and servers to + // attach additional metadata to their responses. + Meta `json:"_meta,omitempty"` +} + +func (x *PromptListChangedParams) isParams() {} +func (x *PromptListChangedParams) GetProgressToken() any { return getProgressToken(x) } +func (x *PromptListChangedParams) SetProgressToken(t any) { setProgressToken(x, t) } + +// Describes a message returned as part of a prompt. +// +// This is similar to SamplingMessage, but also supports the embedding of +// resources from the MCP server. +type PromptMessage struct { + Content Content `json:"content"` + Role Role `json:"role"` +} + +// UnmarshalJSON handles the unmarshalling of content into the Content +// interface. +func (m *PromptMessage) UnmarshalJSON(data []byte) error { + type msg PromptMessage // avoid recursion + var wire struct { + msg + Content *wireContent `json:"content"` + } + if err := internaljson.Unmarshal(data, &wire); err != nil { + return err + } + var err error + if wire.msg.Content, err = contentFromWire(wire.Content, nil); err != nil { + return err + } + *m = PromptMessage(wire.msg) + return nil +} + +type ReadResourceParams struct { + // This property is reserved by the protocol to allow clients and servers to + // attach additional metadata to their responses. + Meta `json:"_meta,omitempty"` + // The URI of the resource to read. The URI can use any protocol; it is up to + // the server how to interpret it. + URI string `json:"uri"` +} + +func (x *ReadResourceParams) isParams() {} +func (x *ReadResourceParams) GetProgressToken() any { return getProgressToken(x) } +func (x *ReadResourceParams) SetProgressToken(t any) { setProgressToken(x, t) } + +// The server's response to a resources/read request from the client. +type ReadResourceResult struct { + // This property is reserved by the protocol to allow clients and servers to + // attach additional metadata to their responses. + Meta `json:"_meta,omitempty"` + Contents []*ResourceContents `json:"contents"` +} + +func (*ReadResourceResult) isResult() {} + +// A known resource that the server is capable of reading. +type Resource struct { + // See [specification/2025-06-18/basic/index#general-fields] for notes on _meta + // usage. + Meta `json:"_meta,omitempty"` + // Optional annotations for the client. + Annotations *Annotations `json:"annotations,omitempty"` + // A description of what this resource represents. + // + // This can be used by clients to improve the LLM's understanding of available + // resources. It can be thought of like a "hint" to the model. + Description string `json:"description,omitempty"` + // The MIME type of this resource, if known. + MIMEType string `json:"mimeType,omitempty"` + // Intended for programmatic or logical use, but used as a display name in past + // specs or fallback (if title isn't present). + Name string `json:"name"` + // The size of the raw resource content, in bytes (i.e., before base64 encoding + // or any tokenization), if known. + // + // This can be used by Hosts to display file sizes and estimate context window + // usage. + Size int64 `json:"size,omitempty"` + // Intended for UI and end-user contexts — optimized to be human-readable and + // easily understood, even by those unfamiliar with domain-specific terminology. + // + // If not provided, the name should be used for display (except for Tool, where + // Annotations.Title should be given precedence over using name, if + // present). + Title string `json:"title,omitempty"` + // The URI of this resource. + URI string `json:"uri"` + // Icons for the resource, if any. + Icons []Icon `json:"icons,omitempty"` +} + +type ResourceListChangedParams struct { + // This property is reserved by the protocol to allow clients and servers to + // attach additional metadata to their responses. + Meta `json:"_meta,omitempty"` +} + +func (x *ResourceListChangedParams) isParams() {} +func (x *ResourceListChangedParams) GetProgressToken() any { return getProgressToken(x) } +func (x *ResourceListChangedParams) SetProgressToken(t any) { setProgressToken(x, t) } + +// A template description for resources available on the server. +type ResourceTemplate struct { + // See [specification/2025-06-18/basic/index#general-fields] for notes on _meta + // usage. + Meta `json:"_meta,omitempty"` + // Optional annotations for the client. + Annotations *Annotations `json:"annotations,omitempty"` + // A description of what this template is for. + // + // This can be used by clients to improve the LLM's understanding of available + // resources. It can be thought of like a "hint" to the model. + Description string `json:"description,omitempty"` + // The MIME type for all resources that match this template. This should only be + // included if all resources matching this template have the same type. + MIMEType string `json:"mimeType,omitempty"` + // Intended for programmatic or logical use, but used as a display name in past + // specs or fallback (if title isn't present). + Name string `json:"name"` + // Intended for UI and end-user contexts — optimized to be human-readable and + // easily understood, even by those unfamiliar with domain-specific terminology. + // + // If not provided, the name should be used for display (except for Tool, where + // Annotations.Title should be given precedence over using name, if + // present). + Title string `json:"title,omitempty"` + // A URI template (according to RFC 6570) that can be used to construct resource + // URIs. + URITemplate string `json:"uriTemplate"` + // Icons for the resource template, if any. + Icons []Icon `json:"icons,omitempty"` +} + +// The sender or recipient of messages and data in a conversation. +type Role string + +// Represents a root directory or file that the server can operate on. +type Root struct { + // See [specification/2025-06-18/basic/index#general-fields] for notes on _meta + // usage. + Meta `json:"_meta,omitempty"` + // An optional name for the root. This can be used to provide a human-readable + // identifier for the root, which may be useful for display purposes or for + // referencing the root in other parts of the application. + Name string `json:"name,omitempty"` + // The URI identifying the root. This *must* start with file:// for now. This + // restriction may be relaxed in future versions of the protocol to allow other + // URI schemes. + URI string `json:"uri"` +} + +type RootsListChangedParams struct { + // This property is reserved by the protocol to allow clients and servers to + // attach additional metadata to their responses. + Meta `json:"_meta,omitempty"` +} + +func (x *RootsListChangedParams) isParams() {} +func (x *RootsListChangedParams) GetProgressToken() any { return getProgressToken(x) } +func (x *RootsListChangedParams) SetProgressToken(t any) { setProgressToken(x, t) } + +// TODO: to be consistent with ServerCapabilities, move the capability types +// below directly above ClientCapabilities. + +// SamplingCapabilities describes the client's support for sampling. +type SamplingCapabilities struct { + // Context indicates the client supports includeContext values other than "none". + Context *SamplingContextCapabilities `json:"context,omitempty"` + // Tools indicates the client supports tools and toolChoice in sampling requests. + Tools *SamplingToolsCapabilities `json:"tools,omitempty"` +} + +// SamplingContextCapabilities indicates the client supports context inclusion. +type SamplingContextCapabilities struct{} + +// SamplingToolsCapabilities indicates the client supports tool use in sampling. +type SamplingToolsCapabilities struct{} + +// ToolChoice controls how the model uses tools during sampling. +type ToolChoice struct { + // Mode controls tool invocation behavior: + // - "auto": Model decides whether to use tools (default) + // - "required": Model must use at least one tool + // - "none": Model must not use any tools + Mode string `json:"mode,omitempty"` +} + +// ElicitationCapabilities describes the capabilities for elicitation. +// +// If neither Form nor URL is set, the 'Form' capabilitiy is assumed. +type ElicitationCapabilities struct { + Form *FormElicitationCapabilities `json:"form,omitempty"` + URL *URLElicitationCapabilities `json:"url,omitempty"` +} + +// FormElicitationCapabilities describes capabilities for form elicitation. +type FormElicitationCapabilities struct{} + +// URLElicitationCapabilities describes capabilities for url elicitation. +type URLElicitationCapabilities struct{} + +// Describes a message issued to or received from an LLM API. +// +// For assistant messages, Content may be text, image, audio, or tool_use. +// For user messages, Content may be text, image, audio, or tool_result. +type SamplingMessage struct { + Content Content `json:"content"` + Role Role `json:"role"` +} + +// UnmarshalJSON handles the unmarshalling of content into the Content +// interface. +func (m *SamplingMessage) UnmarshalJSON(data []byte) error { + type msg SamplingMessage // avoid recursion + var wire struct { + msg + Content *wireContent `json:"content"` + } + if err := internaljson.Unmarshal(data, &wire); err != nil { + return err + } + // Allow text, image, audio, tool_use, and tool_result in sampling messages + var err error + if wire.msg.Content, err = contentFromWire(wire.Content, map[string]bool{"text": true, "image": true, "audio": true, "tool_use": true, "tool_result": true}); err != nil { + return err + } + *m = SamplingMessage(wire.msg) + return nil +} + +type SetLoggingLevelParams struct { + // This property is reserved by the protocol to allow clients and servers to + // attach additional metadata to their responses. + Meta `json:"_meta,omitempty"` + // The level of logging that the client wants to receive from the server. The + // server should send all logs at this level and higher (i.e., more severe) to + // the client as notifications/message. + Level LoggingLevel `json:"level"` +} + +func (x *SetLoggingLevelParams) isParams() {} +func (x *SetLoggingLevelParams) GetProgressToken() any { return getProgressToken(x) } +func (x *SetLoggingLevelParams) SetProgressToken(t any) { setProgressToken(x, t) } + +// Definition for a tool the client can call. +type Tool struct { + // See [specification/2025-06-18/basic/index#general-fields] for notes on _meta + // usage. + Meta `json:"_meta,omitempty"` + // Optional additional tool information. + // + // Display name precedence order is: title, annotations.title, then name. + Annotations *ToolAnnotations `json:"annotations,omitempty"` + // A human-readable description of the tool. + // + // This can be used by clients to improve the LLM's understanding of available + // tools. It can be thought of like a "hint" to the model. + Description string `json:"description,omitempty"` + // InputSchema holds a JSON Schema object defining the expected parameters + // for the tool. + // + // From the server, this field may be set to any value that JSON-marshals to + // valid JSON schema (including json.RawMessage). However, for tools added + // using [AddTool], which automatically validates inputs and outputs, the + // schema must be in a draft the SDK understands. Currently, the SDK uses + // github.com/google/jsonschema-go for inference and validation, which only + // supports the 2020-12 draft of JSON schema. To do your own validation, use + // [Server.AddTool]. + // + // From the client, this field will hold the default JSON marshaling of the + // server's input schema (a map[string]any). + InputSchema any `json:"inputSchema"` + // Intended for programmatic or logical use, but used as a display name in past + // specs or fallback (if title isn't present). + Name string `json:"name"` + // OutputSchema holds an optional JSON Schema object defining the structure + // of the tool's output returned in the StructuredContent field of a + // CallToolResult. + // + // From the server, this field may be set to any value that JSON-marshals to + // valid JSON schema (including json.RawMessage). However, for tools added + // using [AddTool], which automatically validates inputs and outputs, the + // schema must be in a draft the SDK understands. Currently, the SDK uses + // github.com/google/jsonschema-go for inference and validation, which only + // supports the 2020-12 draft of JSON schema. To do your own validation, use + // [Server.AddTool]. + // + // From the client, this field will hold the default JSON marshaling of the + // server's output schema (a map[string]any). + OutputSchema any `json:"outputSchema,omitempty"` + // Intended for UI and end-user contexts — optimized to be human-readable and + // easily understood, even by those unfamiliar with domain-specific terminology. + // If not provided, Annotations.Title should be used for display if present, + // otherwise Name. + Title string `json:"title,omitempty"` + // Icons for the tool, if any. + Icons []Icon `json:"icons,omitempty"` +} + +// Additional properties describing a Tool to clients. +// +// NOTE: all properties in ToolAnnotations are hints. They are not +// guaranteed to provide a faithful description of tool behavior (including +// descriptive properties like title). +// +// Clients should never make tool use decisions based on ToolAnnotations +// received from untrusted servers. +type ToolAnnotations struct { + // If true, the tool may perform destructive updates to its environment. If + // false, the tool performs only additive updates. + // + // (This property is meaningful only when ReadOnlyHint == false.) + // + // Default: true + DestructiveHint *bool `json:"destructiveHint,omitempty"` + // If true, calling the tool repeatedly with the same arguments will have no + // additional effect on the its environment. + // + // (This property is meaningful only when ReadOnlyHint == false.) + // + // Default: false + IdempotentHint bool `json:"idempotentHint,omitempty"` + // If true, this tool may interact with an "open world" of external entities. If + // false, the tool's domain of interaction is closed. For example, the world of + // a web search tool is open, whereas that of a memory tool is not. + // + // Default: true + OpenWorldHint *bool `json:"openWorldHint,omitempty"` + // If true, the tool does not modify its environment. + // + // Default: false + ReadOnlyHint bool `json:"readOnlyHint,omitempty"` + // A human-readable title for the tool. + Title string `json:"title,omitempty"` +} + +type ToolListChangedParams struct { + // This property is reserved by the protocol to allow clients and servers to + // attach additional metadata to their responses. + Meta `json:"_meta,omitempty"` +} + +func (x *ToolListChangedParams) isParams() {} +func (x *ToolListChangedParams) GetProgressToken() any { return getProgressToken(x) } +func (x *ToolListChangedParams) SetProgressToken(t any) { setProgressToken(x, t) } + +// Sent from the client to request resources/updated notifications from the +// server whenever a particular resource changes. +type SubscribeParams struct { + // This property is reserved by the protocol to allow clients and servers to + // attach additional metadata to their responses. + Meta `json:"_meta,omitempty"` + // The URI of the resource to subscribe to. + URI string `json:"uri"` +} + +func (*SubscribeParams) isParams() {} + +// Sent from the client to request cancellation of resources/updated +// notifications from the server. This should follow a previous +// resources/subscribe request. +type UnsubscribeParams struct { + // This property is reserved by the protocol to allow clients and servers to + // attach additional metadata to their responses. + Meta `json:"_meta,omitempty"` + // The URI of the resource to unsubscribe from. + URI string `json:"uri"` +} + +func (*UnsubscribeParams) isParams() {} + +// A notification from the server to the client, informing it that a resource +// has changed and may need to be read again. This should only be sent if the +// client previously sent a resources/subscribe request. +type ResourceUpdatedNotificationParams struct { + // This property is reserved by the protocol to allow clients and servers to + // attach additional metadata to their responses. + Meta `json:"_meta,omitempty"` + // The URI of the resource that has been updated. This might be a sub-resource of the one that the client actually subscribed to. + URI string `json:"uri"` +} + +func (*ResourceUpdatedNotificationParams) isParams() {} + +// TODO(jba): add CompleteRequest and related types. + +// A request from the server to elicit additional information from the user via the client. +type ElicitParams struct { + // This property is reserved by the protocol to allow clients and servers to + // attach additional metadata to their responses. + Meta `json:"_meta,omitempty"` + // The mode of elicitation to use. + // + // If unset, will be inferred from the other fields. + Mode string `json:"mode"` + // The message to present to the user. + Message string `json:"message"` + // A JSON schema object defining the requested elicitation schema. + // + // From the server, this field may be set to any value that can JSON-marshal + // to valid JSON schema (including json.RawMessage for raw schema values). + // Internally, the SDK uses github.com/google/jsonschema-go for validation, + // which only supports the 2020-12 draft of the JSON schema spec. + // + // From the client, this field will use the default JSON marshaling (a + // map[string]any). + // + // Only top-level properties are allowed, without nesting. + // + // This is only used for "form" elicitation. + RequestedSchema any `json:"requestedSchema,omitempty"` + // The URL to present to the user. + // + // This is only used for "url" elicitation. + URL string `json:"url,omitempty"` + // The ID of the elicitation. + // + // This is only used for "url" elicitation. + ElicitationID string `json:"elicitationId,omitempty"` +} + +func (x *ElicitParams) isParams() {} + +func (x *ElicitParams) GetProgressToken() any { return getProgressToken(x) } +func (x *ElicitParams) SetProgressToken(t any) { setProgressToken(x, t) } + +// The client's response to an elicitation/create request from the server. +type ElicitResult struct { + // This property is reserved by the protocol to allow clients and servers to + // attach additional metadata to their responses. + Meta `json:"_meta,omitempty"` + // The user action in response to the elicitation. + // - "accept": User submitted the form/confirmed the action + // - "decline": User explicitly declined the action + // - "cancel": User dismissed without making an explicit choice + Action string `json:"action"` + // The submitted form data, only present when action is "accept". + // Contains values matching the requested schema. + Content map[string]any `json:"content,omitempty"` +} + +func (*ElicitResult) isResult() {} + +// ElicitationCompleteParams is sent from the server to the client, informing it that an out-of-band elicitation interaction has completed. +type ElicitationCompleteParams struct { + // This property is reserved by the protocol to allow clients and servers to + // attach additional metadata to their responses. + Meta `json:"_meta,omitempty"` + // The ID of the elicitation that has completed. This must correspond to the + // elicitationId from the original elicitation/create request. + ElicitationID string `json:"elicitationId"` +} + +func (*ElicitationCompleteParams) isParams() {} + +// An Implementation describes the name and version of an MCP implementation, with an optional +// title for UI representation. +type Implementation struct { + // Intended for programmatic or logical use, but used as a display name in past + // specs or fallback (if title isn't present). + Name string `json:"name"` + // Intended for UI and end-user contexts — optimized to be human-readable and + // easily understood, even by those unfamiliar with domain-specific terminology. + Title string `json:"title,omitempty"` + Version string `json:"version"` + // WebsiteURL for the server, if any. + WebsiteURL string `json:"websiteUrl,omitempty"` + // Icons for the Server, if any. + Icons []Icon `json:"icons,omitempty"` +} + +// CompletionCapabilities describes the server's support for argument autocompletion. +type CompletionCapabilities struct{} + +// LoggingCapabilities describes the server's support for sending log messages to the client. +type LoggingCapabilities struct{} + +// PromptCapabilities describes the server's support for prompts. +type PromptCapabilities struct { + // Whether this server supports notifications for changes to the prompt list. + ListChanged bool `json:"listChanged,omitempty"` +} + +// ResourceCapabilities describes the server's support for resources. +type ResourceCapabilities struct { + // ListChanged reports whether the client supports notifications for + // changes to the resource list. + ListChanged bool `json:"listChanged,omitempty"` + // Subscribe reports whether this server supports subscribing to resource + // updates. + Subscribe bool `json:"subscribe,omitempty"` +} + +// ToolCapabilities describes the server's support for tools. +type ToolCapabilities struct { + // ListChanged reports whether the client supports notifications for + // changes to the tool list. + ListChanged bool `json:"listChanged,omitempty"` +} + +// ServerCapabilities describes capabilities that a server supports. +type ServerCapabilities struct { + // NOTE: any addition to ServerCapabilities must also be reflected in + // [ServerCapabilities.clone]. + + // Experimental reports non-standard capabilities that the server supports. + // The caller should not modify the map after assigning it. + Experimental map[string]any `json:"experimental,omitempty"` + // Extensions reports extensions that the server supports. + // Keys are extension identifiers in "{vendor-prefix}/{extension-name}" format. + // Values are per-extension settings objects; use [ServerCapabilities.AddExtension] + // to ensure nil settings are normalized to empty objects. + // The caller should not modify the map or its values after assigning it. + Extensions map[string]any `json:"extensions,omitempty"` + // Completions is present if the server supports argument autocompletion + // suggestions. + Completions *CompletionCapabilities `json:"completions,omitempty"` + // Logging is present if the server supports log messages. + Logging *LoggingCapabilities `json:"logging,omitempty"` + // Prompts is present if the server supports prompts. + Prompts *PromptCapabilities `json:"prompts,omitempty"` + // Resources is present if the server supports resourcs. + Resources *ResourceCapabilities `json:"resources,omitempty"` + // Tools is present if the supports tools. + Tools *ToolCapabilities `json:"tools,omitempty"` +} + +// AddExtension adds an extension with the given name and settings. +// If settings is nil, an empty map is used to ensure valid JSON serialization +// (the spec requires an object, not null). +// The settings map should not be modified after the call. +func (c *ServerCapabilities) AddExtension(name string, settings map[string]any) { + if c.Extensions == nil { + c.Extensions = make(map[string]any) + } + if settings == nil { + settings = map[string]any{} + } + c.Extensions[name] = settings +} + +// clone returns a copy of the ServerCapabilities. +// Values in the Extensions and Experimental maps are shallow-copied. +func (c *ServerCapabilities) clone() *ServerCapabilities { + cp := *c + cp.Experimental = maps.Clone(c.Experimental) + cp.Extensions = maps.Clone(c.Extensions) + cp.Completions = shallowClone(c.Completions) + cp.Logging = shallowClone(c.Logging) + cp.Prompts = shallowClone(c.Prompts) + cp.Resources = shallowClone(c.Resources) + cp.Tools = shallowClone(c.Tools) + return &cp +} + +const ( + methodCallTool = "tools/call" + notificationCancelled = "notifications/cancelled" + methodComplete = "completion/complete" + methodCreateMessage = "sampling/createMessage" + methodElicit = "elicitation/create" + notificationElicitationComplete = "notifications/elicitation/complete" + methodGetPrompt = "prompts/get" + methodInitialize = "initialize" + notificationInitialized = "notifications/initialized" + methodListPrompts = "prompts/list" + methodListResourceTemplates = "resources/templates/list" + methodListResources = "resources/list" + methodListRoots = "roots/list" + methodListTools = "tools/list" + notificationLoggingMessage = "notifications/message" + methodPing = "ping" + notificationProgress = "notifications/progress" + notificationPromptListChanged = "notifications/prompts/list_changed" + methodReadResource = "resources/read" + notificationResourceListChanged = "notifications/resources/list_changed" + notificationResourceUpdated = "notifications/resources/updated" + notificationRootsListChanged = "notifications/roots/list_changed" + methodSetLevel = "logging/setLevel" + methodSubscribe = "resources/subscribe" + notificationToolListChanged = "notifications/tools/list_changed" + methodUnsubscribe = "resources/unsubscribe" +) diff --git a/mcp/dsx-exchange-mcp/vendor/github.com/modelcontextprotocol/go-sdk/mcp/requests.go b/mcp/dsx-exchange-mcp/vendor/github.com/modelcontextprotocol/go-sdk/mcp/requests.go new file mode 100644 index 0000000..4280941 --- /dev/null +++ b/mcp/dsx-exchange-mcp/vendor/github.com/modelcontextprotocol/go-sdk/mcp/requests.go @@ -0,0 +1,39 @@ +// Copyright 2025 The Go MCP SDK Authors. All rights reserved. +// Use of this source code is governed by an MIT-style +// license that can be found in the LICENSE file. + +// This file holds the request types. + +package mcp + +type ( + CallToolRequest = ServerRequest[*CallToolParamsRaw] + CompleteRequest = ServerRequest[*CompleteParams] + GetPromptRequest = ServerRequest[*GetPromptParams] + InitializedRequest = ServerRequest[*InitializedParams] + ListPromptsRequest = ServerRequest[*ListPromptsParams] + ListResourcesRequest = ServerRequest[*ListResourcesParams] + ListResourceTemplatesRequest = ServerRequest[*ListResourceTemplatesParams] + ListToolsRequest = ServerRequest[*ListToolsParams] + ProgressNotificationServerRequest = ServerRequest[*ProgressNotificationParams] + ReadResourceRequest = ServerRequest[*ReadResourceParams] + RootsListChangedRequest = ServerRequest[*RootsListChangedParams] + SubscribeRequest = ServerRequest[*SubscribeParams] + UnsubscribeRequest = ServerRequest[*UnsubscribeParams] +) + +type ( + CreateMessageRequest = ClientRequest[*CreateMessageParams] + CreateMessageWithToolsRequest = ClientRequest[*CreateMessageWithToolsParams] + ElicitRequest = ClientRequest[*ElicitParams] + initializedClientRequest = ClientRequest[*InitializedParams] + InitializeRequest = ClientRequest[*InitializeParams] + ListRootsRequest = ClientRequest[*ListRootsParams] + LoggingMessageRequest = ClientRequest[*LoggingMessageParams] + ProgressNotificationClientRequest = ClientRequest[*ProgressNotificationParams] + PromptListChangedRequest = ClientRequest[*PromptListChangedParams] + ResourceListChangedRequest = ClientRequest[*ResourceListChangedParams] + ResourceUpdatedNotificationRequest = ClientRequest[*ResourceUpdatedNotificationParams] + ToolListChangedRequest = ClientRequest[*ToolListChangedParams] + ElicitationCompleteNotificationRequest = ClientRequest[*ElicitationCompleteParams] +) diff --git a/mcp/dsx-exchange-mcp/vendor/github.com/modelcontextprotocol/go-sdk/mcp/resource.go b/mcp/dsx-exchange-mcp/vendor/github.com/modelcontextprotocol/go-sdk/mcp/resource.go new file mode 100644 index 0000000..bc4b3cb --- /dev/null +++ b/mcp/dsx-exchange-mcp/vendor/github.com/modelcontextprotocol/go-sdk/mcp/resource.go @@ -0,0 +1,181 @@ +// Copyright 2025 The Go MCP SDK Authors. All rights reserved. +// Use of this source code is governed by an MIT-style +// license that can be found in the LICENSE file. + +package mcp + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net/url" + "os" + "path/filepath" + "strings" + + "github.com/modelcontextprotocol/go-sdk/internal/util" + "github.com/modelcontextprotocol/go-sdk/jsonrpc" + "github.com/yosida95/uritemplate/v3" +) + +// A serverResource associates a Resource with its handler. +type serverResource struct { + resource *Resource + handler ResourceHandler +} + +// A serverResourceTemplate associates a ResourceTemplate with its handler. +type serverResourceTemplate struct { + resourceTemplate *ResourceTemplate + handler ResourceHandler +} + +// A ResourceHandler is a function that reads a resource. +// It will be called when the client calls [ClientSession.ReadResource]. +// If it cannot find the resource, it should return the result of calling [ResourceNotFoundError]. +type ResourceHandler func(context.Context, *ReadResourceRequest) (*ReadResourceResult, error) + +// ResourceNotFoundError returns an error indicating that a resource being read could +// not be found. +func ResourceNotFoundError(uri string) error { + return &jsonrpc.Error{ + Code: CodeResourceNotFound, + Message: "Resource not found", + Data: json.RawMessage(fmt.Sprintf(`{"uri":%q}`, uri)), + } +} + +// readFileResource reads from the filesystem at a URI relative to dirFilepath, respecting +// the roots. +// dirFilepath and rootFilepaths are absolute filesystem paths. +func readFileResource(rawURI, dirFilepath string, rootFilepaths []string) ([]byte, error) { + uriFilepath, err := computeURIFilepath(rawURI, dirFilepath, rootFilepaths) + if err != nil { + return nil, err + } + + var data []byte + err = withFile(dirFilepath, uriFilepath, func(f *os.File) error { + var err error + data, err = io.ReadAll(f) + return err + }) + if os.IsNotExist(err) { + err = ResourceNotFoundError(rawURI) + } + return data, err +} + +// computeURIFilepath returns a path relative to dirFilepath. +// The dirFilepath and rootFilepaths are absolute file paths. +func computeURIFilepath(rawURI, dirFilepath string, rootFilepaths []string) (string, error) { + // We use "file path" to mean a filesystem path. + uri, err := url.Parse(rawURI) + if err != nil { + return "", err + } + if uri.Scheme != "file" { + return "", fmt.Errorf("URI is not a file: %s", uri) + } + if uri.Path == "" { + // A more specific error than the one below, to catch the + // common mistake "file://foo". + return "", errors.New("empty path") + } + // The URI's path is interpreted relative to dirFilepath, and in the local filesystem. + // It must not try to escape its directory. + uriFilepathRel, err := filepath.Localize(strings.TrimPrefix(uri.Path, "/")) + if err != nil { + return "", fmt.Errorf("%q cannot be localized: %w", uriFilepathRel, err) + } + + // Check roots, if there are any. + if len(rootFilepaths) > 0 { + // To check against the roots, we need an absolute file path, not relative to the directory. + // uriFilepath is local, so the joined path is under dirFilepath. + uriFilepathAbs := filepath.Join(dirFilepath, uriFilepathRel) + rootOK := false + // Check that the requested file path is under some root. + // Since both paths are absolute, that's equivalent to filepath.Rel constructing + // a local path. + for _, rootFilepathAbs := range rootFilepaths { + if rel, err := filepath.Rel(rootFilepathAbs, uriFilepathAbs); err == nil && filepath.IsLocal(rel) { + rootOK = true + break + } + } + if !rootOK { + return "", fmt.Errorf("URI path %q is not under any root", uriFilepathAbs) + } + } + return uriFilepathRel, nil +} + +// withFile calls f on the file at join(dir, rel), +// protecting against path traversal attacks. +func withFile(dir, rel string, f func(*os.File) error) (err error) { + r, err := os.OpenRoot(dir) + if err != nil { + return err + } + defer r.Close() + file, err := r.Open(rel) + if err != nil { + return err + } + // Record error, in case f writes. + defer func() { err = errors.Join(err, file.Close()) }() + return f(file) +} + +// fileRoots transforms the Roots obtained from the client into absolute paths on +// the local filesystem. +// TODO(jba): expose this functionality to user ResourceHandlers, +// so they don't have to repeat it. +func fileRoots(rawRoots []*Root) ([]string, error) { + var fileRoots []string + for _, r := range rawRoots { + fr, err := fileRoot(r) + if err != nil { + return nil, err + } + fileRoots = append(fileRoots, fr) + } + return fileRoots, nil +} + +// fileRoot returns the absolute path for Root. +func fileRoot(root *Root) (_ string, err error) { + defer util.Wrapf(&err, "root %q", root.URI) + + // Convert to absolute file path. + rurl, err := url.Parse(root.URI) + if err != nil { + return "", err + } + if rurl.Scheme != "file" { + return "", errors.New("not a file URI") + } + if rurl.Path == "" { + // A more specific error than the one below, to catch the + // common mistake "file://foo". + return "", errors.New("empty path") + } + // We don't want Localize here: we want an absolute path, which is not local. + fileRoot := filepath.Clean(filepath.FromSlash(rurl.Path)) + if !filepath.IsAbs(fileRoot) { + return "", errors.New("not an absolute path") + } + return fileRoot, nil +} + +// Matches reports whether the receiver's uri template matches the uri. +func (sr *serverResourceTemplate) Matches(uri string) bool { + tmpl, err := uritemplate.New(sr.resourceTemplate.URITemplate) + if err != nil { + return false + } + return tmpl.Regexp().MatchString(uri) +} diff --git a/mcp/dsx-exchange-mcp/vendor/github.com/modelcontextprotocol/go-sdk/mcp/schema_cache.go b/mcp/dsx-exchange-mcp/vendor/github.com/modelcontextprotocol/go-sdk/mcp/schema_cache.go new file mode 100644 index 0000000..5fb032d --- /dev/null +++ b/mcp/dsx-exchange-mcp/vendor/github.com/modelcontextprotocol/go-sdk/mcp/schema_cache.go @@ -0,0 +1,69 @@ +// Copyright 2025 The Go MCP SDK Authors. All rights reserved. +// Use of this source code is governed by an MIT-style +// license that can be found in the LICENSE file. + +package mcp + +import ( + "reflect" + "sync" + + "github.com/google/jsonschema-go/jsonschema" +) + +// A SchemaCache caches JSON schemas to avoid repeated reflection and resolution. +// +// This is useful for stateless server deployments (one [Server] per request) +// where tools are re-registered on every request. Without caching, each +// [AddTool] call triggers expensive reflection-based schema generation. +// +// A SchemaCache is safe for concurrent use by multiple goroutines. +// +// # Trade-offs +// +// The cache is unbounded: it stores one entry per unique Go type or schema +// pointer. For typical MCP servers with a fixed set of tools, memory usage +// is negligible. However, if tool input types are generated dynamically, +// the cache will grow without bound. +// +// The cache uses pointer identity for pre-defined schemas. If a schema's +// contents change but the pointer remains the same, stale resolved schemas +// may be returned. In practice, this is not an issue because tool schemas +// are typically defined once at startup. +type SchemaCache struct { + byType sync.Map // reflect.Type -> *cachedSchema + bySchema sync.Map // *jsonschema.Schema -> *jsonschema.Resolved +} + +type cachedSchema struct { + schema *jsonschema.Schema + resolved *jsonschema.Resolved +} + +// NewSchemaCache creates a new [SchemaCache]. +func NewSchemaCache() *SchemaCache { + return &SchemaCache{} +} + +func (c *SchemaCache) getByType(t reflect.Type) (*jsonschema.Schema, *jsonschema.Resolved, bool) { + if v, ok := c.byType.Load(t); ok { + cs := v.(*cachedSchema) + return cs.schema, cs.resolved, true + } + return nil, nil, false +} + +func (c *SchemaCache) setByType(t reflect.Type, schema *jsonschema.Schema, resolved *jsonschema.Resolved) { + c.byType.Store(t, &cachedSchema{schema: schema, resolved: resolved}) +} + +func (c *SchemaCache) getBySchema(schema *jsonschema.Schema) (*jsonschema.Resolved, bool) { + if v, ok := c.bySchema.Load(schema); ok { + return v.(*jsonschema.Resolved), true + } + return nil, false +} + +func (c *SchemaCache) setBySchema(schema *jsonschema.Schema, resolved *jsonschema.Resolved) { + c.bySchema.Store(schema, resolved) +} diff --git a/mcp/dsx-exchange-mcp/vendor/github.com/modelcontextprotocol/go-sdk/mcp/server.go b/mcp/dsx-exchange-mcp/vendor/github.com/modelcontextprotocol/go-sdk/mcp/server.go new file mode 100644 index 0000000..e3c03e2 --- /dev/null +++ b/mcp/dsx-exchange-mcp/vendor/github.com/modelcontextprotocol/go-sdk/mcp/server.go @@ -0,0 +1,1595 @@ +// Copyright 2025 The Go MCP SDK Authors. All rights reserved. +// Use of this source code is governed by an MIT-style +// license that can be found in the LICENSE file. + +package mcp + +import ( + "bytes" + "context" + "crypto/rand" + "encoding/base64" + "encoding/gob" + "encoding/json" + "errors" + "fmt" + "iter" + "log/slog" + "maps" + "net/url" + "path/filepath" + "reflect" + "slices" + "sync" + "sync/atomic" + "time" + + "github.com/google/jsonschema-go/jsonschema" + internaljson "github.com/modelcontextprotocol/go-sdk/internal/json" + "github.com/modelcontextprotocol/go-sdk/internal/jsonrpc2" + "github.com/modelcontextprotocol/go-sdk/internal/util" + "github.com/modelcontextprotocol/go-sdk/jsonrpc" + "github.com/yosida95/uritemplate/v3" +) + +// DefaultPageSize is the default for [ServerOptions.PageSize]. +const DefaultPageSize = 1000 + +// A Server is an instance of an MCP server. +// +// Servers expose server-side MCP features, which can serve one or more MCP +// sessions by using [Server.Run]. +type Server struct { + // fixed at creation + impl *Implementation + opts ServerOptions + + mu sync.Mutex + prompts *featureSet[*serverPrompt] + tools *featureSet[*serverTool] + resources *featureSet[*serverResource] + resourceTemplates *featureSet[*serverResourceTemplate] + sessions []*ServerSession + sendingMethodHandler_ MethodHandler + receivingMethodHandler_ MethodHandler + resourceSubscriptions map[string]map[*ServerSession]bool // uri -> session -> bool + pendingNotifications map[string]*time.Timer // notification name -> timer for pending notification send +} + +// ServerOptions is used to configure behavior of the server. +type ServerOptions struct { + // Optional instructions for connected clients. + Instructions string + // Logger may be set to a non-nil value to enable logging of server activity. + Logger *slog.Logger + // If non-nil, called when "notifications/initialized" is received. + InitializedHandler func(context.Context, *InitializedRequest) + // PageSize is the maximum number of items to return in a single page for + // list methods (e.g. ListTools). + // + // If zero, defaults to [DefaultPageSize]. + PageSize int + // If non-nil, called when "notifications/roots/list_changed" is received. + RootsListChangedHandler func(context.Context, *RootsListChangedRequest) + // If non-nil, called when "notifications/progress" is received. + ProgressNotificationHandler func(context.Context, *ProgressNotificationServerRequest) + // If non-nil, called when "completion/complete" is received. + CompletionHandler func(context.Context, *CompleteRequest) (*CompleteResult, error) + // If non-zero, defines an interval for regular "ping" requests. + // If the peer fails to respond to pings originating from the keepalive check, + // the session is automatically closed. + KeepAlive time.Duration + // Function called when a client session subscribes to a resource. + SubscribeHandler func(context.Context, *SubscribeRequest) error + // Function called when a client session unsubscribes from a resource. + UnsubscribeHandler func(context.Context, *UnsubscribeRequest) error + + // Capabilities optionally configures the server's default capabilities, + // before any capabilities are inferred from other configuration or server + // features. + // + // If Capabilities is nil, the default server capabilities are {"logging":{}}, + // for historical reasons. Setting Capabilities to a non-nil value overrides + // this default. For example, setting Capabilities to `&ServerCapabilities{}` + // disables the logging capability. + // + // # Interaction with capability inference + // + // "tools", "prompts", and "resources" capabilities are automatically added when + // tools, prompts, or resources are added to the server (for example, via + // [Server.AddPrompt]), with default value `{"listChanged":true}`. Similarly, + // if the [ClientOptions.SubscribeHandler] or + // [ClientOptions.CompletionHandler] are set, the inferred capabilities are + // adjusted accordingly. + // + // Any non-nil field in Capabilities overrides the inferred value. + // For example: + // + // - To advertise the "tools" capability, even if no tools are added, set + // Capabilities.Tools to &ToolCapabilities{ListChanged:true}. + // - To disable tool list notifications, set Capabilities.Tools to + // &ToolCapabilities{}. + // + // Conversely, if Capabilities does not set a field (for example, if the + // Prompts field is nil), the inferred capability will be used. + Capabilities *ServerCapabilities + + // If true, advertises the prompts capability during initialization, + // even if no prompts have been registered. + // + // Deprecated: Use Capabilities instead. + HasPrompts bool + // If true, advertises the resources capability during initialization, + // even if no resources have been registered. + // + // Deprecated: Use Capabilities instead. + HasResources bool + // If true, advertises the tools capability during initialization, + // even if no tools have been registered. + // + // Deprecated: Use Capabilities instead. + HasTools bool + // SchemaCache, if non-nil, caches JSON schemas to avoid repeated + // reflection. This is useful for stateless server deployments where + // a new [Server] is created for each request. See [SchemaCache] for + // trade-offs and usage guidance. + SchemaCache *SchemaCache + + // GetSessionID provides the next session ID to use for an incoming request. + // If nil, a default randomly generated ID will be used. + // + // Session IDs should be globally unique across the scope of the server, + // which may span multiple processes in the case of distributed servers. + // + // As a special case, if GetSessionID returns the empty string, the + // Mcp-Session-Id header will not be set. + GetSessionID func() string +} + +// NewServer creates a new MCP server. The resulting server has no features: +// add features using the various Server.AddXXX methods, and the [AddTool] function. +// +// The server can be connected to one or more MCP clients using [Server.Run]. +// +// The first argument must not be nil. +// +// If non-nil, the provided options are used to configure the server. +func NewServer(impl *Implementation, options *ServerOptions) *Server { + if impl == nil { + panic("nil Implementation") + } + var opts ServerOptions + if options != nil { + opts = *options + } + options = nil // prevent reuse + if opts.PageSize < 0 { + panic(fmt.Errorf("invalid page size %d", opts.PageSize)) + } + if opts.PageSize == 0 { + opts.PageSize = DefaultPageSize + } + if opts.SubscribeHandler != nil && opts.UnsubscribeHandler == nil { + panic("SubscribeHandler requires UnsubscribeHandler") + } + if opts.UnsubscribeHandler != nil && opts.SubscribeHandler == nil { + panic("UnsubscribeHandler requires SubscribeHandler") + } + + if opts.GetSessionID == nil { + opts.GetSessionID = rand.Text + } + + if opts.Logger == nil { // ensure we have a logger + opts.Logger = ensureLogger(nil) + } + + return &Server{ + impl: impl, + opts: opts, + prompts: newFeatureSet(func(p *serverPrompt) string { return p.prompt.Name }), + tools: newFeatureSet(func(t *serverTool) string { return t.tool.Name }), + resources: newFeatureSet(func(r *serverResource) string { return r.resource.URI }), + resourceTemplates: newFeatureSet(func(t *serverResourceTemplate) string { return t.resourceTemplate.URITemplate }), + sendingMethodHandler_: defaultSendingMethodHandler, + receivingMethodHandler_: defaultReceivingMethodHandler[*ServerSession], + resourceSubscriptions: make(map[string]map[*ServerSession]bool), + pendingNotifications: make(map[string]*time.Timer), + } +} + +// AddPrompt adds a [Prompt] to the server, or replaces one with the same name. +func (s *Server) AddPrompt(p *Prompt, h PromptHandler) { + // Assume there was a change, since add replaces existing items. + // (It's possible an item was replaced with an identical one, but not worth checking.) + s.changeAndNotify( + notificationPromptListChanged, + func() bool { s.prompts.add(&serverPrompt{p, h}); return true }) +} + +// RemovePrompts removes the prompts with the given names. +// It is not an error to remove a nonexistent prompt. +func (s *Server) RemovePrompts(names ...string) { + s.changeAndNotify(notificationPromptListChanged, func() bool { return s.prompts.remove(names...) }) +} + +// AddTool adds a [Tool] to the server, or replaces one with the same name. +// The Tool argument must not be modified after this call. +// +// The tool's input schema must be non-nil and have the type "object". For a tool +// that takes no input, or one where any input is valid, set [Tool.InputSchema] to +// `{"type": "object"}`, using your preferred library or `json.RawMessage`. +// +// If present, [Tool.OutputSchema] must also have type "object". +// +// When the handler is invoked as part of a CallTool request, req.Params.Arguments +// will be a json.RawMessage. +// +// Unmarshaling the arguments and validating them against the input schema are the +// caller's responsibility. +// +// Validating the result against the output schema, if any, is the caller's responsibility. +// +// Setting the result's Content, StructuredContent and IsError fields are the caller's +// responsibility. +// +// Most users should use the top-level function [AddTool], which handles all these +// responsibilities. +func (s *Server) AddTool(t *Tool, h ToolHandler) { + if err := validateToolName(t.Name); err != nil { + s.opts.Logger.Error(fmt.Sprintf("AddTool: invalid tool name %q: %v", t.Name, err)) + } + if t.InputSchema == nil { + // This prevents the tool author from forgetting to write a schema where + // one should be provided. If we papered over this by supplying the empty + // schema, then every input would be validated and the problem wouldn't be + // discovered until runtime, when the LLM sent bad data. + panic(fmt.Errorf("AddTool %q: missing input schema", t.Name)) + } + if s, ok := t.InputSchema.(*jsonschema.Schema); ok { + if s.Type != "object" { + panic(fmt.Errorf(`AddTool %q: input schema must have type "object"`, t.Name)) + } + } else { + var m map[string]any + if err := remarshal(t.InputSchema, &m); err != nil { + panic(fmt.Errorf("AddTool %q: can't marshal input schema to a JSON object: %v", t.Name, err)) + } + if typ := m["type"]; typ != "object" { + panic(fmt.Errorf(`AddTool %q: input schema must have type "object" (got %v)`, t.Name, typ)) + } + } + if t.OutputSchema != nil { + if s, ok := t.OutputSchema.(*jsonschema.Schema); ok { + if s.Type != "object" { + panic(fmt.Errorf(`AddTool %q: output schema must have type "object"`, t.Name)) + } + } else { + var m map[string]any + if err := remarshal(t.OutputSchema, &m); err != nil { + panic(fmt.Errorf("AddTool %q: can't marshal output schema to a JSON object: %v", t.Name, err)) + } + if typ := m["type"]; typ != "object" { + panic(fmt.Errorf(`AddTool %q: output schema must have type "object" (got %v)`, t.Name, typ)) + } + } + } + st := &serverTool{tool: t, handler: h} + // Assume there was a change, since add replaces existing tools. + // (It's possible a tool was replaced with an identical one, but not worth checking.) + // TODO: Batch these changes by size and time? The typescript SDK doesn't. + // TODO: Surface notify error here? best not, in case we need to batch. + s.changeAndNotify(notificationToolListChanged, func() bool { s.tools.add(st); return true }) +} + +func toolForErr[In, Out any](t *Tool, h ToolHandlerFor[In, Out], cache *SchemaCache) (*Tool, ToolHandler, error) { + tt := *t + + // Special handling for an "any" input: treat as an empty object. + if reflect.TypeFor[In]() == reflect.TypeFor[any]() && t.InputSchema == nil { + tt.InputSchema = &jsonschema.Schema{Type: "object"} + } + + var inputResolved *jsonschema.Resolved + if _, err := setSchema[In](&tt.InputSchema, &inputResolved, cache); err != nil { + return nil, nil, fmt.Errorf("input schema: %w", err) + } + + // Handling for zero values: + // + // If Out is a pointer type and we've derived the output schema from its + // element type, use the zero value of its element type in place of a typed + // nil. + var ( + elemZero any // only non-nil if Out is a pointer type + outputResolved *jsonschema.Resolved + ) + if t.OutputSchema != nil || reflect.TypeFor[Out]() != reflect.TypeFor[any]() { + var err error + elemZero, err = setSchema[Out](&tt.OutputSchema, &outputResolved, cache) + if err != nil { + return nil, nil, fmt.Errorf("output schema: %v", err) + } + } + + th := func(ctx context.Context, req *CallToolRequest) (*CallToolResult, error) { + var input json.RawMessage + if req.Params.Arguments != nil { + input = req.Params.Arguments + } + // Validate input and apply defaults. + var err error + input, err = applySchema(input, inputResolved) + if err != nil { + // TODO(#450): should this be considered a tool error? (and similar below) + return nil, fmt.Errorf("%w: validating \"arguments\": %v", jsonrpc2.ErrInvalidParams, err) + } + + // Unmarshal and validate args. + var in In + if input != nil { + if err := internaljson.Unmarshal(input, &in); err != nil { + return nil, fmt.Errorf("%w: %v", jsonrpc2.ErrInvalidParams, err) + } + } + + // Call typed handler. + res, out, err := h(ctx, req, in) + // Handle server errors appropriately: + // - If the handler returns a structured error (like jsonrpc.Error), return it directly + // - If the handler returns a regular error, wrap it in a CallToolResult with IsError=true + // - This allows tools to distinguish between protocol errors and tool execution errors + if err != nil { + // Check if this is already a structured JSON-RPC error + if wireErr, ok := err.(*jsonrpc.Error); ok { + return nil, wireErr + } + // For regular errors, embed them in the tool result as per MCP spec + var errRes CallToolResult + errRes.SetError(err) + return &errRes, nil + } + + if res == nil { + res = &CallToolResult{} + } + + // Marshal the output and put the RawMessage in the StructuredContent field. + var outval any = out + if elemZero != nil { + // Avoid typed nil, which will serialize as JSON null. + // Instead, use the zero value of the unpointered type. + var z Out + if any(out) == any(z) { // zero is only non-nil if Out is a pointer type + outval = elemZero + } + } + if outval != nil { + outbytes, err := json.Marshal(outval) + if err != nil { + return nil, fmt.Errorf("marshaling output: %w", err) + } + outJSON := json.RawMessage(outbytes) + // Validate the output JSON, and apply defaults. + // + // We validate against the JSON, rather than the output value, as + // some types may have custom JSON marshalling (issue #447). + outJSON, err = applySchema(outJSON, outputResolved) + if err != nil { + return nil, fmt.Errorf("validating tool output: %w", err) + } + res.StructuredContent = outJSON // avoid a second marshal over the wire + + // If the Content field isn't being used, return the serialized JSON in a + // TextContent block, as the spec suggests: + // https://modelcontextprotocol.io/specification/2025-06-18/server/tools#structured-content. + if res.Content == nil { + res.Content = []Content{&TextContent{ + Text: string(outJSON), + }} + } + } + return res, nil + } // end of handler + + return &tt, th, nil +} + +// setSchema sets the schema and resolved schema corresponding to the type T. +// +// If sfield is nil, the schema is derived from T. +// +// Pointers are treated equivalently to non-pointers when deriving the schema. +// If an indirection occurred to derive the schema, a non-nil zero value is +// returned to be used in place of the typed nil zero value. +// +// Note that if sfield already holds a schema, zero will be nil even if T is a +// pointer: if the user provided the schema, they may have intentionally +// derived it from the pointer type, and handling of zero values is up to them. +// +// If cache is non-nil, schemas are cached to avoid repeated reflection. +// +// TODO(rfindley): we really shouldn't ever return 'null' results. Maybe we +// should have a jsonschema.Zero(schema) helper? +func setSchema[T any](sfield *any, rfield **jsonschema.Resolved, cache *SchemaCache) (zero any, err error) { + rt := reflect.TypeFor[T]() + if rt.Kind() == reflect.Pointer { + rt = rt.Elem() + zero = reflect.Zero(rt).Interface() + } + + var internalSchema *jsonschema.Schema + + if *sfield == nil { + // No schema provided: check cache, or generate via reflection. + if cache != nil { + if schema, resolved, ok := cache.getByType(rt); ok { + *sfield = schema + *rfield = resolved + return zero, nil + } + } + + internalSchema, err = jsonschema.ForType(rt, &jsonschema.ForOptions{}) + if err != nil { + return zero, err + } + *sfield = internalSchema + + resolved, err := internalSchema.Resolve(&jsonschema.ResolveOptions{ValidateDefaults: true}) + if err != nil { + return zero, err + } + *rfield = resolved + if cache != nil { + cache.setByType(rt, internalSchema, resolved) + } + return zero, nil + } + + // Schema was provided: check cache by pointer, or resolve it. + if providedSchema, ok := (*sfield).(*jsonschema.Schema); ok { + if cache != nil { + if resolved, ok := cache.getBySchema(providedSchema); ok { + *rfield = resolved + return zero, nil + } + } + internalSchema = providedSchema + } else { + // Schema provided as different type (e.g., map): remarshal to *Schema. + if err := remarshal(*sfield, &internalSchema); err != nil { + return zero, err + } + } + + resolved, err := internalSchema.Resolve(&jsonschema.ResolveOptions{ValidateDefaults: true}) + if err != nil { + return zero, err + } + *rfield = resolved + + if cache != nil { + if providedSchema, ok := (*sfield).(*jsonschema.Schema); ok { + cache.setBySchema(providedSchema, resolved) + } + } + + return zero, nil +} + +// AddTool adds a tool and typed tool handler to the server. +// +// If the tool's input schema is nil, it is set to the schema inferred from the +// In type parameter. Types are inferred from Go types, and property +// descriptions are read from the 'jsonschema' struct tag. Internally, the SDK +// uses the github.com/google/jsonschema-go package for inference and +// validation. The In type argument must be a map or a struct, so that its +// inferred JSON Schema has type "object", as required by the spec. As a +// special case, if the In type is 'any', the tool's input schema is set to an +// empty object schema value. +// +// If the tool's output schema is nil, and the Out type is not 'any', the +// output schema is set to the schema inferred from the Out type argument, +// which must also be a map or struct. If the Out type is 'any', the output +// schema is omitted. +// +// Unlike [Server.AddTool], AddTool does a lot automatically, and forces +// tools to conform to the MCP spec. See [ToolHandlerFor] for a detailed +// description of this automatic behavior. +func AddTool[In, Out any](s *Server, t *Tool, h ToolHandlerFor[In, Out]) { + tt, hh, err := toolForErr(t, h, s.opts.SchemaCache) + if err != nil { + panic(fmt.Sprintf("AddTool: tool %q: %v", t.Name, err)) + } + s.AddTool(tt, hh) +} + +// RemoveTools removes the tools with the given names. +// It is not an error to remove a nonexistent tool. +func (s *Server) RemoveTools(names ...string) { + s.changeAndNotify(notificationToolListChanged, func() bool { return s.tools.remove(names...) }) +} + +// AddResource adds a [Resource] to the server, or replaces one with the same URI. +// AddResource panics if the resource URI is invalid or not absolute (has an empty scheme). +func (s *Server) AddResource(r *Resource, h ResourceHandler) { + s.changeAndNotify(notificationResourceListChanged, + func() bool { + if _, err := url.Parse(r.URI); err != nil { + panic(err) // url.Parse includes the URI in the error + } + s.resources.add(&serverResource{r, h}) + return true + }) +} + +// RemoveResources removes the resources with the given URIs. +// It is not an error to remove a nonexistent resource. +func (s *Server) RemoveResources(uris ...string) { + s.changeAndNotify(notificationResourceListChanged, func() bool { return s.resources.remove(uris...) }) +} + +// AddResourceTemplate adds a [ResourceTemplate] to the server, or replaces one with the same URI. +// AddResourceTemplate panics if a URI template is invalid or not absolute (has an empty scheme). +func (s *Server) AddResourceTemplate(t *ResourceTemplate, h ResourceHandler) { + s.changeAndNotify(notificationResourceListChanged, + func() bool { + // Validate the URI template syntax + _, err := uritemplate.New(t.URITemplate) + if err != nil { + panic(fmt.Errorf("URI template %q is invalid: %w", t.URITemplate, err)) + } + s.resourceTemplates.add(&serverResourceTemplate{t, h}) + return true + }) +} + +// RemoveResourceTemplates removes the resource templates with the given URI templates. +// It is not an error to remove a nonexistent resource. +func (s *Server) RemoveResourceTemplates(uriTemplates ...string) { + s.changeAndNotify(notificationResourceListChanged, func() bool { return s.resourceTemplates.remove(uriTemplates...) }) +} + +func (s *Server) capabilities() *ServerCapabilities { + s.mu.Lock() + defer s.mu.Unlock() + + // Start with user-provided capabilities as defaults, or use SDK defaults. + var caps *ServerCapabilities + if s.opts.Capabilities != nil { + // Deep copy the user-provided capabilities to avoid mutation. + caps = s.opts.Capabilities.clone() + } else { + // SDK defaults: only logging capability. + caps = &ServerCapabilities{ + Logging: &LoggingCapabilities{}, + } + } + + // Augment with tools capability if tools exist or legacy HasTools is set. + if s.opts.HasTools || s.tools.len() > 0 { + if caps.Tools == nil { + caps.Tools = &ToolCapabilities{ListChanged: true} + } + } + + // Augment with prompts capability if prompts exist or legacy HasPrompts is set. + if s.opts.HasPrompts || s.prompts.len() > 0 { + if caps.Prompts == nil { + caps.Prompts = &PromptCapabilities{ListChanged: true} + } + } + + // Augment with resources capability if resources/templates exist or legacy HasResources is set. + if s.opts.HasResources || s.resources.len() > 0 || s.resourceTemplates.len() > 0 { + if caps.Resources == nil { + caps.Resources = &ResourceCapabilities{ListChanged: true} + } + if s.opts.SubscribeHandler != nil { + caps.Resources.Subscribe = true + } + } + + // Augment with completions capability if handler is set. + if s.opts.CompletionHandler != nil { + if caps.Completions == nil { + caps.Completions = &CompletionCapabilities{} + } + } + + return caps +} + +func (s *Server) complete(ctx context.Context, req *CompleteRequest) (*CompleteResult, error) { + if s.opts.CompletionHandler == nil { + return nil, jsonrpc2.ErrMethodNotFound + } + return s.opts.CompletionHandler(ctx, req) +} + +// Map from notification name to its corresponding params. The params have no fields, +// so a single struct can be reused. +var changeNotificationParams = map[string]Params{ + notificationToolListChanged: &ToolListChangedParams{}, + notificationPromptListChanged: &PromptListChangedParams{}, + notificationResourceListChanged: &ResourceListChangedParams{}, +} + +// How long to wait before sending a change notification. +const notificationDelay = 10 * time.Millisecond + +// changeAndNotify is called when a feature is added or removed. +// It calls change, which should do the work and report whether a change actually occurred. +// If there was a change, it sets a timer to send a notification. +// This debounces change notifications: a single notification is sent after +// multiple changes occur in close proximity. +func (s *Server) changeAndNotify(notification string, change func() bool) { + s.mu.Lock() + defer s.mu.Unlock() + if change() && s.shouldSendListChangedNotification(notification) { + // Reset the outstanding delayed call, if any. + if t := s.pendingNotifications[notification]; t == nil { + s.pendingNotifications[notification] = time.AfterFunc(notificationDelay, func() { s.notifySessions(notification) }) + } else { + t.Reset(notificationDelay) + } + } +} + +// notifySessions sends the notification n to all existing sessions. +// It is called asynchronously by changeAndNotify. +func (s *Server) notifySessions(n string) { + s.mu.Lock() + sessions := slices.Clone(s.sessions) + s.pendingNotifications[n] = nil + s.mu.Unlock() // Don't hold the lock during notification: it causes deadlock. + notifySessions(sessions, n, changeNotificationParams[n], s.opts.Logger) +} + +// shouldSendListChangedNotification checks if the server's capabilities allow +// sending the given list-changed notification. +func (s *Server) shouldSendListChangedNotification(notification string) bool { + // Get effective capabilities (considering user-provided defaults). + caps := s.opts.Capabilities + + switch notification { + case notificationToolListChanged: + // If user didn't specify capabilities, default behavior sends notifications. + if caps == nil || caps.Tools == nil { + return true + } + return caps.Tools.ListChanged + case notificationPromptListChanged: + if caps == nil || caps.Prompts == nil { + return true + } + return caps.Prompts.ListChanged + case notificationResourceListChanged: + if caps == nil || caps.Resources == nil { + return true + } + return caps.Resources.ListChanged + default: + // Unknown notification, allow by default. + return true + } +} + +// Sessions returns an iterator that yields the current set of server sessions. +// +// There is no guarantee that the iterator observes sessions that are added or +// removed during iteration. +func (s *Server) Sessions() iter.Seq[*ServerSession] { + s.mu.Lock() + clients := slices.Clone(s.sessions) + s.mu.Unlock() + return slices.Values(clients) +} + +func (s *Server) listPrompts(_ context.Context, req *ListPromptsRequest) (*ListPromptsResult, error) { + s.mu.Lock() + defer s.mu.Unlock() + if req.Params == nil { + req.Params = &ListPromptsParams{} + } + return paginateList(s.prompts, s.opts.PageSize, req.Params, &ListPromptsResult{}, func(res *ListPromptsResult, prompts []*serverPrompt) { + res.Prompts = []*Prompt{} // avoid JSON null + for _, p := range prompts { + res.Prompts = append(res.Prompts, p.prompt) + } + }) +} + +func (s *Server) getPrompt(ctx context.Context, req *GetPromptRequest) (*GetPromptResult, error) { + s.mu.Lock() + prompt, ok := s.prompts.get(req.Params.Name) + s.mu.Unlock() + if !ok { + // Return a proper JSON-RPC error with the correct error code + return nil, &jsonrpc.Error{ + Code: jsonrpc.CodeInvalidParams, + Message: fmt.Sprintf("unknown prompt %q", req.Params.Name), + } + } + return prompt.handler(ctx, req) +} + +func (s *Server) listTools(_ context.Context, req *ListToolsRequest) (*ListToolsResult, error) { + s.mu.Lock() + defer s.mu.Unlock() + if req.Params == nil { + req.Params = &ListToolsParams{} + } + return paginateList(s.tools, s.opts.PageSize, req.Params, &ListToolsResult{}, func(res *ListToolsResult, tools []*serverTool) { + res.Tools = []*Tool{} // avoid JSON null + for _, t := range tools { + res.Tools = append(res.Tools, t.tool) + } + }) +} + +func (s *Server) callTool(ctx context.Context, req *CallToolRequest) (*CallToolResult, error) { + s.mu.Lock() + st, ok := s.tools.get(req.Params.Name) + s.mu.Unlock() + if !ok { + return nil, &jsonrpc.Error{ + Code: jsonrpc.CodeInvalidParams, + Message: fmt.Sprintf("unknown tool %q", req.Params.Name), + } + } + res, err := st.handler(ctx, req) + if err == nil && res != nil && res.Content == nil { + res2 := *res + res2.Content = []Content{} // avoid "null" + res = &res2 + } + return res, err +} + +func (s *Server) listResources(_ context.Context, req *ListResourcesRequest) (*ListResourcesResult, error) { + s.mu.Lock() + defer s.mu.Unlock() + if req.Params == nil { + req.Params = &ListResourcesParams{} + } + return paginateList(s.resources, s.opts.PageSize, req.Params, &ListResourcesResult{}, func(res *ListResourcesResult, resources []*serverResource) { + res.Resources = []*Resource{} // avoid JSON null + for _, r := range resources { + res.Resources = append(res.Resources, r.resource) + } + }) +} + +func (s *Server) listResourceTemplates(_ context.Context, req *ListResourceTemplatesRequest) (*ListResourceTemplatesResult, error) { + s.mu.Lock() + defer s.mu.Unlock() + if req.Params == nil { + req.Params = &ListResourceTemplatesParams{} + } + return paginateList(s.resourceTemplates, s.opts.PageSize, req.Params, &ListResourceTemplatesResult{}, + func(res *ListResourceTemplatesResult, rts []*serverResourceTemplate) { + res.ResourceTemplates = []*ResourceTemplate{} // avoid JSON null + for _, rt := range rts { + res.ResourceTemplates = append(res.ResourceTemplates, rt.resourceTemplate) + } + }) +} + +func (s *Server) readResource(ctx context.Context, req *ReadResourceRequest) (*ReadResourceResult, error) { + uri := req.Params.URI + // Look up the resource URI in the lists of resources and resource templates. + // This is a security check as well as an information lookup. + handler, mimeType, ok := s.lookupResourceHandler(uri) + if !ok { + // Don't expose the server configuration to the client. + // Treat an unregistered resource the same as a registered one that couldn't be found. + return nil, ResourceNotFoundError(uri) + } + res, err := handler(ctx, req) + if err != nil { + return nil, err + } + if res == nil || res.Contents == nil { + return nil, fmt.Errorf("reading resource %s: read handler returned nil information", uri) + } + // As a convenience, populate some fields. + for _, c := range res.Contents { + if c.URI == "" { + c.URI = uri + } + if c.MIMEType == "" { + c.MIMEType = mimeType + } + } + return res, nil +} + +// lookupResourceHandler returns the resource handler and MIME type for the resource or +// resource template matching uri. If none, the last return value is false. +func (s *Server) lookupResourceHandler(uri string) (ResourceHandler, string, bool) { + s.mu.Lock() + defer s.mu.Unlock() + // Try resources first. + if r, ok := s.resources.get(uri); ok { + return r.handler, r.resource.MIMEType, true + } + // Look for matching template. + for rt := range s.resourceTemplates.all() { + if rt.Matches(uri) { + return rt.handler, rt.resourceTemplate.MIMEType, true + } + } + return nil, "", false +} + +// fileResourceHandler returns a ReadResourceHandler that reads paths using dir as +// a base directory. +// It honors client roots and protects against path traversal attacks. +// +// The dir argument should be a filesystem path. It need not be absolute, but +// that is recommended to avoid a dependency on the current working directory (the +// check against client roots is done with an absolute path). If dir is not absolute +// and the current working directory is unavailable, fileResourceHandler panics. +// +// Lexical path traversal attacks, where the path has ".." elements that escape dir, +// are always caught. Go 1.24 and above also protects against symlink-based attacks, +// where symlinks under dir lead out of the tree. +func fileResourceHandler(dir string) ResourceHandler { + // Convert dir to an absolute path. + dirFilepath, err := filepath.Abs(dir) + if err != nil { + panic(err) + } + return func(ctx context.Context, req *ReadResourceRequest) (_ *ReadResourceResult, err error) { + defer util.Wrapf(&err, "reading resource %s", req.Params.URI) + + // TODO(#25): use a memoizing API here. + rootRes, err := req.Session.ListRoots(ctx, nil) + if err != nil { + return nil, fmt.Errorf("listing roots: %w", err) + } + roots, err := fileRoots(rootRes.Roots) + if err != nil { + return nil, err + } + data, err := readFileResource(req.Params.URI, dirFilepath, roots) + if err != nil { + return nil, err + } + // TODO(jba): figure out mime type. Omit for now: Server.readResource will fill it in. + return &ReadResourceResult{Contents: []*ResourceContents{ + {URI: req.Params.URI, Blob: data}, + }}, nil + } +} + +// ResourceUpdated sends a notification to all clients that have subscribed to the +// resource specified in params. This method is the primary way for a +// server author to signal that a resource has changed. +func (s *Server) ResourceUpdated(ctx context.Context, params *ResourceUpdatedNotificationParams) error { + s.mu.Lock() + subscribedSessions := s.resourceSubscriptions[params.URI] + sessions := slices.Collect(maps.Keys(subscribedSessions)) + s.mu.Unlock() + notifySessions(sessions, notificationResourceUpdated, params, s.opts.Logger) + s.opts.Logger.Info("resource updated notification sent", "uri", params.URI, "subscriber_count", len(sessions)) + return nil +} + +func (s *Server) subscribe(ctx context.Context, req *SubscribeRequest) (*emptyResult, error) { + if s.opts.SubscribeHandler == nil { + return nil, fmt.Errorf("%w: server does not support resource subscriptions", jsonrpc2.ErrMethodNotFound) + } + if err := s.opts.SubscribeHandler(ctx, req); err != nil { + return nil, err + } + + s.mu.Lock() + defer s.mu.Unlock() + if s.resourceSubscriptions[req.Params.URI] == nil { + s.resourceSubscriptions[req.Params.URI] = make(map[*ServerSession]bool) + } + s.resourceSubscriptions[req.Params.URI][req.Session] = true + s.opts.Logger.Info("resource subscribed", "uri", req.Params.URI, "session_id", req.Session.ID()) + + return &emptyResult{}, nil +} + +func (s *Server) unsubscribe(ctx context.Context, req *UnsubscribeRequest) (*emptyResult, error) { + if s.opts.UnsubscribeHandler == nil { + return nil, jsonrpc2.ErrMethodNotFound + } + + if err := s.opts.UnsubscribeHandler(ctx, req); err != nil { + return nil, err + } + + s.mu.Lock() + defer s.mu.Unlock() + if subscribedSessions, ok := s.resourceSubscriptions[req.Params.URI]; ok { + delete(subscribedSessions, req.Session) + if len(subscribedSessions) == 0 { + delete(s.resourceSubscriptions, req.Params.URI) + } + } + s.opts.Logger.Info("resource unsubscribed", "uri", req.Params.URI, "session_id", req.Session.ID()) + + return &emptyResult{}, nil +} + +// Run runs the server over the given transport, which must be persistent. +// +// Run blocks until the client terminates the connection or the provided +// context is cancelled. If the context is cancelled, Run closes the connection. +// +// If tools have been added to the server before this call, then the server will +// advertise the capability for tools, including the ability to send list-changed notifications. +// If no tools have been added, the server will not have the tool capability. +// The same goes for other features like prompts and resources. +// +// Run is a convenience for servers that handle a single session (or one session at a time). +// It need not be called on servers that are used for multiple concurrent connections, +// as with [StreamableHTTPHandler]. +func (s *Server) Run(ctx context.Context, t Transport) error { + s.opts.Logger.Info("server run start") + ss, err := s.Connect(ctx, t, nil) + if err != nil { + s.opts.Logger.Error("server connect failed", "error", err) + return err + } + + ssClosed := make(chan error) + go func() { + ssClosed <- ss.Wait() + }() + + select { + case <-ctx.Done(): + ss.Close() + <-ssClosed // wait until waiting go routine above actually completes + s.opts.Logger.Error("server run cancelled", "error", ctx.Err()) + return ctx.Err() + case err := <-ssClosed: + if err != nil { + s.opts.Logger.Error("server session ended with error", "error", err) + } else { + s.opts.Logger.Info("server session ended") + } + return err + } +} + +// bind implements the binder[*ServerSession] interface, so that Servers can +// be connected using [connect]. +func (s *Server) bind(mcpConn Connection, conn *jsonrpc2.Connection, state *ServerSessionState, onClose func()) *ServerSession { + assert(mcpConn != nil && conn != nil, "nil connection") + ss := &ServerSession{conn: conn, mcpConn: mcpConn, server: s, onClose: onClose} + if state != nil { + ss.state = *state + } + s.mu.Lock() + s.sessions = append(s.sessions, ss) + s.mu.Unlock() + s.opts.Logger.Info("server session connected", "session_id", ss.ID()) + return ss +} + +// disconnect implements the binder[*ServerSession] interface, so that +// Servers can be connected using [connect]. +func (s *Server) disconnect(cc *ServerSession) { + s.mu.Lock() + defer s.mu.Unlock() + s.sessions = slices.DeleteFunc(s.sessions, func(cc2 *ServerSession) bool { + return cc2 == cc + }) + + for _, subscribedSessions := range s.resourceSubscriptions { + delete(subscribedSessions, cc) + } + s.opts.Logger.Info("server session disconnected", "session_id", cc.ID()) +} + +// ServerSessionOptions configures the server session. +type ServerSessionOptions struct { + State *ServerSessionState + + onClose func() // used to clean up associated resources +} + +// Connect connects the MCP server over the given transport and starts handling +// messages. +// +// It returns a connection object that may be used to terminate the connection +// (with [Connection.Close]), or await client termination (with +// [Connection.Wait]). +// +// If opts.State is non-nil, it is the initial state for the server. +func (s *Server) Connect(ctx context.Context, t Transport, opts *ServerSessionOptions) (*ServerSession, error) { + var state *ServerSessionState + var onClose func() + if opts != nil { + state = opts.State + onClose = opts.onClose + } + + s.opts.Logger.Info("server connecting") + ss, err := connect(ctx, t, s, state, onClose) + if err != nil { + s.opts.Logger.Error("server connect error", "error", err) + return nil, err + } + return ss, nil +} + +// TODO: (nit) move all ServerSession methods below the ServerSession declaration. +func (ss *ServerSession) initialized(ctx context.Context, params *InitializedParams) (Result, error) { + if params == nil { + // Since we use nilness to signal 'initialized' state, we must ensure that + // params are non-nil. + params = new(InitializedParams) + } + var wasInit, wasInitd bool + ss.updateState(func(state *ServerSessionState) { + wasInit = state.InitializeParams != nil + wasInitd = state.InitializedParams != nil + if wasInit && !wasInitd { + state.InitializedParams = params + } + }) + + if !wasInit { + ss.server.opts.Logger.Error("initialized before initialize") + return nil, fmt.Errorf("%q before %q", notificationInitialized, methodInitialize) + } + if wasInitd { + ss.server.opts.Logger.Error("duplicate initialized notification") + return nil, fmt.Errorf("duplicate %q received", notificationInitialized) + } + if ss.server.opts.KeepAlive > 0 { + ss.startKeepalive(ss.server.opts.KeepAlive) + } + if h := ss.server.opts.InitializedHandler; h != nil { + h(ctx, serverRequestFor(ss, params)) + } + ss.server.opts.Logger.Info("session initialized") + return nil, nil +} + +func (s *Server) callRootsListChangedHandler(ctx context.Context, req *RootsListChangedRequest) (Result, error) { + if h := s.opts.RootsListChangedHandler; h != nil { + h(ctx, req) + } + return nil, nil +} + +func (ss *ServerSession) callProgressNotificationHandler(ctx context.Context, p *ProgressNotificationParams) (Result, error) { + if h := ss.server.opts.ProgressNotificationHandler; h != nil { + h(ctx, serverRequestFor(ss, p)) + } + return nil, nil +} + +// NotifyProgress sends a progress notification from the server to the client +// associated with this session. +// This is typically used to report on the status of a long-running request +// that was initiated by the client. +func (ss *ServerSession) NotifyProgress(ctx context.Context, params *ProgressNotificationParams) error { + return handleNotify(ctx, notificationProgress, newServerRequest(ss, orZero[Params](params))) +} + +func newServerRequest[P Params](ss *ServerSession, params P) *ServerRequest[P] { + return &ServerRequest[P]{Session: ss, Params: params} +} + +// A ServerSession is a logical connection from a single MCP client. Its +// methods can be used to send requests or notifications to the client. Create +// a session by calling [Server.Connect]. +// +// Call [ServerSession.Close] to close the connection, or await client +// termination with [ServerSession.Wait]. +type ServerSession struct { + // Ensure that onClose is called at most once. + // We defensively use an atomic CompareAndSwap rather than a sync.Once, in case the + // onClose callback triggers a re-entrant call to Close. + calledOnClose atomic.Bool + onClose func() + + server *Server + conn *jsonrpc2.Connection + mcpConn Connection + keepaliveCancel context.CancelFunc // TODO: theory around why keepaliveCancel need not be guarded + + mu sync.Mutex + state ServerSessionState +} + +func (ss *ServerSession) updateState(mut func(*ServerSessionState)) { + ss.mu.Lock() + mut(&ss.state) + copy := ss.state + ss.mu.Unlock() + if c, ok := ss.mcpConn.(serverConnection); ok { + c.sessionUpdated(copy) + } +} + +// hasInitialized reports whether the server has received the initialized +// notification. +// +// TODO(findleyr): use this to prevent change notifications. +func (ss *ServerSession) hasInitialized() bool { + ss.mu.Lock() + defer ss.mu.Unlock() + return ss.state.InitializedParams != nil +} + +// checkInitialized returns a formatted error if the server has not yet +// received the initialized notification. +func (ss *ServerSession) checkInitialized(method string) error { + if !ss.hasInitialized() { + // TODO(rfindley): enable this check. + // Right now is is flaky, because server tests don't await the initialized notification. + // Perhaps requests should simply block until they have received the initialized notification + + // if strings.HasPrefix(method, "notifications/") { + // return fmt.Errorf("must not send %q before %q is received", method, notificationInitialized) + // } else { + // return fmt.Errorf("cannot call %q before %q is received", method, notificationInitialized) + // } + } + return nil +} + +func (ss *ServerSession) ID() string { + if c, ok := ss.mcpConn.(hasSessionID); ok { + return c.SessionID() + } + return "" +} + +// Ping pings the client. +func (ss *ServerSession) Ping(ctx context.Context, params *PingParams) error { + _, err := handleSend[*emptyResult](ctx, methodPing, newServerRequest(ss, orZero[Params](params))) + return err +} + +// ListRoots lists the client roots. +func (ss *ServerSession) ListRoots(ctx context.Context, params *ListRootsParams) (*ListRootsResult, error) { + if err := ss.checkInitialized(methodListRoots); err != nil { + return nil, err + } + return handleSend[*ListRootsResult](ctx, methodListRoots, newServerRequest(ss, orZero[Params](params))) +} + +// CreateMessage sends a sampling request to the client. +// +// If the client returns multiple content blocks (e.g. parallel tool calls), +// CreateMessage returns an error. Use [ServerSession.CreateMessageWithTools] +// for tool-enabled sampling. +func (ss *ServerSession) CreateMessage(ctx context.Context, params *CreateMessageParams) (*CreateMessageResult, error) { + if err := ss.checkInitialized(methodCreateMessage); err != nil { + return nil, err + } + if params == nil { + params = &CreateMessageParams{Messages: []*SamplingMessage{}} + } + if params.Messages == nil { + p2 := *params + p2.Messages = []*SamplingMessage{} // avoid JSON "null" + params = &p2 + } + res, err := handleSend[*CreateMessageWithToolsResult](ctx, methodCreateMessage, newServerRequest(ss, orZero[Params](params))) + if err != nil { + return nil, err + } + // Downconvert to singular content. + if len(res.Content) > 1 { + return nil, fmt.Errorf("CreateMessage result has %d content blocks; use CreateMessageWithTools for multiple content", len(res.Content)) + } + var content Content + if len(res.Content) > 0 { + content = res.Content[0] + } + return &CreateMessageResult{ + Meta: res.Meta, + Content: content, + Model: res.Model, + Role: res.Role, + StopReason: res.StopReason, + }, nil +} + +// CreateMessageWithTools sends a sampling request with tools to the client, +// returning a [CreateMessageWithToolsResult] that supports array content +// (for parallel tool calls). Use this instead of [ServerSession.CreateMessage] +// when the request includes tools. +func (ss *ServerSession) CreateMessageWithTools(ctx context.Context, params *CreateMessageWithToolsParams) (*CreateMessageWithToolsResult, error) { + if err := ss.checkInitialized(methodCreateMessage); err != nil { + return nil, err + } + if params == nil { + params = &CreateMessageWithToolsParams{Messages: []*SamplingMessageV2{}} + } + if params.Messages == nil { + p2 := *params + p2.Messages = []*SamplingMessageV2{} // avoid JSON "null" + params = &p2 + } + return handleSend[*CreateMessageWithToolsResult](ctx, methodCreateMessage, newServerRequest(ss, orZero[Params](params))) +} + +// Elicit sends an elicitation request to the client asking for user input. +func (ss *ServerSession) Elicit(ctx context.Context, params *ElicitParams) (*ElicitResult, error) { + if err := ss.checkInitialized(methodElicit); err != nil { + return nil, err + } + if params == nil { + return nil, fmt.Errorf("%w: params cannot be nil", jsonrpc2.ErrInvalidParams) + } + + if params.Mode == "" { + params2 := *params + if params.URL != "" || params.ElicitationID != "" { + params2.Mode = "url" + } else { + params2.Mode = "form" + } + params = ¶ms2 + } + + if iparams := ss.InitializeParams(); iparams == nil || iparams.Capabilities == nil || iparams.Capabilities.Elicitation == nil { + return nil, fmt.Errorf("client does not support elicitation") + } + caps := ss.InitializeParams().Capabilities.Elicitation + switch params.Mode { + case "form": + if caps.Form == nil && caps.URL != nil { + // Note: if both 'Form' and 'URL' are nil, we assume the client supports + // form elicitation for backward compatibility. + return nil, errors.New(`client does not support "form" elicitation`) + } + case "url": + if caps.URL == nil { + return nil, errors.New(`client does not support "url" elicitation`) + } + } + + res, err := handleSend[*ElicitResult](ctx, methodElicit, newServerRequest(ss, orZero[Params](params))) + if err != nil { + return nil, err + } + + if res.Action != "accept" { + return res, nil + } + + if params.RequestedSchema == nil { + return res, nil + } + schema, err := validateElicitSchema(params.RequestedSchema) + if err != nil { + return nil, err + } + if schema == nil { + return res, nil + } + + resolved, err := schema.Resolve(nil) + if err != nil { + return nil, err + } + if err := resolved.Validate(res.Content); err != nil { + return nil, fmt.Errorf("elicitation result content does not match requested schema: %v", err) + } + err = resolved.ApplyDefaults(&res.Content) + if err != nil { + return nil, fmt.Errorf("failed to apply schema defalts to elicitation result: %v", err) + } + + return res, nil +} + +// Log sends a log message to the client. +// The message is not sent if the client has not called SetLevel, or if its level +// is below that of the last SetLevel. +func (ss *ServerSession) Log(ctx context.Context, params *LoggingMessageParams) error { + ss.mu.Lock() + logLevel := ss.state.LogLevel + ss.mu.Unlock() + if logLevel == "" { + // The spec is unclear, but seems to imply that no log messages are sent until the client + // sets the level. + // TODO(jba): read other SDKs, possibly file an issue. + return nil + } + if compareLevels(params.Level, logLevel) < 0 { + return nil + } + return handleNotify(ctx, notificationLoggingMessage, newServerRequest(ss, orZero[Params](params))) +} + +// AddSendingMiddleware wraps the current sending method handler using the provided +// middleware. Middleware is applied from right to left, so that the first one is +// executed first. +// +// For example, AddSendingMiddleware(m1, m2, m3) augments the method handler as +// m1(m2(m3(handler))). +// +// Sending middleware is called when a request is sent. It is useful for tasks +// such as tracing, metrics, and adding progress tokens. +func (s *Server) AddSendingMiddleware(middleware ...Middleware) { + s.mu.Lock() + defer s.mu.Unlock() + addMiddleware(&s.sendingMethodHandler_, middleware) +} + +// AddReceivingMiddleware wraps the current receiving method handler using +// the provided middleware. Middleware is applied from right to left, so that the +// first one is executed first. +// +// For example, AddReceivingMiddleware(m1, m2, m3) augments the method handler as +// m1(m2(m3(handler))). +// +// Receiving middleware is called when a request is received. It is useful for tasks +// such as authentication, request logging and metrics. +func (s *Server) AddReceivingMiddleware(middleware ...Middleware) { + s.mu.Lock() + defer s.mu.Unlock() + addMiddleware(&s.receivingMethodHandler_, middleware) +} + +// serverMethodInfos maps from the RPC method name to serverMethodInfos. +// +// The 'allowMissingParams' values are extracted from the protocol schema. +// TODO(rfindley): actually load and validate the protocol schema, rather than +// curating these method flags. +var serverMethodInfos = map[string]methodInfo{ + methodComplete: newServerMethodInfo(serverMethod((*Server).complete), 0), + methodInitialize: initializeMethodInfo(), + methodPing: newServerMethodInfo(serverSessionMethod((*ServerSession).ping), missingParamsOK), + methodListPrompts: newServerMethodInfo(serverMethod((*Server).listPrompts), missingParamsOK), + methodGetPrompt: newServerMethodInfo(serverMethod((*Server).getPrompt), 0), + methodListTools: newServerMethodInfo(serverMethod((*Server).listTools), missingParamsOK), + methodCallTool: newServerMethodInfo(serverMethod((*Server).callTool), 0), + methodListResources: newServerMethodInfo(serverMethod((*Server).listResources), missingParamsOK), + methodListResourceTemplates: newServerMethodInfo(serverMethod((*Server).listResourceTemplates), missingParamsOK), + methodReadResource: newServerMethodInfo(serverMethod((*Server).readResource), 0), + methodSetLevel: newServerMethodInfo(serverSessionMethod((*ServerSession).setLevel), 0), + methodSubscribe: newServerMethodInfo(serverMethod((*Server).subscribe), 0), + methodUnsubscribe: newServerMethodInfo(serverMethod((*Server).unsubscribe), 0), + notificationCancelled: newServerMethodInfo(serverSessionMethod((*ServerSession).cancel), notification|missingParamsOK), + notificationInitialized: newServerMethodInfo(serverSessionMethod((*ServerSession).initialized), notification|missingParamsOK), + notificationRootsListChanged: newServerMethodInfo(serverMethod((*Server).callRootsListChangedHandler), notification|missingParamsOK), + notificationProgress: newServerMethodInfo(serverSessionMethod((*ServerSession).callProgressNotificationHandler), notification), +} + +// initializeMethodInfo handles the workaround for #607: we must set +// params.Capabilities.RootsV2. +func initializeMethodInfo() methodInfo { + info := newServerMethodInfo(serverSessionMethod((*ServerSession).initialize), 0) + info.unmarshalParams = func(m json.RawMessage) (Params, error) { + var params *initializeParamsV2 + if m != nil { + if err := internaljson.Unmarshal(m, ¶ms); err != nil { + return nil, fmt.Errorf("unmarshaling %q into a %T: %w", m, params, err) + } + } + if params == nil { + return nil, fmt.Errorf(`missing required "params"`) + } + return params.toV1(), nil + } + return info +} + +func (ss *ServerSession) sendingMethodInfos() map[string]methodInfo { return clientMethodInfos } + +func (ss *ServerSession) receivingMethodInfos() map[string]methodInfo { return serverMethodInfos } + +func (ss *ServerSession) sendingMethodHandler() MethodHandler { + s := ss.server + s.mu.Lock() + defer s.mu.Unlock() + return s.sendingMethodHandler_ +} + +func (ss *ServerSession) receivingMethodHandler() MethodHandler { + s := ss.server + s.mu.Lock() + defer s.mu.Unlock() + return s.receivingMethodHandler_ +} + +// getConn implements [session.getConn]. +func (ss *ServerSession) getConn() *jsonrpc2.Connection { return ss.conn } + +// handle invokes the method described by the given JSON RPC request. +func (ss *ServerSession) handle(ctx context.Context, req *jsonrpc.Request) (any, error) { + ss.mu.Lock() + initialized := ss.state.InitializeParams != nil + ss.mu.Unlock() + + // From the spec: + // "The client SHOULD NOT send requests other than pings before the server + // has responded to the initialize request." + switch req.Method { + case methodInitialize, methodPing, notificationInitialized: + default: + if !initialized { + ss.server.opts.Logger.Error("method invalid during initialization", "method", req.Method) + return nil, fmt.Errorf("method %q is invalid during session initialization", req.Method) + } + } + + // modelcontextprotocol/go-sdk#26: handle calls asynchronously, and + // notifications synchronously, except for 'initialize' which shouldn't be + // asynchronous to other + if req.IsCall() && req.Method != methodInitialize { + jsonrpc2.Async(ctx) + } + + // For the streamable transport, we need the request ID to correlate + // server->client calls and notifications to the incoming request from which + // they originated. See [idContextKey] for details. + ctx = context.WithValue(ctx, idContextKey{}, req.ID) + return handleReceive(ctx, ss, req) +} + +// InitializeParams returns the InitializeParams provided during the client's +// initial connection. +func (ss *ServerSession) InitializeParams() *InitializeParams { + ss.mu.Lock() + defer ss.mu.Unlock() + return ss.state.InitializeParams +} + +func (ss *ServerSession) initialize(ctx context.Context, params *InitializeParams) (*InitializeResult, error) { + if params == nil { + return nil, fmt.Errorf("%w: \"params\" must be be provided", jsonrpc2.ErrInvalidParams) + } + ss.updateState(func(state *ServerSessionState) { + state.InitializeParams = params + }) + + s := ss.server + return &InitializeResult{ + // TODO(rfindley): alter behavior when falling back to an older version: + // reject unsupported features. + ProtocolVersion: negotiatedVersion(params.ProtocolVersion), + Capabilities: s.capabilities(), + Instructions: s.opts.Instructions, + ServerInfo: s.impl, + }, nil +} + +func (ss *ServerSession) ping(context.Context, *PingParams) (*emptyResult, error) { + return &emptyResult{}, nil +} + +// cancel is a placeholder: cancellation is handled the jsonrpc2 package. +// +// It should never be invoked in practice because cancellation is preempted, +// but having its signature here facilitates the construction of methodInfo +// that can be used to validate incoming cancellation notifications. +func (ss *ServerSession) cancel(context.Context, *CancelledParams) (Result, error) { + return nil, nil +} + +func (ss *ServerSession) setLevel(_ context.Context, params *SetLoggingLevelParams) (*emptyResult, error) { + ss.updateState(func(state *ServerSessionState) { + state.LogLevel = params.Level + }) + ss.server.opts.Logger.Info("client log level set", "level", params.Level) + return &emptyResult{}, nil +} + +// Close performs a graceful shutdown of the connection, preventing new +// requests from being handled, and waiting for ongoing requests to return. +// Close then terminates the connection. +// +// Close is idempotent and concurrency safe. +func (ss *ServerSession) Close() error { + if ss.keepaliveCancel != nil { + // Note: keepaliveCancel access is safe without a mutex because: + // 1. keepaliveCancel is only written once during startKeepalive (happens-before all Close calls) + // 2. context.CancelFunc is safe to call multiple times and from multiple goroutines + // 3. The keepalive goroutine calls Close on ping failure, but this is safe since + // Close is idempotent and conn.Close() handles concurrent calls correctly + ss.keepaliveCancel() + } + err := ss.conn.Close() + + if ss.onClose != nil && ss.calledOnClose.CompareAndSwap(false, true) { + ss.onClose() + } + + return err +} + +// Wait waits for the connection to be closed by the client. +func (ss *ServerSession) Wait() error { + return ss.conn.Wait() +} + +// startKeepalive starts the keepalive mechanism for this server session. +func (ss *ServerSession) startKeepalive(interval time.Duration) { + startKeepalive(ss, interval, &ss.keepaliveCancel) +} + +// pageToken is the internal structure for the opaque pagination cursor. +// It will be Gob-encoded and then Base64-encoded for use as a string token. +type pageToken struct { + LastUID string // The unique ID of the last resource seen. +} + +// encodeCursor encodes a unique identifier (UID) into a opaque pagination cursor +// by serializing a pageToken struct. +func encodeCursor(uid string) (string, error) { + var buf bytes.Buffer + token := pageToken{LastUID: uid} + encoder := gob.NewEncoder(&buf) + if err := encoder.Encode(token); err != nil { + return "", fmt.Errorf("failed to encode page token: %w", err) + } + return base64.URLEncoding.EncodeToString(buf.Bytes()), nil +} + +// decodeCursor decodes an opaque pagination cursor into the original pageToken struct. +func decodeCursor(cursor string) (*pageToken, error) { + decodedBytes, err := base64.URLEncoding.DecodeString(cursor) + if err != nil { + return nil, fmt.Errorf("failed to decode cursor: %w", err) + } + + var token pageToken + buf := bytes.NewBuffer(decodedBytes) + decoder := gob.NewDecoder(buf) + if err := decoder.Decode(&token); err != nil { + return nil, fmt.Errorf("failed to decode page token: %w, cursor: %v", err, cursor) + } + return &token, nil +} + +// paginateList is a generic helper that returns a paginated slice of items +// from a featureSet. It populates the provided result res with the items +// and sets its next cursor for subsequent pages. +// If there are no more pages, the next cursor within the result will be an empty string. +func paginateList[P listParams, R listResult[T], T any](fs *featureSet[T], pageSize int, params P, res R, setFunc func(R, []T)) (R, error) { + var seq iter.Seq[T] + if params.cursorPtr() == nil || *params.cursorPtr() == "" { + seq = fs.all() + } else { + pageToken, err := decodeCursor(*params.cursorPtr()) + // According to the spec, invalid cursors should return Invalid params. + if err != nil { + var zero R + return zero, jsonrpc2.ErrInvalidParams + } + seq = fs.above(pageToken.LastUID) + } + var count int + var features []T + for f := range seq { + count++ + // If we've seen pageSize + 1 elements, we've gathered enough info to determine + // if there's a next page. Stop processing the sequence. + if count == pageSize+1 { + break + } + features = append(features, f) + } + setFunc(res, features) + // No remaining pages. + if count < pageSize+1 { + return res, nil + } + nextCursor, err := encodeCursor(fs.uniqueID(features[len(features)-1])) + if err != nil { + var zero R + return zero, err + } + *res.nextCursorPtr() = nextCursor + return res, nil +} diff --git a/mcp/dsx-exchange-mcp/vendor/github.com/modelcontextprotocol/go-sdk/mcp/session.go b/mcp/dsx-exchange-mcp/vendor/github.com/modelcontextprotocol/go-sdk/mcp/session.go new file mode 100644 index 0000000..dcf9888 --- /dev/null +++ b/mcp/dsx-exchange-mcp/vendor/github.com/modelcontextprotocol/go-sdk/mcp/session.go @@ -0,0 +1,29 @@ +// Copyright 2025 The Go MCP SDK Authors. All rights reserved. +// Use of this source code is governed by an MIT-style +// license that can be found in the LICENSE file. + +package mcp + +// hasSessionID is the interface which, if implemented by connections, informs +// the session about their session ID. +// +// TODO(rfindley): remove SessionID methods from connections, when it doesn't +// make sense. Or remove it from the Sessions entirely: why does it even need +// to be exposed? +type hasSessionID interface { + SessionID() string +} + +// ServerSessionState is the state of a session. +type ServerSessionState struct { + // InitializeParams are the parameters from 'initialize'. + InitializeParams *InitializeParams `json:"initializeParams"` + + // InitializedParams are the parameters from 'notifications/initialized'. + InitializedParams *InitializedParams `json:"initializedParams"` + + // LogLevel is the logging level for the session. + LogLevel LoggingLevel `json:"logLevel"` + + // TODO: resource subscriptions +} diff --git a/mcp/dsx-exchange-mcp/vendor/github.com/modelcontextprotocol/go-sdk/mcp/shared.go b/mcp/dsx-exchange-mcp/vendor/github.com/modelcontextprotocol/go-sdk/mcp/shared.go new file mode 100644 index 0000000..bda00c2 --- /dev/null +++ b/mcp/dsx-exchange-mcp/vendor/github.com/modelcontextprotocol/go-sdk/mcp/shared.go @@ -0,0 +1,611 @@ +// Copyright 2025 The Go MCP SDK Authors. All rights reserved. +// Use of this source code is governed by an MIT-style +// license that can be found in the LICENSE file. + +// This file contains code shared between client and server, including +// method handler and middleware definitions. +// +// Much of this is here so that we can factor out commonalities using +// generics. If this becomes unwieldy, it can perhaps be simplified with +// reflection. + +package mcp + +import ( + "context" + "encoding/json" + "fmt" + "log/slog" + "net/http" + "reflect" + "slices" + "strings" + "time" + + "github.com/modelcontextprotocol/go-sdk/auth" + internaljson "github.com/modelcontextprotocol/go-sdk/internal/json" + "github.com/modelcontextprotocol/go-sdk/internal/jsonrpc2" + "github.com/modelcontextprotocol/go-sdk/jsonrpc" +) + +const ( + // latestProtocolVersion is the latest protocol version that this version of + // the SDK supports. + // + // It is the version that the client sends in the initialization request, and + // the default version used by the server. + latestProtocolVersion = protocolVersion20250618 + protocolVersion20251125 = "2025-11-25" // not yet released + protocolVersion20250618 = "2025-06-18" + protocolVersion20250326 = "2025-03-26" + protocolVersion20241105 = "2024-11-05" +) + +var supportedProtocolVersions = []string{ + protocolVersion20251125, + protocolVersion20250618, + protocolVersion20250326, + protocolVersion20241105, +} + +// negotiatedVersion returns the effective protocol version to use, given a +// client version. +func negotiatedVersion(clientVersion string) string { + // In general, prefer to use the clientVersion, but if we don't support the + // client's version, use the latest version. + // + // This handles the case where a new spec version is released, and the SDK + // does not support it yet. + if !slices.Contains(supportedProtocolVersions, clientVersion) { + return latestProtocolVersion + } + return clientVersion +} + +// A MethodHandler handles MCP messages. +// For methods, exactly one of the return values must be nil. +// For notifications, both must be nil. +type MethodHandler func(ctx context.Context, method string, req Request) (result Result, err error) + +// A Session is either a [ClientSession] or a [ServerSession]. +type Session interface { + // ID returns the session ID, or the empty string if there is none. + ID() string + + sendingMethodInfos() map[string]methodInfo + receivingMethodInfos() map[string]methodInfo + sendingMethodHandler() MethodHandler + receivingMethodHandler() MethodHandler + getConn() *jsonrpc2.Connection +} + +// Middleware is a function from [MethodHandler] to [MethodHandler]. +type Middleware func(MethodHandler) MethodHandler + +// addMiddleware wraps the handler in the middleware functions. +func addMiddleware(handlerp *MethodHandler, middleware []Middleware) { + for _, m := range slices.Backward(middleware) { + *handlerp = m(*handlerp) + } +} + +func defaultSendingMethodHandler(ctx context.Context, method string, req Request) (Result, error) { + info, ok := req.GetSession().sendingMethodInfos()[method] + if !ok { + // This can be called from user code, with an arbitrary value for method. + return nil, jsonrpc2.ErrNotHandled + } + params := req.GetParams() + if initParams, ok := params.(*InitializeParams); ok { + // Fix the marshaling of initialize params, to work around #607. + // + // The initialize params we produce should never be nil, nor have nil + // capabilities, so any panic here is a bug. + params = initParams.toV2() + } + // Notifications don't have results. + if strings.HasPrefix(method, "notifications/") { + return nil, req.GetSession().getConn().Notify(ctx, method, params) + } + // Create the result to unmarshal into. + // The concrete type of the result is the return type of the receiving function. + res := info.newResult() + if err := call(ctx, req.GetSession().getConn(), method, params, res); err != nil { + return nil, err + } + return res, nil +} + +// Helper method to avoid typed nil. +func orZero[T any, P *U, U any](p P) T { + if p == nil { + var zero T + return zero + } + return any(p).(T) +} + +func handleNotify(ctx context.Context, method string, req Request) error { + mh := req.GetSession().sendingMethodHandler() + _, err := mh(ctx, method, req) + return err +} + +func handleSend[R Result](ctx context.Context, method string, req Request) (R, error) { + mh := req.GetSession().sendingMethodHandler() + // mh might be user code, so ensure that it returns the right values for the jsonrpc2 protocol. + res, err := mh(ctx, method, req) + if err != nil { + var z R + return z, err + } + return res.(R), nil +} + +// defaultReceivingMethodHandler is the initial MethodHandler for servers and clients, before being wrapped by middleware. +func defaultReceivingMethodHandler[S Session](ctx context.Context, method string, req Request) (Result, error) { + info, ok := req.GetSession().receivingMethodInfos()[method] + if !ok { + // This can be called from user code, with an arbitrary value for method. + return nil, jsonrpc2.ErrNotHandled + } + return info.handleMethod(ctx, method, req) +} + +func handleReceive[S Session](ctx context.Context, session S, jreq *jsonrpc.Request) (Result, error) { + info, err := checkRequest(jreq, session.receivingMethodInfos()) + if err != nil { + return nil, err + } + params, err := info.unmarshalParams(jreq.Params) + if err != nil { + return nil, fmt.Errorf("handling '%s': %w", jreq.Method, err) + } + + mh := session.receivingMethodHandler() + re, _ := jreq.Extra.(*RequestExtra) + req := info.newRequest(session, params, re) + // mh might be user code, so ensure that it returns the right values for the jsonrpc2 protocol. + res, err := mh(ctx, jreq.Method, req) + if err != nil { + return nil, err + } + return res, nil +} + +// checkRequest checks the given request against the provided method info, to +// ensure it is a valid MCP request. +// +// If valid, the relevant method info is returned. Otherwise, a non-nil error +// is returned describing why the request is invalid. +// +// This is extracted from request handling so that it can be called in the +// transport layer to preemptively reject bad requests. +func checkRequest(req *jsonrpc.Request, infos map[string]methodInfo) (methodInfo, error) { + info, ok := infos[req.Method] + if !ok { + return methodInfo{}, fmt.Errorf("%w: %q unsupported", jsonrpc2.ErrNotHandled, req.Method) + } + if info.flags¬ification != 0 && req.IsCall() { + return methodInfo{}, fmt.Errorf("%w: unexpected id for %q", jsonrpc2.ErrInvalidRequest, req.Method) + } + if info.flags¬ification == 0 && !req.IsCall() { + return methodInfo{}, fmt.Errorf("%w: missing id for %q", jsonrpc2.ErrInvalidRequest, req.Method) + } + // missingParamsOK is checked here to catch the common case where "params" is + // missing entirely. + // + // However, it's checked again after unmarshalling to catch the rare but + // possible case where "params" is JSON null (see https://go.dev/issue/33835). + if info.flags&missingParamsOK == 0 && len(req.Params) == 0 { + return methodInfo{}, fmt.Errorf("%w: missing required \"params\"", jsonrpc2.ErrInvalidRequest) + } + return info, nil +} + +// methodInfo is information about sending and receiving a method. +type methodInfo struct { + // flags is a collection of flags controlling how the JSONRPC method is + // handled. See individual flag values for documentation. + flags methodFlags + // Unmarshal params from the wire into a Params struct. + // Used on the receive side. + unmarshalParams func(json.RawMessage) (Params, error) + newRequest func(Session, Params, *RequestExtra) Request + // Run the code when a call to the method is received. + // Used on the receive side. + handleMethod MethodHandler + // Create a pointer to a Result struct. + // Used on the send side. + newResult func() Result +} + +// The following definitions support converting from typed to untyped method handlers. +// Type parameter meanings: +// - S: sessions +// - P: params +// - R: results + +// A typedMethodHandler is like a MethodHandler, but with type information. +type ( + typedClientMethodHandler[P Params, R Result] func(context.Context, *ClientRequest[P]) (R, error) + typedServerMethodHandler[P Params, R Result] func(context.Context, *ServerRequest[P]) (R, error) +) + +type paramsPtr[T any] interface { + *T + Params +} + +type methodFlags int + +const ( + notification methodFlags = 1 << iota // method is a notification, not request + missingParamsOK // params may be missing or null +) + +func newClientMethodInfo[P paramsPtr[T], R Result, T any](d typedClientMethodHandler[P, R], flags methodFlags) methodInfo { + mi := newMethodInfo[P, R](flags) + mi.newRequest = func(s Session, p Params, _ *RequestExtra) Request { + r := &ClientRequest[P]{Session: s.(*ClientSession)} + if p != nil { + r.Params = p.(P) + } + return r + } + mi.handleMethod = MethodHandler(func(ctx context.Context, _ string, req Request) (Result, error) { + return d(ctx, req.(*ClientRequest[P])) + }) + return mi +} + +func newServerMethodInfo[P paramsPtr[T], R Result, T any](d typedServerMethodHandler[P, R], flags methodFlags) methodInfo { + mi := newMethodInfo[P, R](flags) + mi.newRequest = func(s Session, p Params, re *RequestExtra) Request { + r := &ServerRequest[P]{Session: s.(*ServerSession), Extra: re} + if p != nil { + r.Params = p.(P) + } + return r + } + mi.handleMethod = MethodHandler(func(ctx context.Context, _ string, req Request) (Result, error) { + return d(ctx, req.(*ServerRequest[P])) + }) + return mi +} + +// newMethodInfo creates a methodInfo from a typedMethodHandler. +// +// If isRequest is set, the method is treated as a request rather than a +// notification. +func newMethodInfo[P paramsPtr[T], R Result, T any](flags methodFlags) methodInfo { + return methodInfo{ + flags: flags, + unmarshalParams: func(m json.RawMessage) (Params, error) { + var p P + if m != nil { + if err := internaljson.Unmarshal(m, &p); err != nil { + return nil, fmt.Errorf("unmarshaling %q into a %T: %w", m, p, err) + } + } + // We must check missingParamsOK here, in addition to checkRequest, to + // catch the edge cases where "params" is set to JSON null. + // See also https://go.dev/issue/33835. + // + // We need to ensure that p is non-null to guard against crashes, as our + // internal code or externally provided handlers may assume that params + // is non-null. + if flags&missingParamsOK == 0 && p == nil { + return nil, fmt.Errorf("%w: missing required \"params\"", jsonrpc2.ErrInvalidRequest) + } + return orZero[Params](p), nil + }, + // newResult is used on the send side, to construct the value to unmarshal the result into. + // R is a pointer to a result struct. There is no way to "unpointer" it without reflection. + // TODO(jba): explore generic approaches to this, perhaps by treating R in + // the signature as the unpointered type. + newResult: func() Result { return reflect.New(reflect.TypeFor[R]().Elem()).Interface().(R) }, + } +} + +// serverMethod is glue for creating a typedMethodHandler from a method on Server. +func serverMethod[P Params, R Result]( + f func(*Server, context.Context, *ServerRequest[P]) (R, error), +) typedServerMethodHandler[P, R] { + return func(ctx context.Context, req *ServerRequest[P]) (R, error) { + return f(req.Session.server, ctx, req) + } +} + +// clientMethod is glue for creating a typedMethodHandler from a method on Client. +func clientMethod[P Params, R Result]( + f func(*Client, context.Context, *ClientRequest[P]) (R, error), +) typedClientMethodHandler[P, R] { + return func(ctx context.Context, req *ClientRequest[P]) (R, error) { + return f(req.Session.client, ctx, req) + } +} + +// serverSessionMethod is glue for creating a typedServerMethodHandler from a method on ServerSession. +func serverSessionMethod[P Params, R Result](f func(*ServerSession, context.Context, P) (R, error)) typedServerMethodHandler[P, R] { + return func(ctx context.Context, req *ServerRequest[P]) (R, error) { + return f(req.GetSession().(*ServerSession), ctx, req.Params) + } +} + +// clientSessionMethod is glue for creating a typedMethodHandler from a method on ServerSession. +func clientSessionMethod[P Params, R Result](f func(*ClientSession, context.Context, P) (R, error)) typedClientMethodHandler[P, R] { + return func(ctx context.Context, req *ClientRequest[P]) (R, error) { + return f(req.GetSession().(*ClientSession), ctx, req.Params) + } +} + +// MCP-specific error codes. +const ( + // CodeResourceNotFound indicates that a requested resource could not be found. + CodeResourceNotFound = -32002 + // CodeURLElicitationRequired indicates that the server requires URL elicitation + // before processing the request. The client should execute the elicitation handler + // with the elicitations provided in the error data. + CodeURLElicitationRequired = -32042 +) + +// URLElicitationRequiredError returns an error indicating that URL elicitation is required +// before the request can be processed. The elicitations parameter should contain the +// elicitation requests that must be completed. +func URLElicitationRequiredError(elicitations []*ElicitParams) error { + // Validate that all elicitations are URL mode + for _, elicit := range elicitations { + mode := elicit.Mode + if mode == "" { + mode = "form" // default mode + } + if mode != "url" { + panic(fmt.Sprintf("URLElicitationRequiredError requires all elicitations to be URL mode, got %q", mode)) + } + } + + data, err := json.Marshal(map[string]any{ + "elicitations": elicitations, + }) + if err != nil { + // This should never happen with valid ElicitParams + panic(fmt.Sprintf("failed to marshal elicitations: %v", err)) + } + return &jsonrpc.Error{ + Code: CodeURLElicitationRequired, + Message: "URL elicitation required", + Data: json.RawMessage(data), + } +} + +// Internal error codes +const ( + // The error code if the method exists and was called properly, but the peer does not support it. + // + // TODO(rfindley): this code is wrong, and we should fix it to be + // consistent with other SDKs. + codeUnsupportedMethod = -31001 +) + +// notifySessions calls Notify on all the sessions. +// Should be called on a copy of the peer sessions. +// The logger must be non-nil. +func notifySessions[S Session, P Params](sessions []S, method string, params P, logger *slog.Logger) { + if sessions == nil { + return + } + // Notify with the background context, so the messages are sent on the + // standalone stream. + // TODO: make this timeout configurable, or call handleNotify asynchronously. + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + // TODO: there's a potential spec violation here, when the feature list + // changes before the session (client or server) is initialized. + for _, s := range sessions { + req := newRequest(s, params) + if err := handleNotify(ctx, method, req); err != nil { + logger.Warn(fmt.Sprintf("calling %s: %v", method, err)) + } + } +} + +func newRequest[S Session, P Params](s S, p P) Request { + switch s := any(s).(type) { + case *ClientSession: + return &ClientRequest[P]{Session: s, Params: p} + case *ServerSession: + return &ServerRequest[P]{Session: s, Params: p} + default: + panic("bad session") + } +} + +// Meta is additional metadata for requests, responses and other types. +type Meta map[string]any + +// GetMeta returns metadata from a value. +func (m Meta) GetMeta() map[string]any { return m } + +// SetMeta sets the metadata on a value. +func (m *Meta) SetMeta(x map[string]any) { *m = x } + +const progressTokenKey = "progressToken" + +func getProgressToken(p Params) any { + return p.GetMeta()[progressTokenKey] +} + +func setProgressToken(p Params, pt any) { + switch pt.(type) { + // Support int32 and int64 for atomic.IntNN. + case int, int32, int64, string: + default: + panic(fmt.Sprintf("progress token %v is of type %[1]T, not int or string", pt)) + } + m := p.GetMeta() + if m == nil { + m = map[string]any{} + } + m[progressTokenKey] = pt +} + +// A Request is a method request with parameters and additional information, such as the session. +// Request is implemented by [*ClientRequest] and [*ServerRequest]. +type Request interface { + isRequest() + GetSession() Session + GetParams() Params + // GetExtra returns the Extra field for ServerRequests, and nil for ClientRequests. + GetExtra() *RequestExtra +} + +// A ClientRequest is a request to a client. +type ClientRequest[P Params] struct { + Session *ClientSession + Params P +} + +// A ServerRequest is a request to a server. +type ServerRequest[P Params] struct { + Session *ServerSession + Params P + Extra *RequestExtra +} + +// RequestExtra is extra information included in requests, typically from +// the transport layer. +type RequestExtra struct { + TokenInfo *auth.TokenInfo // bearer token info (e.g. from OAuth) if any + Header http.Header // header from HTTP request, if any + + // If set, CloseSSEStream explicitly closes the current SSE request stream. + // + // [SEP-1699] introduced server-side SSE stream disconnection: for + // long-running requests, servers may opt to close the SSE stream and + // ask the client to retry at a later time. CloseSSEStream implements this + // feature; if RetryAfter is set, an event is sent with a `retry:` field + // to configure the reconnection delay. + // + // [SEP-1699]: https://github.com/modelcontextprotocol/modelcontextprotocol/issues/1699 + CloseSSEStream func(CloseSSEStreamArgs) +} + +// CloseSSEStreamArgs are arguments for [RequestExtra.CloseSSEStream]. +type CloseSSEStreamArgs struct { + // RetryAfter configures the reconnection delay sent to the client via the + // SSE retry field. If zero, no retry field is sent. + RetryAfter time.Duration +} + +func (*ClientRequest[P]) isRequest() {} +func (*ServerRequest[P]) isRequest() {} + +func (r *ClientRequest[P]) GetSession() Session { return r.Session } +func (r *ServerRequest[P]) GetSession() Session { return r.Session } + +func (r *ClientRequest[P]) GetParams() Params { return r.Params } +func (r *ServerRequest[P]) GetParams() Params { return r.Params } + +func (r *ClientRequest[P]) GetExtra() *RequestExtra { return nil } +func (r *ServerRequest[P]) GetExtra() *RequestExtra { return r.Extra } + +func serverRequestFor[P Params](s *ServerSession, p P) *ServerRequest[P] { + return &ServerRequest[P]{Session: s, Params: p} +} + +func clientRequestFor[P Params](s *ClientSession, p P) *ClientRequest[P] { + return &ClientRequest[P]{Session: s, Params: p} +} + +// Params is a parameter (input) type for an MCP call or notification. +type Params interface { + // GetMeta returns metadata from a value. + GetMeta() map[string]any + // SetMeta sets the metadata on a value. + SetMeta(map[string]any) + + // isParams discourages implementation of Params outside of this package. + isParams() +} + +// RequestParams is a parameter (input) type for an MCP request. +type RequestParams interface { + Params + + // GetProgressToken returns the progress token from the params' Meta field, or nil + // if there is none. + GetProgressToken() any + + // SetProgressToken sets the given progress token into the params' Meta field. + // It panics if its argument is not an int or a string. + SetProgressToken(any) +} + +// Result is a result of an MCP call. +type Result interface { + // isResult discourages implementation of Result outside of this package. + isResult() + + // GetMeta returns metadata from a value. + GetMeta() map[string]any + // SetMeta sets the metadata on a value. + SetMeta(map[string]any) +} + +// emptyResult is returned by methods that have no result, like ping. +// Those methods cannot return nil, because jsonrpc2 cannot handle nils. +type emptyResult struct{} + +func (*emptyResult) isResult() {} +func (*emptyResult) GetMeta() map[string]any { panic("should never be called") } +func (*emptyResult) SetMeta(map[string]any) { panic("should never be called") } + +type listParams interface { + // Returns a pointer to the param's Cursor field. + cursorPtr() *string +} + +type listResult[T any] interface { + // Returns a pointer to the param's NextCursor field. + nextCursorPtr() *string +} + +// keepaliveSession represents a session that supports keepalive functionality. +type keepaliveSession interface { + Ping(ctx context.Context, params *PingParams) error + Close() error +} + +// startKeepalive starts the keepalive mechanism for a session. +// It assigns the cancel function to the provided cancelPtr and starts a goroutine +// that sends ping messages at the specified interval. +func startKeepalive(session keepaliveSession, interval time.Duration, cancelPtr *context.CancelFunc) { + ctx, cancel := context.WithCancel(context.Background()) + // Assign cancel function before starting goroutine to avoid race condition. + // We cannot return it because the caller may need to cancel during the + // window between goroutine scheduling and function return. + *cancelPtr = cancel + + go func() { + ticker := time.NewTicker(interval) + defer ticker.Stop() + + for { + select { + case <-ctx.Done(): + return + case <-ticker.C: + pingCtx, pingCancel := context.WithTimeout(context.Background(), interval/2) + err := session.Ping(pingCtx, nil) + pingCancel() + if err != nil { + // Ping failed, close the session + _ = session.Close() + return + } + } + } + }() +} diff --git a/mcp/dsx-exchange-mcp/vendor/github.com/modelcontextprotocol/go-sdk/mcp/sse.go b/mcp/dsx-exchange-mcp/vendor/github.com/modelcontextprotocol/go-sdk/mcp/sse.go new file mode 100644 index 0000000..e57dad1 --- /dev/null +++ b/mcp/dsx-exchange-mcp/vendor/github.com/modelcontextprotocol/go-sdk/mcp/sse.go @@ -0,0 +1,489 @@ +// Copyright 2025 The Go MCP SDK Authors. All rights reserved. +// Use of this source code is governed by an MIT-style +// license that can be found in the LICENSE file. + +package mcp + +import ( + "bytes" + "context" + "crypto/rand" + "fmt" + "io" + "net/http" + "net/url" + "sync" + + "github.com/modelcontextprotocol/go-sdk/internal/jsonrpc2" + "github.com/modelcontextprotocol/go-sdk/jsonrpc" +) + +// This file implements support for SSE (HTTP with server-sent events) +// transport server and client. +// https://modelcontextprotocol.io/specification/2024-11-05/basic/transports +// +// The transport is simple, at least relative to the new streamable transport +// introduced in the 2025-03-26 version of the spec. In short: +// +// 1. Sessions are initiated via a hanging GET request, which streams +// server->client messages as SSE 'message' events. +// 2. The first event in the SSE stream must be an 'endpoint' event that +// informs the client of the session endpoint. +// 3. The client POSTs client->server messages to the session endpoint. +// +// Therefore, the each new GET request hands off its responsewriter to an +// [SSEServerTransport] type that abstracts the transport as follows: +// - Write writes a new event to the responseWriter, or fails if the GET has +// exited. +// - Read reads off a message queue that is pushed to via POST requests. +// - Close causes the hanging GET to exit. + +// SSEHandler is an http.Handler that serves SSE-based MCP sessions as defined by +// the [2024-11-05 version] of the MCP spec. +// +// [2024-11-05 version]: https://modelcontextprotocol.io/specification/2024-11-05/basic/transports +type SSEHandler struct { + getServer func(request *http.Request) *Server + opts SSEOptions + onConnection func(*ServerSession) // for testing; must not block + + mu sync.Mutex + sessions map[string]*SSEServerTransport +} + +// SSEOptions specifies options for an [SSEHandler]. +// for now, it is empty, but may be extended in future. +// https://github.com/modelcontextprotocol/go-sdk/issues/507 +type SSEOptions struct{} + +// NewSSEHandler returns a new [SSEHandler] that creates and manages MCP +// sessions created via incoming HTTP requests. +// +// Sessions are created when the client issues a GET request to the server, +// which must accept text/event-stream responses (server-sent events). +// For each such request, a new [SSEServerTransport] is created with a distinct +// messages endpoint, and connected to the server returned by getServer. +// The SSEHandler also handles requests to the message endpoints, by +// delegating them to the relevant server transport. +// +// The getServer function may return a distinct [Server] for each new +// request, or reuse an existing server. If it returns nil, the handler +// will return a 400 Bad Request. +func NewSSEHandler(getServer func(request *http.Request) *Server, opts *SSEOptions) *SSEHandler { + s := &SSEHandler{ + getServer: getServer, + sessions: make(map[string]*SSEServerTransport), + } + + if opts != nil { + s.opts = *opts + } + + return s +} + +// A SSEServerTransport is a logical SSE session created through a hanging GET +// request. +// +// Use [SSEServerTransport.Connect] to initiate the flow of messages. +// +// When connected, it returns the following [Connection] implementation: +// - Writes are SSE 'message' events to the GET response. +// - Reads are received from POSTs to the session endpoint, via +// [SSEServerTransport.ServeHTTP]. +// - Close terminates the hanging GET. +// +// The transport is itself an [http.Handler]. It is the caller's responsibility +// to ensure that the resulting transport serves HTTP requests on the given +// session endpoint. +// +// Each SSEServerTransport may be connected (via [Server.Connect]) at most +// once, since [SSEServerTransport.ServeHTTP] serves messages to the connected +// session. +// +// Most callers should instead use an [SSEHandler], which transparently handles +// the delegation to SSEServerTransports. +type SSEServerTransport struct { + // Endpoint is the endpoint for this session, where the client can POST + // messages. + Endpoint string + + // Response is the hanging response body to the incoming GET request. + Response http.ResponseWriter + + // incoming is the queue of incoming messages. + // It is never closed, and by convention, incoming is non-nil if and only if + // the transport is connected. + incoming chan jsonrpc.Message + + // We must guard both pushes to the incoming queue and writes to the response + // writer, because incoming POST requests are arbitrarily concurrent and we + // need to ensure we don't write push to the queue, or write to the + // ResponseWriter, after the session GET request exits. + mu sync.Mutex // also guards writes to Response + closed bool // set when the stream is closed + done chan struct{} // closed when the connection is closed +} + +// ServeHTTP handles POST requests to the transport endpoint. +func (t *SSEServerTransport) ServeHTTP(w http.ResponseWriter, req *http.Request) { + if t.incoming == nil { + http.Error(w, "session not connected", http.StatusInternalServerError) + return + } + + // Read and parse the message. + data, err := io.ReadAll(req.Body) + if err != nil { + http.Error(w, "failed to read body", http.StatusBadRequest) + return + } + // Optionally, we could just push the data onto a channel, and let the + // message fail to parse when it is read. This failure seems a bit more + // useful + msg, err := jsonrpc2.DecodeMessage(data) + if err != nil { + http.Error(w, "failed to parse body", http.StatusBadRequest) + return + } + if req, ok := msg.(*jsonrpc.Request); ok { + if _, err := checkRequest(req, serverMethodInfos); err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + } + select { + case t.incoming <- msg: + w.WriteHeader(http.StatusAccepted) + case <-t.done: + http.Error(w, "session closed", http.StatusBadRequest) + } +} + +// Connect sends the 'endpoint' event to the client. +// See [SSEServerTransport] for more details on the [Connection] implementation. +func (t *SSEServerTransport) Connect(context.Context) (Connection, error) { + if t.incoming != nil { + return nil, fmt.Errorf("already connected") + } + t.incoming = make(chan jsonrpc.Message, 100) + t.done = make(chan struct{}) + _, err := writeEvent(t.Response, Event{ + Name: "endpoint", + Data: []byte(t.Endpoint), + }) + if err != nil { + return nil, err + } + return &sseServerConn{t: t}, nil +} + +func (h *SSEHandler) ServeHTTP(w http.ResponseWriter, req *http.Request) { + sessionID := req.URL.Query().Get("sessionid") + + // TODO: consider checking Content-Type here. For now, we are lax. + + // For POST requests, the message body is a message to send to a session. + if req.Method == http.MethodPost { + // Look up the session. + if sessionID == "" { + http.Error(w, "sessionid must be provided", http.StatusBadRequest) + return + } + h.mu.Lock() + session := h.sessions[sessionID] + h.mu.Unlock() + if session == nil { + http.Error(w, "session not found", http.StatusNotFound) + return + } + + session.ServeHTTP(w, req) + return + } + + if req.Method != http.MethodGet { + w.Header().Set("Allow", "GET, POST") + http.Error(w, "Method Not Allowed", http.StatusMethodNotAllowed) + return + } + + // GET requests create a new session, and serve messages over SSE. + + // TODO: it's not entirely documented whether we should check Accept here. + // Let's again be lax and assume the client will accept SSE. + + w.Header().Set("Content-Type", "text/event-stream") + w.Header().Set("Cache-Control", "no-cache") + w.Header().Set("Connection", "keep-alive") + + sessionID = rand.Text() + endpoint, err := req.URL.Parse("?sessionid=" + sessionID) + if err != nil { + http.Error(w, "internal error: failed to create endpoint", http.StatusInternalServerError) + return + } + + transport := &SSEServerTransport{Endpoint: endpoint.RequestURI(), Response: w} + + // The session is terminated when the request exits. + h.mu.Lock() + h.sessions[sessionID] = transport + h.mu.Unlock() + defer func() { + h.mu.Lock() + delete(h.sessions, sessionID) + h.mu.Unlock() + }() + + server := h.getServer(req) + if server == nil { + // The getServer argument to NewSSEHandler returned nil. + http.Error(w, "no server available", http.StatusBadRequest) + return + } + ss, err := server.Connect(req.Context(), transport, nil) + if err != nil { + http.Error(w, "connection failed", http.StatusInternalServerError) + return + } + if h.onConnection != nil { + h.onConnection(ss) + } + defer ss.Close() // close the transport when the GET exits + + select { + case <-req.Context().Done(): + case <-transport.done: + } +} + +// sseServerConn implements the [Connection] interface for a single [SSEServerTransport]. +// It hides the Connection interface from the SSEServerTransport API. +type sseServerConn struct { + t *SSEServerTransport +} + +// TODO(jba): get the session ID. (Not urgent because SSE transports have been removed from the spec.) +func (s *sseServerConn) SessionID() string { return "" } + +// Read implements jsonrpc2.Reader. +func (s *sseServerConn) Read(ctx context.Context) (jsonrpc.Message, error) { + select { + case <-ctx.Done(): + return nil, ctx.Err() + case msg := <-s.t.incoming: + return msg, nil + case <-s.t.done: + return nil, io.EOF + } +} + +// Write implements jsonrpc2.Writer. +func (s *sseServerConn) Write(ctx context.Context, msg jsonrpc.Message) error { + if ctx.Err() != nil { + return ctx.Err() + } + + data, err := jsonrpc2.EncodeMessage(msg) + if err != nil { + return err + } + + s.t.mu.Lock() + defer s.t.mu.Unlock() + + // Note that it is invalid to write to a ResponseWriter after ServeHTTP has + // exited, and so we must lock around this write and check isDone, which is + // set before the hanging GET exits. + if s.t.closed { + return io.EOF + } + + _, err = writeEvent(s.t.Response, Event{Name: "message", Data: data}) + return err +} + +// Close implements io.Closer, and closes the session. +// +// It must be safe to call Close more than once, as the close may +// asynchronously be initiated by either the server closing its connection, or +// by the hanging GET exiting. +func (s *sseServerConn) Close() error { + s.t.mu.Lock() + defer s.t.mu.Unlock() + if !s.t.closed { + s.t.closed = true + close(s.t.done) + } + return nil +} + +// An SSEClientTransport is a [Transport] that can communicate with an MCP +// endpoint serving the SSE transport defined by the 2024-11-05 version of the +// spec. +// +// https://modelcontextprotocol.io/specification/2024-11-05/basic/transports +type SSEClientTransport struct { + // Endpoint is the SSE endpoint to connect to. + Endpoint string + + // HTTPClient is the client to use for making HTTP requests. If nil, + // http.DefaultClient is used. + HTTPClient *http.Client +} + +// Connect connects through the client endpoint. +func (c *SSEClientTransport) Connect(ctx context.Context) (Connection, error) { + parsedURL, err := url.Parse(c.Endpoint) + if err != nil { + return nil, fmt.Errorf("invalid endpoint: %v", err) + } + req, err := http.NewRequestWithContext(ctx, "GET", c.Endpoint, nil) + if err != nil { + return nil, err + } + httpClient := c.HTTPClient + if httpClient == nil { + httpClient = http.DefaultClient + } + req.Header.Set("Accept", "text/event-stream") + resp, err := httpClient.Do(req) + if err != nil { + return nil, err + } + + // Check HTTP status code before attempting to parse SSE events. + // This ensures proper error reporting for authentication failures (401), + // authorization failures (403), and other HTTP errors. + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + resp.Body.Close() + return nil, fmt.Errorf("failed to connect: %s", http.StatusText(resp.StatusCode)) + } + + msgEndpoint, err := func() (*url.URL, error) { + var evt Event + for evt, err = range scanEvents(resp.Body) { + break + } + if err != nil { + return nil, err + } + if evt.Name != "endpoint" { + return nil, fmt.Errorf("first event is %q, want %q", evt.Name, "endpoint") + } + raw := string(evt.Data) + return parsedURL.Parse(raw) + }() + if err != nil { + resp.Body.Close() + return nil, fmt.Errorf("missing endpoint: %v", err) + } + + // From here on, the stream takes ownership of resp.Body. + s := &sseClientConn{ + client: httpClient, + msgEndpoint: msgEndpoint, + incoming: make(chan []byte, 100), + body: resp.Body, + done: make(chan struct{}), + } + + go func() { + defer s.Close() // close the transport when the GET exits + + for evt, err := range scanEvents(resp.Body) { + if err != nil { + return + } + select { + case s.incoming <- evt.Data: + case <-s.done: + return + } + } + }() + + return s, nil +} + +// An sseClientConn is a logical jsonrpc2 connection that implements the client +// half of the SSE protocol: +// - Writes are POSTS to the session endpoint. +// - Reads are SSE 'message' events, and pushes them onto a buffered channel. +// - Close terminates the GET request. +type sseClientConn struct { + client *http.Client // HTTP client to use for requests + msgEndpoint *url.URL // session endpoint for POSTs + incoming chan []byte // queue of incoming messages + + mu sync.Mutex + body io.ReadCloser // body of the hanging GET + closed bool // set when the stream is closed + done chan struct{} // closed when the stream is closed +} + +// TODO(jba): get the session ID. (Not urgent because SSE transports have been removed from the spec.) +func (c *sseClientConn) SessionID() string { return "" } + +func (c *sseClientConn) isDone() bool { + c.mu.Lock() + defer c.mu.Unlock() + return c.closed +} + +func (c *sseClientConn) Read(ctx context.Context) (jsonrpc.Message, error) { + select { + case <-ctx.Done(): + return nil, ctx.Err() + + case <-c.done: + return nil, io.EOF + + case data := <-c.incoming: + // TODO(rfindley): do we really need to check this? We receive from c.done above. + if c.isDone() { + return nil, io.EOF + } + msg, err := jsonrpc2.DecodeMessage(data) + if err != nil { + return nil, err + } + return msg, nil + } +} + +func (c *sseClientConn) Write(ctx context.Context, msg jsonrpc.Message) error { + data, err := jsonrpc2.EncodeMessage(msg) + if err != nil { + return err + } + if c.isDone() { + return io.EOF + } + req, err := http.NewRequestWithContext(ctx, "POST", c.msgEndpoint.String(), bytes.NewReader(data)) + if err != nil { + return err + } + req.Header.Set("Content-Type", "application/json") + resp, err := c.client.Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + return fmt.Errorf("failed to write: %s", resp.Status) + } + return nil +} + +func (c *sseClientConn) Close() error { + c.mu.Lock() + defer c.mu.Unlock() + if !c.closed { + c.closed = true + _ = c.body.Close() + close(c.done) + } + return nil +} diff --git a/mcp/dsx-exchange-mcp/vendor/github.com/modelcontextprotocol/go-sdk/mcp/streamable.go b/mcp/dsx-exchange-mcp/vendor/github.com/modelcontextprotocol/go-sdk/mcp/streamable.go new file mode 100644 index 0000000..0b11eff --- /dev/null +++ b/mcp/dsx-exchange-mcp/vendor/github.com/modelcontextprotocol/go-sdk/mcp/streamable.go @@ -0,0 +1,2188 @@ +// Copyright 2025 The Go MCP SDK Authors. All rights reserved. +// Use of this source code is governed by an MIT-style +// license that can be found in the LICENSE file. + +// NOTE: see streamable_server.go and streamable_client.go for detailed +// documentation of the streamable server design. +// TODO: move the client and server logic into those files. + +package mcp + +import ( + "bytes" + "context" + crand "crypto/rand" + "encoding/json" + "errors" + "fmt" + "io" + "log/slog" + "maps" + "math" + "math/rand/v2" + "net" + "net/http" + "slices" + "strconv" + "strings" + "sync" + "time" + + "github.com/modelcontextprotocol/go-sdk/auth" + internaljson "github.com/modelcontextprotocol/go-sdk/internal/json" + "github.com/modelcontextprotocol/go-sdk/internal/jsonrpc2" + "github.com/modelcontextprotocol/go-sdk/internal/mcpgodebug" + "github.com/modelcontextprotocol/go-sdk/internal/util" + "github.com/modelcontextprotocol/go-sdk/internal/xcontext" + "github.com/modelcontextprotocol/go-sdk/jsonrpc" +) + +const ( + protocolVersionHeader = "Mcp-Protocol-Version" + sessionIDHeader = "Mcp-Session-Id" + lastEventIDHeader = "Last-Event-ID" +) + +// A StreamableHTTPHandler is an http.Handler that serves streamable MCP +// sessions, as defined by the [MCP spec]. +// +// [MCP spec]: https://modelcontextprotocol.io/2025/03/26/streamable-http-transport.html +type StreamableHTTPHandler struct { + getServer func(*http.Request) *Server + opts StreamableHTTPOptions + + onTransportDeletion func(sessionID string) // for testing + + mu sync.Mutex + sessions map[string]*sessionInfo // keyed by session ID +} + +type sessionInfo struct { + session *ServerSession + transport *StreamableServerTransport + // userID is the user ID from the TokenInfo when the session was created. + // If non-empty, subsequent requests must have the same user ID to prevent + // session hijacking. + userID string + + // If timeout is set, automatically close the session after an idle period. + timeout time.Duration + timerMu sync.Mutex + refs int // reference count + timer *time.Timer +} + +// startPOST signals that a POST request for this session is starting (which +// carries a client->server message), pausing the session timeout if it was +// running. +// +// TODO: we may want to also pause the timer when resuming non-standalone SSE +// streams, but that is tricy to implement. Clients should generally make +// keepalive pings if they want to keep the session live. +func (i *sessionInfo) startPOST() { + if i.timeout <= 0 { + return + } + + i.timerMu.Lock() + defer i.timerMu.Unlock() + + if i.timer == nil { + return // timer stopped permanently + } + if i.refs == 0 { + i.timer.Stop() + } + i.refs++ +} + +// endPOST sigals that a request for this session is ending, starting the +// timeout if there are no other requests running. +func (i *sessionInfo) endPOST() { + if i.timeout <= 0 { + return + } + + i.timerMu.Lock() + defer i.timerMu.Unlock() + + if i.timer == nil { + return // timer stopped permanently + } + + i.refs-- + assert(i.refs >= 0, "negative ref count") + if i.refs == 0 { + i.timer.Reset(i.timeout) + } +} + +// stopTimer stops the inactivity timer permanently. +func (i *sessionInfo) stopTimer() { + i.timerMu.Lock() + defer i.timerMu.Unlock() + if i.timer != nil { + i.timer.Stop() + i.timer = nil + } +} + +// StreamableHTTPOptions configures the StreamableHTTPHandler. +type StreamableHTTPOptions struct { + // Stateless controls whether the session is 'stateless'. + // + // A stateless server does not validate the Mcp-Session-Id header, and uses a + // temporary session with default initialization parameters. Any + // server->client request is rejected immediately as there's no way for the + // client to respond. Server->Client notifications may reach the client if + // they are made in the context of an incoming request, as described in the + // documentation for [StreamableServerTransport]. + Stateless bool + + // TODO(#148): support session retention (?) + + // JSONResponse causes streamable responses to return application/json rather + // than text/event-stream ([§2.1.5] of the spec). + // + // [§2.1.5]: https://modelcontextprotocol.io/specification/2025-06-18/basic/transports#sending-messages-to-the-server + JSONResponse bool + + // Logger specifies the logger to use. + // If nil, do not log. + Logger *slog.Logger + + // EventStore enables stream resumption. + // + // If set, EventStore will be used to persist stream events and replay them + // upon stream resumption. + EventStore EventStore + + // SessionTimeout configures a timeout for idle sessions. + // + // When sessions receive no new HTTP requests from the client for this + // duration, they are automatically closed. + // + // If SessionTimeout is the zero value, idle sessions are never closed. + SessionTimeout time.Duration + + // DisableLocalhostProtection disables automatic DNS rebinding protection. + // By default, requests arriving via a localhost address (127.0.0.1, [::1]) + // that have a non-localhost Host header are rejected with 403 Forbidden. + // This protects against DNS rebinding attacks regardless of whether the + // server is listening on localhost specifically or on 0.0.0.0. + // + // Only disable this if you understand the security implications. + // See: https://modelcontextprotocol.io/specification/2025-11-25/basic/security_best_practices#local-mcp-server-compromise + DisableLocalhostProtection bool +} + +// NewStreamableHTTPHandler returns a new [StreamableHTTPHandler]. +// +// The getServer function is used to create or look up servers for new +// sessions. It is OK for getServer to return the same server multiple times. +// If getServer returns nil, a 400 Bad Request will be served. +func NewStreamableHTTPHandler(getServer func(*http.Request) *Server, opts *StreamableHTTPOptions) *StreamableHTTPHandler { + h := &StreamableHTTPHandler{ + getServer: getServer, + sessions: make(map[string]*sessionInfo), + } + if opts != nil { + h.opts = *opts + } + + if h.opts.Logger == nil { // ensure we have a logger + h.opts.Logger = ensureLogger(nil) + } + + return h +} + +// closeAll closes all ongoing sessions, for tests. +// +// TODO(rfindley): investigate the best API for callers to configure their +// session lifecycle. (?) +// +// Should we allow passing in a session store? That would allow the handler to +// be stateless. +func (h *StreamableHTTPHandler) closeAll() { + // TODO: if we ever expose this outside of tests, we'll need to do better + // than simply collecting sessions while holding the lock: we need to prevent + // new sessions from being added. + // + // Currently, sessions remove themselves from h.sessions when closed, so we + // can't call Close while holding the lock. + h.mu.Lock() + sessionInfos := slices.Collect(maps.Values(h.sessions)) + h.sessions = nil + h.mu.Unlock() + for _, s := range sessionInfos { + s.session.Close() + } +} + +// disablelocalhostprotection is a compatibility parameter that allows to disable +// DNS rebinding protection, which was added in the 1.4.0 version of the SDK. +// See the documentation for the mcpgodebug package for instructions how to enable it. +// The option will be removed in the 1.6.0 version of the SDK. +var disablelocalhostprotection = mcpgodebug.Value("disablelocalhostprotection") + +func (h *StreamableHTTPHandler) ServeHTTP(w http.ResponseWriter, req *http.Request) { + // DNS rebinding protection: auto-enabled for localhost servers. + // See: https://modelcontextprotocol.io/specification/2025-11-25/basic/security_best_practices#local-mcp-server-compromise + if !h.opts.DisableLocalhostProtection && disablelocalhostprotection != "1" { + if localAddr, ok := req.Context().Value(http.LocalAddrContextKey).(net.Addr); ok && localAddr != nil { + if util.IsLoopback(localAddr.String()) && !util.IsLoopback(req.Host) { + http.Error(w, fmt.Sprintf("Forbidden: invalid Host header %q", req.Host), http.StatusForbidden) + return + } + } + } + + // Allow multiple 'Accept' headers. + // https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/Accept#syntax + accept := strings.Split(strings.Join(req.Header.Values("Accept"), ","), ",") + var jsonOK, streamOK bool + for _, c := range accept { + switch strings.TrimSpace(c) { + case "application/json", "application/*": + jsonOK = true + case "text/event-stream", "text/*": + streamOK = true + case "*/*": + jsonOK = true + streamOK = true + } + } + + if req.Method == http.MethodGet { + if !streamOK { + http.Error(w, "Accept must contain 'text/event-stream' for GET requests", http.StatusBadRequest) + return + } + } else if (!jsonOK || !streamOK) && req.Method != http.MethodDelete { // TODO: consolidate with handling of http method below. + http.Error(w, "Accept must contain both 'application/json' and 'text/event-stream'", http.StatusBadRequest) + return + } + + sessionID := req.Header.Get(sessionIDHeader) + var sessInfo *sessionInfo + if sessionID != "" { + h.mu.Lock() + sessInfo = h.sessions[sessionID] + h.mu.Unlock() + if sessInfo == nil && !h.opts.Stateless { + // Unless we're in 'stateless' mode, which doesn't perform any Session-ID + // validation, we require that the session ID matches a known session. + // + // In stateless mode, a temporary transport is be created below. + http.Error(w, "session not found", http.StatusNotFound) + return + } + // Prevent session hijacking: if the session was created with a user ID, + // verify that subsequent requests come from the same user. + if sessInfo != nil && sessInfo.userID != "" { + tokenInfo := auth.TokenInfoFromContext(req.Context()) + if tokenInfo == nil || tokenInfo.UserID != sessInfo.userID { + http.Error(w, "session user mismatch", http.StatusForbidden) + return + } + } + } + + if req.Method == http.MethodDelete { + if sessionID == "" { + http.Error(w, "Bad Request: DELETE requires an Mcp-Session-Id header", http.StatusBadRequest) + return + } + if sessInfo != nil { // sessInfo may be nil in stateless mode + // Closing the session also removes it from h.sessions, due to the + // onClose callback. + sessInfo.session.Close() + } + w.WriteHeader(http.StatusNoContent) + return + } + + switch req.Method { + case http.MethodPost, http.MethodGet: + if req.Method == http.MethodGet && (h.opts.Stateless || sessionID == "") { + if h.opts.Stateless { + // Per MCP spec: server MUST return 405 if it doesn't offer SSE stream. + // In stateless mode, GET (SSE streaming) is not supported. + // RFC 9110 §15.5.6: 405 responses MUST include Allow header. + w.Header().Set("Allow", "POST") + http.Error(w, "Method Not Allowed", http.StatusMethodNotAllowed) + } else { + // In stateful mode, GET is supported but requires a session ID. + // This is a precondition error, similar to DELETE without session. + http.Error(w, "Bad Request: GET requires an Mcp-Session-Id header", http.StatusBadRequest) + } + return + } + default: + // RFC 9110 §15.5.6: 405 responses MUST include Allow header. + if h.opts.Stateless { + w.Header().Set("Allow", "POST") + } else { + w.Header().Set("Allow", "GET, POST, DELETE") + } + http.Error(w, "Method Not Allowed", http.StatusMethodNotAllowed) + return + } + + // [§2.7] of the spec (2025-06-18) states: + // + // "If using HTTP, the client MUST include the MCP-Protocol-Version: + // HTTP header on all subsequent requests to the MCP + // server, allowing the MCP server to respond based on the MCP protocol + // version. + // + // For example: MCP-Protocol-Version: 2025-06-18 + // The protocol version sent by the client SHOULD be the one negotiated during + // initialization. + // + // For backwards compatibility, if the server does not receive an + // MCP-Protocol-Version header, and has no other way to identify the version - + // for example, by relying on the protocol version negotiated during + // initialization - the server SHOULD assume protocol version 2025-03-26. + // + // If the server receives a request with an invalid or unsupported + // MCP-Protocol-Version, it MUST respond with 400 Bad Request." + // + // Since this wasn't present in the 2025-03-26 version of the spec, this + // effectively means: + // 1. IF the client provides a version header, it must be a supported + // version. + // 2. In stateless mode, where we've lost the state of the initialize + // request, we assume that whatever the client tells us is the truth (or + // assume 2025-03-26 if the client doesn't say anything). + // + // This logic matches the typescript SDK. + // + // [§2.7]: https://modelcontextprotocol.io/specification/2025-06-18/basic/transports#protocol-version-header + protocolVersion := req.Header.Get(protocolVersionHeader) + if protocolVersion == "" { + protocolVersion = protocolVersion20250326 + } + if !slices.Contains(supportedProtocolVersions, protocolVersion) { + http.Error(w, fmt.Sprintf("Bad Request: Unsupported protocol version (supported versions: %s)", strings.Join(supportedProtocolVersions, ",")), http.StatusBadRequest) + return + } + + if sessInfo == nil { + server := h.getServer(req) + if server == nil { + // The getServer argument to NewStreamableHTTPHandler returned nil. + http.Error(w, "no server available", http.StatusBadRequest) + return + } + if sessionID == "" { + // In stateless mode, sessionID may be nonempty even if there's no + // existing transport. + sessionID = server.opts.GetSessionID() + } + transport := &StreamableServerTransport{ + SessionID: sessionID, + Stateless: h.opts.Stateless, + EventStore: h.opts.EventStore, + jsonResponse: h.opts.JSONResponse, + logger: h.opts.Logger, + } + + // Sessions without a session ID are also stateless: there's no way to + // address them. + stateless := h.opts.Stateless || sessionID == "" + // To support stateless mode, we initialize the session with a default + // state, so that it doesn't reject subsequent requests. + var connectOpts *ServerSessionOptions + if stateless { + // Peek at the body to see if it is initialize or initialized. + // We want those to be handled as usual. + var hasInitialize, hasInitialized bool + { + // TODO: verify that this allows protocol version negotiation for + // stateless servers. + body, err := io.ReadAll(req.Body) + if err != nil { + http.Error(w, "failed to read body", http.StatusBadRequest) + return + } + req.Body.Close() + + // Reset the body so that it can be read later. + req.Body = io.NopCloser(bytes.NewBuffer(body)) + + msgs, _, err := readBatch(body) + if err == nil { + for _, msg := range msgs { + if req, ok := msg.(*jsonrpc.Request); ok { + switch req.Method { + case methodInitialize: + hasInitialize = true + case notificationInitialized: + hasInitialized = true + } + } + } + } + } + + // If we don't have InitializeParams or InitializedParams in the request, + // set the initial state to a default value. + state := new(ServerSessionState) + if !hasInitialize { + state.InitializeParams = &InitializeParams{ + ProtocolVersion: protocolVersion, + } + } + if !hasInitialized { + state.InitializedParams = new(InitializedParams) + } + state.LogLevel = "info" + connectOpts = &ServerSessionOptions{ + State: state, + } + } else { + // Cleanup is only required in stateful mode, as transportation is + // not stored in the map otherwise. + connectOpts = &ServerSessionOptions{ + onClose: func() { + h.mu.Lock() + defer h.mu.Unlock() + if info, ok := h.sessions[transport.SessionID]; ok { + info.stopTimer() + delete(h.sessions, transport.SessionID) + if h.onTransportDeletion != nil { + h.onTransportDeletion(transport.SessionID) + } + } + }, + } + } + + // Pass req.Context() here, to allow middleware to add context values. + // The context is detached in the jsonrpc2 library when handling the + // long-running stream. + session, err := server.Connect(req.Context(), transport, connectOpts) + if err != nil { + http.Error(w, "failed connection", http.StatusInternalServerError) + return + } + // Capture the user ID from the token info to enable session hijacking + // prevention on subsequent requests. + var userID string + if tokenInfo := auth.TokenInfoFromContext(req.Context()); tokenInfo != nil { + userID = tokenInfo.UserID + } + sessInfo = &sessionInfo{ + session: session, + transport: transport, + userID: userID, + } + + if stateless { + // Stateless mode: close the session when the request exits. + defer session.Close() // close the fake session after handling the request + } else { + // Otherwise, save the transport so that it can be reused + + // Clean up the session when it times out. + // + // Note that the timer here may fire multiple times, but + // sessInfo.session.Close is idempotent. + if h.opts.SessionTimeout > 0 { + sessInfo.timeout = h.opts.SessionTimeout + sessInfo.timer = time.AfterFunc(sessInfo.timeout, func() { + sessInfo.session.Close() + }) + } + h.mu.Lock() + h.sessions[transport.SessionID] = sessInfo + h.mu.Unlock() + defer func() { + // If initialization failed, clean up the session (#578). + if session.InitializeParams() == nil { + // Initialization failed. + session.Close() + } + }() + } + } + + if req.Method == http.MethodPost { + sessInfo.startPOST() + defer sessInfo.endPOST() + } + + sessInfo.transport.ServeHTTP(w, req) +} + +// A StreamableServerTransport implements the server side of the MCP streamable +// transport. +// +// Each StreamableServerTransport must be connected (via [Server.Connect]) at +// most once, since [StreamableServerTransport.ServeHTTP] serves messages to +// the connected session. +// +// Reads from the streamable server connection receive messages from http POST +// requests from the client. Writes to the streamable server connection are +// sent either to the related stream, or to the standalone SSE stream, +// according to the following rules: +// - JSON-RPC responses to incoming requests are always routed to the +// appropriate HTTP response. +// - Requests or notifications made with a context.Context value derived from +// an incoming request handler, are routed to the HTTP response +// corresponding to that request, unless it has already terminated, in +// which case they are routed to the standalone SSE stream. +// - Requests or notifications made with a detached context.Context value are +// routed to the standalone SSE stream. +type StreamableServerTransport struct { + // SessionID is the ID of this session. + // + // If SessionID is the empty string, this is a 'stateless' session, which has + // limited ability to communicate with the client. Otherwise, the session ID + // must be globally unique, that is, different from any other session ID + // anywhere, past and future. (We recommend using a crypto random number + // generator to produce one, as with [crypto/rand.Text].) + SessionID string + + // Stateless controls whether the eventstore is 'Stateless'. Server sessions + // connected to a stateless transport are disallowed from making outgoing + // requests. + // + // See also [StreamableHTTPOptions.Stateless]. + Stateless bool + + // EventStore enables stream resumption. + // + // If set, EventStore will be used to persist stream events and replay them + // upon stream resumption. + EventStore EventStore + + // jsonResponse, if set, tells the server to prefer to respond to requests + // using application/json responses rather than text/event-stream. + // + // Specifically, responses will be application/json whenever incoming POST + // request contain only a single message. In this case, notifications or + // requests made within the context of a server request will be sent to the + // standalone SSE stream, if any. + // + // TODO(rfindley): jsonResponse should be exported, since + // StreamableHTTPOptions.JSONResponse is exported, and we want to allow users + // to write their own streamable HTTP handler. + jsonResponse bool + + // optional logger provided through the [StreamableHTTPOptions.Logger]. + // + // TODO(rfindley): logger should be exported, since we want to allow users + // to write their own streamable HTTP handler. + logger *slog.Logger + + // connection is non-nil if and only if the transport has been connected. + connection *streamableServerConn +} + +// Connect implements the [Transport] interface. +func (t *StreamableServerTransport) Connect(ctx context.Context) (Connection, error) { + if t.connection != nil { + return nil, fmt.Errorf("transport already connected") + } + t.connection = &streamableServerConn{ + sessionID: t.SessionID, + stateless: t.Stateless, + eventStore: t.EventStore, + jsonResponse: t.jsonResponse, + logger: ensureLogger(t.logger), // see #556: must be non-nil + incoming: make(chan jsonrpc.Message, 10), + done: make(chan struct{}), + streams: make(map[string]*stream), + requestStreams: make(map[jsonrpc.ID]string), + } + // Stream 0 corresponds to the standalone SSE stream. + // + // It is always text/event-stream, since it must carry arbitrarily many + // messages. + var err error + t.connection.streams[""], err = t.connection.newStream(ctx, nil, "") + if err != nil { + return nil, err + } + return t.connection, nil +} + +type streamableServerConn struct { + sessionID string + stateless bool + jsonResponse bool + eventStore EventStore + + logger *slog.Logger + + incoming chan jsonrpc.Message // messages from the client to the server + + mu sync.Mutex // guards all fields below + + // Sessions are closed exactly once. + isDone bool + done chan struct{} + + // Sessions can have multiple logical connections (which we call streams), + // corresponding to HTTP requests. Additionally, streams may be resumed by + // subsequent HTTP requests, when the HTTP connection is terminated + // unexpectedly. + // + // Therefore, we use a logical stream ID to key the stream state, and + // perform the accounting described below when incoming HTTP requests are + // handled. + + // streams holds the logical streams for this session, keyed by their ID. + // + // Lifecycle: streams persist until all of their responses are received from + // the server. + streams map[string]*stream + + // requestStreams maps incoming requests to their logical stream ID. + // + // Lifecycle: requestStreams persist until their response is received. + requestStreams map[jsonrpc.ID]string +} + +func (c *streamableServerConn) SessionID() string { + return c.sessionID +} + +// A stream is a single logical stream of SSE events within a server session. +// A stream begins with a client request, or with a client GET that has +// no Last-Event-ID header. +// +// A stream ends only when its session ends; we cannot determine its end otherwise, +// since a client may send a GET with a Last-Event-ID that references the stream +// at any time. +type stream struct { + // id is the logical ID for the stream, unique within a session. + // + // The standalone SSE stream has id "". + id string + + // logger is used for logging errors during stream operations. + logger *slog.Logger + + // mu guards the fields below, as well as storage of new messages in the + // connection's event store (if any). + mu sync.Mutex + + // If pendingJSONMessages is non-nil, this is a JSON stream and messages are + // collected here until the stream is complete, at which point they are + // flushed as a single JSON response. Note that the non-nilness of this field + // is significant, as it signals the expected content type. + // + // Note: if we remove support for batching, this could just be a bool. + pendingJSONMessages []json.RawMessage + + // w is the HTTP response writer for this stream. A non-nil w indicates + // that the stream is claimed by an HTTP request (the hanging POST or GET); + // it is set to nil when the request completes. + w http.ResponseWriter + + // done is closed to release the hanging HTTP request. + // + // Invariant: a non-nil done implies w is also non-nil, though the converse + // is not necessarily true: done is set to nil when it is closed, to avoid + // duplicate closure. + done chan struct{} + + // lastIdx is the index of the last written SSE event, for event ID generation. + // It starts at -1 since indices start at 0. + lastIdx int + + // protocolVersion is the protocol version for this stream. + protocolVersion string + + // requests is the set of unanswered incoming requests for the stream. + // + // Requests are removed when their response has been received. + // In practice, there is only one request, but in the 2025-03-26 version of + // the spec and earlier there was a concept of batching, in which POST + // payloads could hold multiple requests or responses. + requests map[jsonrpc.ID]struct{} +} + +// close sends a 'close' event to the client (if protocolVersion >= 2025-11-25 +// and reconnectAfter > 0) and closes the done channel. +// +// The done channel is set to nil after closing, so that done != nil implies +// the stream is active and done is open. This simplifies checks elsewhere. +func (s *stream) close(reconnectAfter time.Duration) { + s.mu.Lock() + defer s.mu.Unlock() + if s.done == nil { + return // stream not connected or already closed + } + if s.protocolVersion >= protocolVersion20251125 && reconnectAfter > 0 { + reconnectStr := strconv.FormatInt(reconnectAfter.Milliseconds(), 10) + if _, err := writeEvent(s.w, Event{ + Name: "close", + Retry: reconnectStr, + }); err != nil { + s.logger.Warn(fmt.Sprintf("Writing close event: %v", err)) + } + } + close(s.done) + s.done = nil +} + +// release releases the stream from its HTTP request, allowing it to be +// claimed by another request (e.g., for resumption). +func (s *stream) release() { + s.mu.Lock() + defer s.mu.Unlock() + s.w = nil + s.done = nil // may already be nil, if the stream is done or closed +} + +// deliverLocked writes data to the stream (for SSE) or stores it in +// pendingJSONMessages (for JSON mode). The eventID is used for SSE event ID; +// pass "" to omit. +// +// If responseTo is valid, it is removed from the requests map. When all +// requests have been responded to, the done channel is closed and set to nil. +// +// Returns true if the stream is now done (all requests have been responded to). +// The done value is always accurate, even if an error is returned. +// +// s.mu must be held when calling this method. +func (s *stream) deliverLocked(data []byte, eventID string, responseTo jsonrpc.ID) (done bool, err error) { + // First, record the response. We must do this *before* returning an error + // below, as even if the stream is disconnected we want to update our + // accounting. + if responseTo.IsValid() { + delete(s.requests, responseTo) + } + // Now, try to deliver the message to the client. + done = len(s.requests) == 0 && s.id != "" + if s.done == nil { + return done, fmt.Errorf("stream not connected or already closed") + } + if done { + defer func() { close(s.done); s.done = nil }() + } + // Try to write to the response. + // + // If we get here, the request is still hanging (because s.done != nil + // implies s.w != nil), but may have been cancelled by the client/http layer: + // there's a brief race between request cancellation and releasing the + // stream. + if s.pendingJSONMessages != nil { + s.pendingJSONMessages = append(s.pendingJSONMessages, data) + if done { + // Flush all pending messages as JSON response. + var toWrite []byte + if len(s.pendingJSONMessages) == 1 { + toWrite = s.pendingJSONMessages[0] + } else { + toWrite, err = json.Marshal(s.pendingJSONMessages) + if err != nil { + return done, err + } + } + if _, err := s.w.Write(toWrite); err != nil { + return done, err + } + } + } else { + // SSE mode: write event to response writer. + s.lastIdx++ + if _, err := writeEvent(s.w, Event{Name: "message", Data: data, ID: eventID}); err != nil { + return done, err + } + } + return done, nil +} + +// doneLocked reports whether the stream is logically complete. +// +// s.requests was populated when reading the POST body, requests are deleted as +// they are responded to. Once all requests have been responded to, the stream +// is done. +// +// s.mu must be held while calling this function. +func (s *stream) doneLocked() bool { + return len(s.requests) == 0 && s.id != "" +} + +func (c *streamableServerConn) newStream(ctx context.Context, requests map[jsonrpc.ID]struct{}, id string) (*stream, error) { + if c.eventStore != nil { + if err := c.eventStore.Open(ctx, c.sessionID, id); err != nil { + return nil, err + } + } + return &stream{ + id: id, + requests: requests, + lastIdx: -1, // indices start at 0, incremented before each write + logger: c.logger, + }, nil +} + +// We track the incoming request ID inside the handler context using +// idContextValue, so that notifications and server->client calls that occur in +// the course of handling incoming requests are correlated with the incoming +// request that caused them, and can be dispatched as server-sent events to the +// correct HTTP request. +// +// Currently, this is implemented in [ServerSession.handle]. This is not ideal, +// because it means that a user of the MCP package couldn't implement the +// streamable transport, as they'd lack this privileged access. +// +// If we ever wanted to expose this mechanism, we have a few options: +// 1. Make ServerSession an interface, and provide an implementation of +// ServerSession to handlers that closes over the incoming request ID. +// 2. Expose a 'HandlerTransport' interface that allows transports to provide +// a handler middleware, so that we don't hard-code this behavior in +// ServerSession.handle. +// 3. Add a `func ForRequest(context.Context) jsonrpc.ID` accessor that lets +// any transport access the incoming request ID. +// +// For now, by giving only the StreamableServerTransport access to the request +// ID, we avoid having to make this API decision. +type idContextKey struct{} + +// ServeHTTP handles a single HTTP request for the session. +func (t *StreamableServerTransport) ServeHTTP(w http.ResponseWriter, req *http.Request) { + if t.connection == nil { + http.Error(w, "transport not connected", http.StatusInternalServerError) + return + } + switch req.Method { + case http.MethodGet: + t.connection.serveGET(w, req) + case http.MethodPost: + t.connection.servePOST(w, req) + default: + // Should not be reached, as this is checked in StreamableHTTPHandler.ServeHTTP. + w.Header().Set("Allow", "GET, POST") + http.Error(w, "unsupported method", http.StatusMethodNotAllowed) + return + } +} + +// serveGET streams messages to a hanging http GET, with stream ID and last +// message parsed from the Last-Event-ID header. +// +// It returns an HTTP status code and error message. +func (c *streamableServerConn) serveGET(w http.ResponseWriter, req *http.Request) { + // streamID "" corresponds to the default GET request. + streamID := "" + // By default, we haven't seen a last index. Since indices start at 0, we represent + // that by -1. This is incremented just before each event is written. + lastIdx := -1 + if len(req.Header.Values(lastEventIDHeader)) > 0 { + eid := req.Header.Get(lastEventIDHeader) + var ok bool + streamID, lastIdx, ok = parseEventID(eid) + if !ok { + http.Error(w, fmt.Sprintf("malformed Last-Event-ID %q", eid), http.StatusBadRequest) + return + } + if c.eventStore == nil { + http.Error(w, "stream replay unsupported", http.StatusBadRequest) + return + } + } + + ctx := req.Context() + + // Read the protocol version from the header. For GET requests, this should + // always be present since GET only happens after initialization. + protocolVersion := req.Header.Get(protocolVersionHeader) + if protocolVersion == "" { + protocolVersion = protocolVersion20250326 + } + + stream, done := c.acquireStream(ctx, w, streamID, lastIdx, protocolVersion) + if stream == nil { + return + } + defer stream.release() + c.hangResponse(ctx, done) +} + +// hangResponse blocks the HTTP response until one of three conditions is met: +// - ctx is cancelled (the client disconnected or the request timed out) +// - done is closed (all responses have been sent, or the stream was explicitly closed) +// - the session is closed +// +// This keeps the HTTP connection open so that server-sent events can be +// written to the response. +func (c *streamableServerConn) hangResponse(ctx context.Context, done <-chan struct{}) { + select { + case <-ctx.Done(): + case <-done: + case <-c.done: + } +} + +// acquireStream replays all events since lastIdx, and acquires the ongoing +// stream, if any. If non-nil, the resulting stream will be registered for +// receiving new messages, and the stream's done channel will be closed when +// all related messages have been delivered. +// +// If any errors occur, they will be written to w and the resulting stream will +// be nil. The resulting stream may also be nil if the stream is complete. +// +// Importantly, this function must hold the stream mutex until done replaying +// all messages, so that no delivery or storage of new messages occurs while +// the stream is still replaying. +// +// protocolVersion is the protocol version for this stream, used to determine +// feature support (e.g. prime and close events were added in 2025-11-25). +func (c *streamableServerConn) acquireStream(ctx context.Context, w http.ResponseWriter, streamID string, lastIdx int, protocolVersion string) (*stream, chan struct{}) { + // if tempStream is set, the stream is done and we're just replaying messages. + // + // We record a temporary stream to claim exclusive replay rights. The spec + // (https://modelcontextprotocol.io/specification/2025-11-25/basic/transports#resumability-and-redelivery) + // does not explicitly require exclusive replay, but we enforce it defensively. + tempStream := false + c.mu.Lock() + s, ok := c.streams[streamID] + if !ok { + // The stream is logically done, but claim exclusive rights to replay it by + // adding a temporary entry in the streams map. + // + // We create this entry with a non-nil w, to ensure it isn't claimed by + // another request before we lock it below. + tempStream = true + s = &stream{ + id: streamID, + w: w, + } + c.streams[streamID] = s + + // Since this stream is transient, we must clean up after replaying. + defer func() { + c.mu.Lock() + delete(c.streams, streamID) + c.mu.Unlock() + }() + } + c.mu.Unlock() + + s.mu.Lock() + defer s.mu.Unlock() + + // Check that this stream wasn't claimed by another request. + if !tempStream && s.w != nil { + http.Error(w, "stream ID conflicts with ongoing stream", http.StatusConflict) + return nil, nil + } + + // Collect events to replay. Collect them all before writing, so that we + // have an opportunity to set the HTTP status code on an error. + // + // As indicated above, we must do that while holding stream.mu, so that no + // new messages are added to the eventstore until we've replayed all previous + // messages, and registered our delivery function. + var toReplay [][]byte + if c.eventStore != nil { + for data, err := range c.eventStore.After(ctx, c.SessionID(), s.id, lastIdx) { + if err != nil { + // We can't replay events, perhaps because the underlying event store + // has garbage collected its storage. + // + // We must be careful here: any 404 will signal to the client that the + // *session* is not found, rather than the stream. + // + // 400 is not really accurate, but should at least have no side effects. + // Other SDKs (typescript) do not have a mechanism for events to be purged. + http.Error(w, "failed to replay events", http.StatusBadRequest) + return nil, nil + } + if len(data) > 0 { + toReplay = append(toReplay, data) + } + } + } + + w.Header().Set("Cache-Control", "no-cache, no-transform") + w.Header().Set("Content-Type", "text/event-stream") // Accept checked in [StreamableHTTPHandler] + w.Header().Set("Connection", "keep-alive") + + if s.id == "" { + // Issue #410: the standalone SSE stream is likely not to receive messages + // for a long time. Ensure that headers are flushed. + w.WriteHeader(http.StatusOK) + if f, ok := w.(http.Flusher); ok { + f.Flush() + } + } + + for _, data := range toReplay { + lastIdx++ + e := Event{Name: "message", Data: data} + if c.eventStore != nil { + e.ID = formatEventID(s.id, lastIdx) + } + if _, err := writeEvent(w, e); err != nil { + return nil, nil + } + } + + if tempStream || s.doneLocked() { + // Nothing more to do. + return nil, nil + } + + // The stream is not done: set up delivery state before the stream is + // unlocked, allowing the connection to write new events. + s.w = w + s.done = make(chan struct{}) + s.lastIdx = lastIdx + s.protocolVersion = protocolVersion + return s, s.done +} + +// servePOST handles an incoming message, and replies with either an outgoing +// message stream or single response object, depending on whether the +// jsonResponse option is set. +// +// It returns an HTTP status code and error message. +func (c *streamableServerConn) servePOST(w http.ResponseWriter, req *http.Request) { + if len(req.Header.Values(lastEventIDHeader)) > 0 { + http.Error(w, "can't send Last-Event-ID for POST request", http.StatusBadRequest) + return + } + + // Read incoming messages. + body, err := io.ReadAll(req.Body) + if err != nil { + http.Error(w, "failed to read body", http.StatusBadRequest) + return + } + if len(body) == 0 { + http.Error(w, "POST requires a non-empty body", http.StatusBadRequest) + return + } + // TODO(#674): once we've documented the support matrix for 2025-03-26 and + // earlier, drop support for matching entirely; that will simplify this + // logic. + incoming, isBatch, err := readBatch(body) + if err != nil { + http.Error(w, fmt.Sprintf("malformed payload: %v", err), http.StatusBadRequest) + return + } + + protocolVersion := req.Header.Get(protocolVersionHeader) + if protocolVersion == "" { + protocolVersion = protocolVersion20250326 + } + + if isBatch && protocolVersion >= protocolVersion20250618 { + http.Error(w, fmt.Sprintf("JSON-RPC batching is not supported in %s and later (request version: %s)", protocolVersion20250618, protocolVersion), http.StatusBadRequest) + return + } + + // TODO(rfindley): no tests fail if we reject batch JSON requests entirely. + // We need to test this with older protocol versions. + // if isBatch && c.jsonResponse { + // http.Error(w, "server does not support batch requests", http.StatusBadRequest) + // return + // } + + calls := make(map[jsonrpc.ID]struct{}) + tokenInfo := auth.TokenInfoFromContext(req.Context()) + isInitialize := false + var initializeProtocolVersion string + for _, msg := range incoming { + if jreq, ok := msg.(*jsonrpc.Request); ok { + // Preemptively check that this is a valid request, so that we can fail + // the HTTP request. If we didn't do this, a request with a bad method or + // missing ID could be silently swallowed. + if _, err := checkRequest(jreq, serverMethodInfos); err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + if jreq.Method == methodInitialize { + isInitialize = true + // Extract the protocol version from InitializeParams. + var params InitializeParams + if err := internaljson.Unmarshal(jreq.Params, ¶ms); err == nil { + initializeProtocolVersion = params.ProtocolVersion + } + } + // Include metadata for all requests (including notifications). + jreq.Extra = &RequestExtra{ + TokenInfo: tokenInfo, + Header: req.Header, + } + if jreq.IsCall() { + calls[jreq.ID] = struct{}{} + // See the doc for CloseSSEStream: allow the request handler to + // explicitly close the ongoing stream. + jreq.Extra.(*RequestExtra).CloseSSEStream = func(args CloseSSEStreamArgs) { + c.mu.Lock() + streamID, ok := c.requestStreams[jreq.ID] + var stream *stream + if ok { + stream = c.streams[streamID] + } + c.mu.Unlock() + + if stream != nil { + stream.close(args.RetryAfter) + } + } + } + } + } + + // The prime and close events were added in protocol version 2025-11-25 (SEP-1699). + // Use the version from InitializeParams if this is an initialize request, + // otherwise use the protocol version header. + effectiveVersion := protocolVersion + if isInitialize && initializeProtocolVersion != "" { + effectiveVersion = initializeProtocolVersion + } + + // If we don't have any calls, we can just publish the incoming messages and return. + // No need to track a logical stream. + // + // See section [§2.1.4] of the spec: "If the server accepts the input, the + // server MUST return HTTP status code 202 Accepted with no body." + // + // [§2.1.4]: https://modelcontextprotocol.io/specification/2025-11-25/basic/transports#sending-messages-to-the-server + if len(calls) == 0 { + for _, msg := range incoming { + select { + case c.incoming <- msg: + case <-c.done: + // The session is closing. Since we haven't yet written any data to the + // response, we can signal to the client that the session is gone. + http.Error(w, "session is closing", http.StatusNotFound) + return + } + } + w.WriteHeader(http.StatusAccepted) + return + } + + // Invariant: we have at least one call. + // + // Create a logical stream to track its responses. + // Important: don't publish the incoming messages until the stream is + // registered, as the server may attempt to respond to imcoming messages as + // soon as they're published. + stream, err := c.newStream(req.Context(), calls, crand.Text()) + if err != nil { + http.Error(w, fmt.Sprintf("storing stream: %v", err), http.StatusInternalServerError) + return + } + + // Set response headers. Accept was checked in [StreamableHTTPHandler]. + w.Header().Set("Cache-Control", "no-cache, no-transform") + if c.jsonResponse { + w.Header().Set("Content-Type", "application/json") + } else { + w.Header().Set("Content-Type", "text/event-stream") + w.Header().Set("Connection", "keep-alive") + } + if c.sessionID != "" && isInitialize { + w.Header().Set(sessionIDHeader, c.sessionID) + } + + // Set up stream delivery state. + stream.w = w + done := make(chan struct{}) + stream.done = done + stream.protocolVersion = effectiveVersion + if c.jsonResponse { + // JSON mode: collect messages in pendingJSONMessages until done. + // Set pendingJSONMessages to a non-nil value to signal that this is an + // application/json stream. + stream.pendingJSONMessages = []json.RawMessage{} + } else { + // SSE mode: write a priming event if supported. + if c.eventStore != nil && effectiveVersion >= protocolVersion20251125 { + // Write a priming event, as defined by [§2.1.6] of the spec. + // + // [§2.1.6]: https://modelcontextprotocol.io/specification/2025-11-25/basic/transports#sending-messages-to-the-server + // + // We must also write it to the event store in order for indexes to + // align. + if err := c.eventStore.Append(req.Context(), c.sessionID, stream.id, nil); err != nil { + c.logger.Warn(fmt.Sprintf("Storing priming event: %v", err)) + } + stream.lastIdx++ + e := Event{Name: "prime", ID: formatEventID(stream.id, stream.lastIdx)} + if _, err := writeEvent(w, e); err != nil { + c.logger.Warn(fmt.Sprintf("Writing priming event: %v", err)) + } + } + } + + // TODO(rfindley): if we have no event store, we should really cancel all + // remaining requests here, since the client will never get the results. + defer stream.release() + + // The stream is now set up to deliver messages. + // + // Register it before publishing incoming messages. + c.mu.Lock() + c.streams[stream.id] = stream + for reqID := range calls { + c.requestStreams[reqID] = stream.id + } + c.mu.Unlock() + + // Publish incoming messages. + for _, msg := range incoming { + select { + case c.incoming <- msg: + // Note: don't select on req.Context().Done() here, since we've already + // received the requests and may have already published a response message + // or notification. The client could resume the stream. + // + // In fact, this send could be in a separate goroutine. + case <-c.done: + // Session closed: we don't know if any data has been written, so it's + // too late to write a status code here. + return + } + } + + c.hangResponse(req.Context(), done) +} + +// Event IDs: encode both the logical connection ID and the index, as +// _, to be consistent with the typescript implementation. + +// formatEventID returns the event ID to use for the logical connection ID +// streamID and message index idx. +// +// See also [parseEventID]. +func formatEventID(sid string, idx int) string { + return fmt.Sprintf("%s_%d", sid, idx) +} + +// parseEventID parses a Last-Event-ID value into a logical stream id and +// index. +// +// See also [formatEventID]. +func parseEventID(eventID string) (streamID string, idx int, ok bool) { + parts := strings.Split(eventID, "_") + if len(parts) != 2 { + return "", 0, false + } + streamID = parts[0] + idx, err := strconv.Atoi(parts[1]) + if err != nil || idx < 0 { + return "", 0, false + } + return streamID, idx, true +} + +// Read implements the [Connection] interface. +func (c *streamableServerConn) Read(ctx context.Context) (jsonrpc.Message, error) { + select { + case <-ctx.Done(): + return nil, ctx.Err() + case msg, ok := <-c.incoming: + if !ok { + return nil, io.EOF + } + return msg, nil + case <-c.done: + return nil, io.EOF + } +} + +// Write implements the [Connection] interface. +func (c *streamableServerConn) Write(ctx context.Context, msg jsonrpc.Message) error { + // Throughout this function, note that any error that wraps ErrRejected + // indicates a does not cause the connection to break. + // + // Most errors don't break the connection: unlike a true bidirectional + // stream, a failure to deliver to a stream is not an indication that the + // logical session is broken. + data, err := jsonrpc2.EncodeMessage(msg) + if err != nil { + return err + } + + if req, ok := msg.(*jsonrpc.Request); ok && req.IsCall() && (c.stateless || c.sessionID == "") { + // Requests aren't possible with stateless servers, or when there's no session ID. + return fmt.Errorf("%w: stateless servers cannot make requests", jsonrpc2.ErrRejected) + } + + // Find the incoming request that this write relates to, if any. + var ( + relatedRequest jsonrpc.ID + responseTo jsonrpc.ID // if valid, the message is a response to this request + ) + if resp, ok := msg.(*jsonrpc.Response); ok { + // If the message is a response, it relates to its request (of course). + relatedRequest = resp.ID + responseTo = resp.ID + } else { + // Otherwise, we check to see if it request was made in the context of an + // ongoing request. This may not be the case if the request was made with + // an unrelated context. + if v := ctx.Value(idContextKey{}); v != nil { + relatedRequest = v.(jsonrpc.ID) + } + } + + // If the stream is application/json, but the message is not a response, we + // must send it out of band to the standalone SSE stream. + if c.jsonResponse && !responseTo.IsValid() { + relatedRequest = jsonrpc.ID{} + } + + // Write the message to the stream. + var s *stream + c.mu.Lock() + if relatedRequest.IsValid() { + if streamID, ok := c.requestStreams[relatedRequest]; ok { + s = c.streams[streamID] + } + } else { + s = c.streams[""] // standalone SSE stream + } + if responseTo.IsValid() { + // Once we've responded to a request, disallow related messages by removing + // the stream association. This also releases memory. + delete(c.requestStreams, responseTo) + } + sessionClosed := c.isDone + c.mu.Unlock() + + if s == nil { + // The request was made in the context of an ongoing request, but that + // request is complete. + // + // In the future, we could be less strict and allow the request to land on + // the standalone SSE stream. + return fmt.Errorf("%w: write to closed stream", jsonrpc2.ErrRejected) + } + if sessionClosed { + return errors.New("session is closed") + } + + s.mu.Lock() + defer s.mu.Unlock() + + // Store in eventStore before delivering. + // TODO(rfindley): we should only append if the response is SSE, not JSON, by + // pushing down into the delivery layer. + delivered := false + var errs []error + if c.eventStore != nil { + if err := c.eventStore.Append(ctx, c.sessionID, s.id, data); err != nil { + errs = append(errs, err) + } else { + delivered = true + } + } + + // Compute eventID for SSE streams with event store. + // Use s.lastIdx + 1 because deliverLocked increments before writing. + var eventID string + if c.eventStore != nil { + eventID = formatEventID(s.id, s.lastIdx+1) + } + + done, err := s.deliverLocked(data, eventID, responseTo) + if err != nil { + errs = append(errs, err) + } else { + delivered = true + } + + if done { + c.mu.Lock() + delete(c.streams, s.id) + c.mu.Unlock() + } + + if !delivered { + return fmt.Errorf("%w: undelivered message: %v", jsonrpc2.ErrRejected, errors.Join(errs...)) + } + return nil +} + +// Close implements the [Connection] interface. +func (c *streamableServerConn) Close() error { + c.mu.Lock() + defer c.mu.Unlock() + if !c.isDone { + c.isDone = true + close(c.done) + if c.eventStore != nil { + // TODO: find a way to plumb a context here, or an event store with a long-running + // close operation can take arbitrary time. Alternative: impose a fixed timeout here. + return c.eventStore.SessionClosed(context.TODO(), c.sessionID) + } + } + return nil +} + +// A StreamableClientTransport is a [Transport] that can communicate with an MCP +// endpoint serving the streamable HTTP transport defined by the 2025-03-26 +// version of the spec. +type StreamableClientTransport struct { + Endpoint string + HTTPClient *http.Client + // MaxRetries is the maximum number of times to attempt a reconnect before giving up. + // It defaults to 5. To disable retries, use a negative number. + MaxRetries int + + // DisableStandaloneSSE controls whether the client establishes a standalone SSE stream + // for receiving server-initiated messages. + // + // When false (the default), after initialization the client sends an HTTP GET request + // to establish a persistent server-sent events (SSE) connection. This allows the server + // to send messages to the client at any time, such as ToolListChangedNotification or + // other server-initiated requests and notifications. The connection persists for the + // lifetime of the session and automatically reconnects if interrupted. + // + // When true, the client does not establish the standalone SSE stream. The client will + // only receive responses to its own POST requests. Server-initiated messages will not + // be received. + // + // According to the MCP specification, the standalone SSE stream is optional. + // Setting DisableStandaloneSSE to true is useful when: + // - You only need request-response communication and don't need server-initiated notifications + // - The server doesn't properly handle GET requests for SSE streams + // - You want to avoid maintaining a persistent connection + DisableStandaloneSSE bool + + // OAuthHandler is an optional field that, if provided, will be used to authorize the requests. + OAuthHandler auth.OAuthHandler + + // TODO(rfindley): propose exporting these. + // If strict is set, the transport is in 'strict mode', where any violation + // of the MCP spec causes a failure. + strict bool + // If logger is set, it is used to log aspects of the transport, such as spec + // violations that were ignored. + logger *slog.Logger +} + +// These settings are not (yet) exposed to the user in +// StreamableClientTransport. +const ( + // reconnectGrowFactor is the multiplicative factor by which the delay increases after each attempt. + // A value of 1.0 results in a constant delay, while a value of 2.0 would double it each time. + // It must be 1.0 or greater if MaxRetries is greater than 0. + reconnectGrowFactor = 1.5 + // reconnectMaxDelay caps the backoff delay, preventing it from growing indefinitely. + reconnectMaxDelay = 30 * time.Second +) + +var ( + // reconnectInitialDelay is the base delay for the first reconnect attempt. + // + // Mutable for testing. + reconnectInitialDelay = 1 * time.Second +) + +// Connect implements the [Transport] interface. +// +// The resulting [Connection] writes messages via POST requests to the +// transport URL with the Mcp-Session-Id header set, and reads messages from +// hanging requests. +// +// When closed, the connection issues a DELETE request to terminate the logical +// session. +func (t *StreamableClientTransport) Connect(ctx context.Context) (Connection, error) { + client := t.HTTPClient + if client == nil { + client = http.DefaultClient + } + maxRetries := t.MaxRetries + if maxRetries == 0 { + maxRetries = 5 + } else if maxRetries < 0 { + maxRetries = 0 + } + // Create a new cancellable context that will manage the connection's lifecycle. + // This is crucial for cleanly shutting down the background SSE listener by + // cancelling its blocking network operations, which prevents hangs on exit. + // + // This context should be detached from the incoming context: the standalone + // SSE request should not break when the connection context is done. + // + // For example, consider that the user may want to wait at most 5s to connect + // to the server, and therefore uses a context with a 5s timeout when calling + // client.Connect. Let's suppose that Connect returns after 1s, and the user + // starts using the resulting session. If we didn't detach here, the session + // would break after 4s, when the background SSE stream is terminated. + // + // Instead, creating a cancellable context detached from the incoming context + // allows us to preserve context values (which may be necessary for auth + // middleware), yet only cancel the standalone stream when the connection is closed. + connCtx, cancel := context.WithCancel(xcontext.Detach(ctx)) + conn := &streamableClientConn{ + url: t.Endpoint, + client: client, + incoming: make(chan jsonrpc.Message, 10), + done: make(chan struct{}), + maxRetries: maxRetries, + strict: t.strict, + logger: ensureLogger(t.logger), // must be non-nil for safe logging + ctx: connCtx, + cancel: cancel, + failed: make(chan struct{}), + disableStandaloneSSE: t.DisableStandaloneSSE, + oauthHandler: t.OAuthHandler, + } + return conn, nil +} + +type streamableClientConn struct { + url string + client *http.Client + ctx context.Context // connection context, detached from Connect + cancel context.CancelFunc // cancels ctx + incoming chan jsonrpc.Message + maxRetries int + strict bool // from [StreamableClientTransport.strict] + logger *slog.Logger // from [StreamableClientTransport.logger] + + // disableStandaloneSSE controls whether to disable the standalone SSE stream + // for receiving server-to-client notifications when no request is in flight. + disableStandaloneSSE bool // from [StreamableClientTransport.DisableStandaloneSSE] + + // oauthHandler is the OAuth handler for the connection. + oauthHandler auth.OAuthHandler // from [StreamableClientTransport.OAuthHandler] + + // Guard calls to Close, as it may be called multiple times. + closeOnce sync.Once + closeErr error + done chan struct{} // signal graceful termination + + // Logical reads are distributed across multiple http requests. Whenever any + // of them fails to process their response, we must break the connection, by + // failing the pending Read. + // + // Achieve this by storing the failure message, and signalling when reads are + // broken. See also [streamableClientConn.fail] and + // [streamableClientConn.failure]. + failOnce sync.Once + _failure error + failed chan struct{} // signal failure + + // Guard the initialization state. + mu sync.Mutex + initializedResult *InitializeResult + sessionID string +} + +var _ clientConnection = (*streamableClientConn)(nil) + +func (c *streamableClientConn) sessionUpdated(state clientSessionState) { + c.mu.Lock() + c.initializedResult = state.InitializeResult + c.mu.Unlock() + + // Start the standalone SSE stream as soon as we have the initialized + // result, if continuous listening is enabled. + // + // § 2.2: The client MAY issue an HTTP GET to the MCP endpoint. This can be + // used to open an SSE stream, allowing the server to communicate to the + // client, without the client first sending data via HTTP POST. + // + // We have to wait for initialized, because until we've received + // initialized, we don't know whether the server requires a sessionID. + // + // § 2.5: A server using the Streamable HTTP transport MAY assign a session + // ID at initialization time, by including it in a Mcp-Session-Id header + // on the HTTP response containing the InitializeResult. + if !c.disableStandaloneSSE { + c.connectStandaloneSSE() + } +} + +func (c *streamableClientConn) connectStandaloneSSE() { + resp, err := c.connectSSE(c.ctx, "", 0, true) + if err != nil { + // If the client didn't cancel the request, and failure breaks the logical + // session. + if c.ctx.Err() == nil { + c.fail(fmt.Errorf("standalone SSE request failed (session ID: %v): %v", c.sessionID, err)) + } + return + } + + // [§2.2.3]: "The server MUST either return Content-Type: + // text/event-stream in response to this HTTP GET, or else return HTTP + // 405 Method Not Allowed, indicating that the server does not offer an + // SSE stream at this endpoint." + // + // [§2.2.3]: https://modelcontextprotocol.io/specification/2025-06-18/basic/transports#listening-for-messages-from-the-server + if resp.StatusCode == http.StatusMethodNotAllowed { + // The server doesn't support the standalone SSE stream. + resp.Body.Close() + return + } + if resp.Header.Get("Content-Type") != "text/event-stream" { + // modelcontextprotocol/go-sdk#736: some servers return 200 OK or redirect with + // non-SSE content type instead of text/event-stream for the standalone + // SSE stream. + c.logger.Warn(fmt.Sprintf("got Content-Type %s instead of text/event-stream for standalone SSE stream", resp.Header.Get("Content-Type"))) + resp.Body.Close() + return + } + if resp.StatusCode >= 400 && resp.StatusCode < 500 && !c.strict { + // modelcontextprotocol/go-sdk#393,#610: some servers return NotFound or + // other status codes instead of MethodNotAllowed for the standalone SSE + // stream. + // + // Treat this like MethodNotAllowed in non-strict mode. + c.logger.Warn(fmt.Sprintf("got %d instead of 405 for standalone SSE stream", resp.StatusCode)) + resp.Body.Close() + return + } + summary := "standalone SSE stream" + if err := c.checkResponse(summary, resp); err != nil { + c.fail(err) + return + } + go c.handleSSE(c.ctx, summary, resp, nil) +} + +// fail handles an asynchronous error while reading. +// +// If err is non-nil, it is terminal, and subsequent (or pending) Reads will +// fail. +// +// If err wraps ErrSessionMissing, the failure indicates that the session is no +// longer present on the server, and no final DELETE will be performed when +// closing the connection. +func (c *streamableClientConn) fail(err error) { + if err != nil { + c.failOnce.Do(func() { + c._failure = err + close(c.failed) + }) + } +} + +func (c *streamableClientConn) failure() error { + select { + case <-c.failed: + return c._failure + default: + return nil + } +} + +func (c *streamableClientConn) SessionID() string { + c.mu.Lock() + defer c.mu.Unlock() + return c.sessionID +} + +// Read implements the [Connection] interface. +func (c *streamableClientConn) Read(ctx context.Context) (jsonrpc.Message, error) { + if err := c.failure(); err != nil { + return nil, err + } + select { + case <-ctx.Done(): + return nil, ctx.Err() + case <-c.failed: + return nil, c.failure() + case <-c.done: + return nil, io.EOF + case msg := <-c.incoming: + return msg, nil + } +} + +// Write implements the [Connection] interface. +func (c *streamableClientConn) Write(ctx context.Context, msg jsonrpc.Message) error { + if err := c.failure(); err != nil { + return err + } + + var requestSummary string + var forCall *jsonrpc.Request + switch msg := msg.(type) { + case *jsonrpc.Request: + requestSummary = fmt.Sprintf("sending %q", msg.Method) + if msg.IsCall() { + forCall = msg + } + case *jsonrpc.Response: + requestSummary = fmt.Sprintf("sending jsonrpc response #%d", msg.ID) + default: + panic("unreachable") + } + + data, err := jsonrpc.EncodeMessage(msg) + if err != nil { + return fmt.Errorf("%s: %v", requestSummary, err) + } + + doRequest := func() (*http.Request, *http.Response, error) { + req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.url, bytes.NewReader(data)) + if err != nil { + return nil, nil, err + } + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Accept", "application/json, text/event-stream") + if err := c.setMCPHeaders(req); err != nil { + // Failure to set headers means that the request was not sent. + // Wrap with ErrRejected so the jsonrpc2 connection doesn't set writeErr + // and permanently break the connection. + return nil, nil, fmt.Errorf("%s: %w: %v", requestSummary, jsonrpc2.ErrRejected, err) + } + resp, err := c.client.Do(req) + if err != nil { + // Any error from client.Do means the request didn't reach the server. + // Wrap with ErrRejected so the jsonrpc2 connection doesn't set writeErr + // and permanently break the connection. + err = fmt.Errorf("%s: %w: %v", requestSummary, jsonrpc2.ErrRejected, err) + } + return req, resp, err + } + + req, resp, err := doRequest() + if err != nil { + return err + } + + if (resp.StatusCode == http.StatusUnauthorized || resp.StatusCode == http.StatusForbidden) && c.oauthHandler != nil { + if err := c.oauthHandler.Authorize(ctx, req, resp); err != nil { + // Wrap with ErrRejected so the jsonrpc2 connection doesn't set writeErr + // and permanently break the connection. + // Wrap the authorization error as well for client inspection. + return fmt.Errorf("%s: %w: %w", requestSummary, jsonrpc2.ErrRejected, err) + } + // Retry the request after successful authorization. + _, resp, err = doRequest() + if err != nil { + return err + } + } + + if err := c.checkResponse(requestSummary, resp); err != nil { + // Only fail the connection for non-transient errors. + // Transient errors (wrapped with ErrRejected) should not break the connection. + if !errors.Is(err, jsonrpc2.ErrRejected) { + c.fail(err) + } + return err + } + + if sessionID := resp.Header.Get(sessionIDHeader); sessionID != "" { + c.mu.Lock() + hadSessionID := c.sessionID + if hadSessionID == "" { + c.sessionID = sessionID + } + c.mu.Unlock() + if hadSessionID != "" && hadSessionID != sessionID { + resp.Body.Close() + return fmt.Errorf("mismatching session IDs %q and %q", hadSessionID, sessionID) + } + } + + if forCall == nil { + resp.Body.Close() + + // [§2.1.4]: "If the input is a JSON-RPC response or notification: + // If the server accepts the input, the server MUST return HTTP status code 202 Accepted with no body." + // + // [§2.1.4]: https://modelcontextprotocol.io/specification/2025-06-18/basic/transports#listening-for-messages-from-the-server + if resp.StatusCode != http.StatusNoContent && resp.StatusCode != http.StatusAccepted { + errMsg := fmt.Sprintf("unexpected status code %d from non-call", resp.StatusCode) + // Some servers return 200, even with an empty json body. + // + // In strict mode, return an error to the caller. + c.logger.Warn(errMsg) + if c.strict { + return errors.New(errMsg) + } + } + return nil + } + + contentType := strings.TrimSpace(strings.SplitN(resp.Header.Get("Content-Type"), ";", 2)[0]) + switch contentType { + case "application/json": + go c.handleJSON(requestSummary, resp) + + case "text/event-stream": + var forCall *jsonrpc.Request + if jsonReq, ok := msg.(*jsonrpc.Request); ok && jsonReq.IsCall() { + forCall = jsonReq + } + // Handle the resulting stream. Note that ctx comes from the call, and + // therefore is already cancelled when the JSON-RPC request is cancelled + // (or rather, context cancellation is what *triggers* JSON-RPC + // cancellation) + go c.handleSSE(ctx, requestSummary, resp, forCall) + + default: + resp.Body.Close() + return fmt.Errorf("%s: unsupported content type %q", requestSummary, contentType) + } + return nil +} + +func (c *streamableClientConn) setMCPHeaders(req *http.Request) error { + c.mu.Lock() + defer c.mu.Unlock() + + if c.oauthHandler != nil { + ts, err := c.oauthHandler.TokenSource(c.ctx) + if err != nil { + return err + } + if ts != nil { + token, err := ts.Token() + if err != nil { + return err + } + if token != nil { + req.Header.Set("Authorization", "Bearer "+token.AccessToken) + } + } + } + if c.initializedResult != nil { + req.Header.Set(protocolVersionHeader, c.initializedResult.ProtocolVersion) + } + if c.sessionID != "" { + req.Header.Set(sessionIDHeader, c.sessionID) + } + return nil +} + +func (c *streamableClientConn) handleJSON(requestSummary string, resp *http.Response) { + body, err := io.ReadAll(resp.Body) + resp.Body.Close() + if err != nil { + c.fail(fmt.Errorf("%s: failed to read body: %v", requestSummary, err)) + return + } + msg, err := jsonrpc.DecodeMessage(body) + if err != nil { + c.fail(fmt.Errorf("%s: failed to decode response: %v", requestSummary, err)) + return + } + select { + case c.incoming <- msg: + case <-c.done: + // The connection was closed by the client; exit gracefully. + } +} + +// handleSSE manages the lifecycle of an SSE connection. It can be either +// persistent (for the main GET listener) or temporary (for a POST response). +// +// If forCall is set, it is the call that initiated the stream, and the +// stream is complete when we receive its response. Otherwise, this is the +// standalone stream. +func (c *streamableClientConn) handleSSE(ctx context.Context, requestSummary string, resp *http.Response, forCall *jsonrpc2.Request) { + // Track the last event ID to detect progress. + // The retry counter is only reset when progress is made (lastEventID advances). + // This prevents infinite retry loops when a server repeatedly terminates + // connections without making progress (#679). + var prevLastEventID string + retriesWithoutProgress := 0 + + for { + lastEventID, reconnectDelay, clientClosed := c.processStream(ctx, requestSummary, resp, forCall) + + // If the connection was closed by the client, we're done. + if clientClosed { + return + } + // If we don't have a last event ID, we can never get the call response, so + // there's nothing to resume. For the standalone stream, we can reconnect, + // but we may just miss messages. + if lastEventID == "" && forCall != nil { + return + } + + // Check if we made progress (lastEventID advanced). + // Only reset the retry counter when actual progress is made. + if lastEventID != "" && lastEventID != prevLastEventID { + // Progress was made: reset the retry counter. + retriesWithoutProgress = 0 + prevLastEventID = lastEventID + } else { + // No progress: increment the retry counter. + retriesWithoutProgress++ + if retriesWithoutProgress > c.maxRetries { + if ctx.Err() == nil { + c.fail(fmt.Errorf("%s: exceeded %d retries without progress (session ID: %v)", requestSummary, c.maxRetries, c.sessionID)) + } + return + } + } + + // The stream was interrupted or ended by the server. Attempt to reconnect. + newResp, err := c.connectSSE(ctx, lastEventID, reconnectDelay, false) + if err != nil { + // If the client didn't cancel this request, any failure to execute it + // breaks the logical MCP session. + if ctx.Err() == nil { + // All reconnection attempts failed: fail the connection. + c.fail(fmt.Errorf("%s: failed to reconnect (session ID: %v): %v", requestSummary, c.sessionID, err)) + } + return + } + + resp = newResp + if err := c.checkResponse(requestSummary, resp); err != nil { + c.fail(err) + return + } + } +} + +// checkResponse checks the status code of the provided response, and +// translates it into an error if the request was unsuccessful. +// +// The response body is close if a non-nil error is returned. +func (c *streamableClientConn) checkResponse(requestSummary string, resp *http.Response) (err error) { + defer func() { + if err != nil { + resp.Body.Close() + } + }() + // §2.5.3: "The server MAY terminate the session at any time, after + // which it MUST respond to requests containing that session ID with HTTP + // 404 Not Found." + if resp.StatusCode == http.StatusNotFound { + // Return an ErrSessionMissing to avoid sending a redundant DELETE when the + // session is already gone. + return fmt.Errorf("%s: failed to connect (session ID: %v): %w", requestSummary, c.sessionID, ErrSessionMissing) + } + // Transient server errors (502, 503, 504, 429) should not break the connection. + // Wrap them with ErrRejected so the jsonrpc2 layer doesn't set writeErr. + if isTransientHTTPStatus(resp.StatusCode) { + return fmt.Errorf("%w: %s: %v", jsonrpc2.ErrRejected, requestSummary, http.StatusText(resp.StatusCode)) + } + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + return fmt.Errorf("%s: %v", requestSummary, http.StatusText(resp.StatusCode)) + } + return nil +} + +// processStream reads from a single response body, sending events to the +// incoming channel. It returns the ID of the last processed event and a flag +// indicating if the connection was closed by the client. If resp is nil, it +// returns "", false. +func (c *streamableClientConn) processStream(ctx context.Context, requestSummary string, resp *http.Response, forCall *jsonrpc.Request) (lastEventID string, reconnectDelay time.Duration, clientClosed bool) { + defer func() { + // Drain any remaining unprocessed body. This allows the connection to be re-used after closing. + io.Copy(io.Discard, resp.Body) + resp.Body.Close() + }() + for evt, err := range scanEvents(resp.Body) { + if err != nil { + if ctx.Err() != nil { + return "", 0, true // don't reconnect: client cancelled + } + + // Malformed events are hard errors that indicate corrupted data or protocol + // violations. These should fail the connection permanently. + if errors.Is(err, errMalformedEvent) { + c.fail(fmt.Errorf("%s: %v", requestSummary, err)) + return "", 0, true + } + + break + } + + if evt.ID != "" { + lastEventID = evt.ID + } + + if evt.Retry != "" { + if n, err := strconv.ParseInt(evt.Retry, 10, 64); err == nil { + reconnectDelay = time.Duration(n) * time.Millisecond + } + } + + // According to SSE specification + // (https://html.spec.whatwg.org/multipage/server-sent-events.html#event-stream-interpretation) + // events with an empty data buffer are allowed. + // In MCP these can be priming events (SEP-1699) that carry only a Last-Event-ID for stream resumption. + if len(evt.Data) == 0 { + continue + } + + // According to SSE spec, events with no name default to "message" + if evt.Name != "" && evt.Name != "message" { + continue + } + + msg, err := jsonrpc.DecodeMessage(evt.Data) + if err != nil { + c.fail(fmt.Errorf("%s: failed to decode event: %v", requestSummary, err)) + return "", 0, true + } + + select { + case c.incoming <- msg: + // Check if this is the response to our call, which terminates the request. + // (it could also be a server->client request or notification). + if jsonResp, ok := msg.(*jsonrpc.Response); ok && forCall != nil { + // TODO: we should never get a response when forReq is nil (the standalone SSE request). + // We should detect this case. + if jsonResp.ID == forCall.ID { + return "", 0, true + } + } + + case <-c.done: + // The connection was closed by the client; exit gracefully. + return "", 0, true + } + } + // The loop finished without an error, indicating the server closed the stream. + // + // If the lastEventID is "", the stream is not retryable and we should + // report a synthetic error for the call. + // + // Note that this is different from the cancellation case above, since the + // caller is still waiting for a response that will never come. + if lastEventID == "" && forCall != nil { + errmsg := &jsonrpc2.Response{ + ID: forCall.ID, + Error: fmt.Errorf("request terminated without response"), + } + select { + case c.incoming <- errmsg: + case <-c.done: + } + } + return lastEventID, reconnectDelay, false +} + +// connectSSE handles the logic of connecting a text/event-stream connection. +// +// If lastEventID is set, it is the last-event ID of a stream being resumed. +// +// If connection fails, connectSSE retries with an exponential backoff +// strategy. It returns a new, valid HTTP response if successful, or an error +// if all retries are exhausted. +// +// reconnectDelay is the delay set by the server using the SSE retry field, or +// 0. +// +// If initial is set, this is the initial attempt. +// +// If connectSSE exits due to context cancellation, the result is (nil, ctx.Err()). +func (c *streamableClientConn) connectSSE(ctx context.Context, lastEventID string, reconnectDelay time.Duration, initial bool) (*http.Response, error) { + var finalErr error + attempt := 0 + if !initial { + // We've already connected successfully once, so delay subsequent + // reconnections. Otherwise, if the server returns 200 but terminates the + // connection, we'll reconnect as fast as we can, ad infinitum. + // + // TODO: we should consider also setting a limit on total attempts for one + // logical request. + attempt = 1 + } + delay := calculateReconnectDelay(attempt) + if reconnectDelay > 0 { + delay = reconnectDelay // honor the server's requested initial delay + } + for ; attempt <= c.maxRetries; attempt++ { + select { + case <-c.done: + return nil, fmt.Errorf("connection closed by client during reconnect") + + case <-ctx.Done(): + // If the connection context is canceled, the request below will not + // succeed anyway. + return nil, ctx.Err() + + case <-time.After(delay): + req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.url, nil) + if err != nil { + return nil, err + } + if err := c.setMCPHeaders(req); err != nil { + return nil, err + } + if lastEventID != "" { + req.Header.Set(lastEventIDHeader, lastEventID) + } + req.Header.Set("Accept", "text/event-stream") + resp, err := c.client.Do(req) + if err != nil { + finalErr = err // Store the error and try again. + delay = calculateReconnectDelay(attempt + 1) + continue + } + return resp, nil + } + } + // If the loop completes, all retries have failed, or the client is closing. + if finalErr != nil { + return nil, fmt.Errorf("connection failed after %d attempts: %w", c.maxRetries, finalErr) + } + return nil, fmt.Errorf("connection aborted after %d attempts", c.maxRetries) +} + +// Close implements the [Connection] interface. +func (c *streamableClientConn) Close() error { + c.closeOnce.Do(func() { + if errors.Is(c.failure(), ErrSessionMissing) { + // If the session is missing, no need to delete it. + } else { + req, err := http.NewRequestWithContext(c.ctx, http.MethodDelete, c.url, nil) + if err != nil { + c.closeErr = err + } else { + if err := c.setMCPHeaders(req); err != nil { + c.closeErr = err + } else if _, err := c.client.Do(req); err != nil { + c.closeErr = err + } + } + } + + // Cancel any hanging network requests after cleanup. + c.cancel() + close(c.done) + }) + return c.closeErr +} + +// calculateReconnectDelay calculates a delay using exponential backoff with full jitter. +func calculateReconnectDelay(attempt int) time.Duration { + if attempt == 0 { + return 0 + } + // Calculate the exponential backoff using the grow factor. + backoffDuration := time.Duration(float64(reconnectInitialDelay) * math.Pow(reconnectGrowFactor, float64(attempt-1))) + // Cap the backoffDuration at maxDelay. + backoffDuration = min(backoffDuration, reconnectMaxDelay) + + // Use a full jitter using backoffDuration + jitter := rand.N(backoffDuration) + + return backoffDuration + jitter +} + +// isTransientHTTPStatus reports whether the HTTP status code indicates a +// transient server error that should not permanently break the connection. +func isTransientHTTPStatus(statusCode int) bool { + switch statusCode { + case http.StatusInternalServerError, // 500 + http.StatusBadGateway, // 502 + http.StatusServiceUnavailable, // 503 + http.StatusGatewayTimeout, // 504 + http.StatusTooManyRequests: // 429 + return true + } + return false +} diff --git a/mcp/dsx-exchange-mcp/vendor/github.com/modelcontextprotocol/go-sdk/mcp/streamable_client.go b/mcp/dsx-exchange-mcp/vendor/github.com/modelcontextprotocol/go-sdk/mcp/streamable_client.go new file mode 100644 index 0000000..c2cc25b --- /dev/null +++ b/mcp/dsx-exchange-mcp/vendor/github.com/modelcontextprotocol/go-sdk/mcp/streamable_client.go @@ -0,0 +1,226 @@ +// Copyright 2025 The Go MCP SDK Authors. All rights reserved. +// Use of this source code is governed by an MIT-style +// license that can be found in the LICENSE file. + +// TODO: move client-side streamable HTTP logic from streamable.go to this file. + +package mcp + +/* +Streamable HTTP Client Design + +This document describes the client-side implementation of the MCP streamable +HTTP transport, as defined by the MCP spec: +https://modelcontextprotocol.io/specification/2025-11-25/basic/transports#streamable-http + +# Overview + +The client-side streamable transport allows an MCP client to communicate with a +server over HTTP, sending messages via POST and receiving responses via either +JSON or server-sent events (SSE). The implementation consists of two main +components: + + ┌─────────────────────────────────────────────────────────────────┐ + │ [StreamableClientTransport] │ + │ Transport configuration; creates connections via Connect() │ + └─────────────────────────────────────────────────────────────────┘ + │ + ▼ + ┌─────────────────────────────────────────────────────────────────┐ + │ [streamableClientConn] │ + │ Connection implementation; handles HTTP request/response │ + └─────────────────────────────────────────────────────────────────┘ + │ + ├──────────────────────────────────────┐ + ▼ ▼ + ┌─────────────────────────────────────────┐ ┌────────────────────────────────────┐ + │ POST request handlers │ │ Standalone SSE stream │ + │ (one per outgoing message/call) │ │ (server-initiated messages) │ + └─────────────────────────────────────────┘ └────────────────────────────────────┘ + +# Sessions + +The client maintains a session with the server, identified by a session ID +(Mcp-Session-Id header): + + - Session ID is received from the server after initialization + - Client includes the session ID in all subsequent requests + - Session ends when the client calls Close() (sends DELETE) or server returns 404 + +[streamableClientConn] stores the session state: + - [streamableClientConn.sessionID]: Server-assigned session identifier + - [streamableClientConn.initializedResult]: Protocol version and server capabilities + +# Connection Lifecycle + +1. Connect: [StreamableClientTransport.Connect] creates a [streamableClientConn] + with a detached context for the connection's lifetime. The context is detached + to prevent the standalone SSE stream from being cancelled when the original + Connect context times out. + +2. Initialize: The MCP client sends initialize/initialized messages. Upon + receiving [InitializeResult], the connection: + - Stores the negotiated protocol version for the Mcp-Protocol-Version header + - Captures the session ID from the Mcp-Session-Id response header + - Starts the standalone SSE stream via [streamableClientConn.connectStandaloneSSE] + +3. Operation: Messages are sent via POST, responses received via JSON or SSE. + +4. Close: [streamableClientConn.Close] sends a DELETE request to terminate + the session (unless the session is already gone), then cancels the connection + context to clean up the standalone SSE stream. + +# Sending Messages (Write) + +[streamableClientConn.Write] sends all outgoing messages via HTTP POST: + + POST /endpoint + Content-Type: application/json + Accept: application/json, text/event-stream + Mcp-Protocol-Version: + Mcp-Session-Id: + + + +The server may respond with: + - 202 Accepted: Message received, no response body (notifications/responses) + - 200 OK with application/json: Single JSON-RPC response + - 200 OK with text/event-stream: SSE stream of responses + +# Receiving Messages (Read) + +[streamableClientConn.Read] returns messages from the [streamableClientConn.incoming] +channel, which is populated by multiple concurrent goroutines: + +1. POST response handlers ([streamableClientConn.handleJSON] and + [streamableClientConn.handleSSE]): Process responses from POST requests + +2. Standalone SSE stream: Receives server-initiated requests and notifications + +The client handles both response formats: + - JSON: [streamableClientConn.handleJSON] reads body, decodes message + - SSE: [streamableClientConn.handleSSE] scans events, decodes each message + +# Standalone SSE Stream + +After initialization, [streamableClientConn.sessionUpdated] triggers +[streamableClientConn.connectStandaloneSSE] to open a GET request for +server-initiated messages: + + GET /endpoint + Accept: text/event-stream + Mcp-Session-Id: + +Stream behavior: + - Optional: Server may return 405 Method Not Allowed (spec-compliant) or + other 4xx errors (tolerated in non-strict mode for compatibility) + - Persistent: Runs for the connection lifetime in a background goroutine + - Resumable: Uses Last-Event-ID header on reconnection if server provides event IDs + - Reconnects: Automatic reconnection with exponential backoff on interruption + +# Stream Resumption + +When an SSE stream (standalone or POST response) is interrupted, the client +attempts to reconnect using [streamableClientConn.connectSSE]: + +Event ID tracking: + - [streamableClientConn.processStream] tracks the last received event ID + - On reconnection, the Last-Event-ID header is set to resume from that point + - Server replays missed events if it has an [EventStore] configured + +See [calculateReconnectDelay] for the reconnect delay details. + +Server-initiated reconnection (SEP-1699) + - SSE retry field: Sets the delay for the next reconnect attempt + - If server doesn't provide event IDs, non-standalone streams don't reconnect + +# Response Formats + +The client must handle two response formats from POST requests: + +1. application/json: Single JSON-RPC response + - Body contains one JSON-RPC message + - Handled by [streamableClientConn.handleJSON] + - Simpler but doesn't support streaming or server-initiated messages + +2. text/event-stream: SSE stream of messages + - Body contains SSE events with JSON-RPC messages + - Handled by [streamableClientConn.handleSSE] + - Supports multiple messages and server-initiated communication + - Stream completes when the response to the originating call is received + +# HTTP Methods + + - POST: Send JSON-RPC messages (requests, responses, notifications) + - Used by [streamableClientConn.Write] + - Response may be JSON or SSE + + - GET: Open or resume SSE stream for server-initiated messages + - Used by [streamableClientConn.connectSSE] + - Always expects text/event-stream response (or 405) + + - DELETE: Terminate the session + - Used by [streamableClientConn.Close] + - Skipped if session is already known to be gone ([ErrSessionMissing]) + +# Error Handling + +Errors are categorized and handled differently: + +1. Transient (recoverable via reconnection): + - Network interruption during SSE streaming + - Connection reset or timeout + - Triggers reconnection in [streamableClientConn.handleSSE] + +2. Terminal (breaks the connection): + - 404 Not Found: Session terminated by server ([ErrSessionMissing]) + - Message decode errors: Protocol violation + - Context cancellation: Client closed connection + - Mismatched session IDs: Protocol error + - See issue #683: our terminal errors are too strict. + +Terminal errors are stored via [streamableClientConn.fail] and returned by +subsequent [streamableClientConn.Read] calls. The [streamableClientConn.failed] +channel signals that the connection is broken. + +Special case: [ErrSessionMissing] indicates the server has terminated the session, +so [streamableClientConn.Close] skips the DELETE request. + +# Protocol Version Header + +After initialization, all requests include: + + Mcp-Protocol-Version: + +This header (set by [streamableClientConn.setMCPHeaders]): + - Allows the server to handle requests per the negotiated protocol + - Is omitted before initialization completes + - Uses the version from [streamableClientConn.initializedResult] + +# Key Implementation Details + +[StreamableClientTransport] configuration: + - [StreamableClientTransport.Endpoint]: URL of the MCP server + - [StreamableClientTransport.HTTPClient]: Custom HTTP client (optional) + - [StreamableClientTransport.MaxRetries]: Reconnection attempts (default 5) + +[streamableClientConn] handles the [Connection] interface: + - [streamableClientConn.Read]: Returns messages from incoming channel + - [streamableClientConn.Write]: Sends messages via POST, starts response handlers + - [streamableClientConn.Close]: Sends DELETE, cancels context, closes done channel + +State management: + - [streamableClientConn.incoming]: Buffered channel for received messages + - [streamableClientConn.sessionID]: Server-assigned session identifier + - [streamableClientConn.initializedResult]: Cached for protocol version header + - [streamableClientConn.failed]: Channel closed on terminal error + - [streamableClientConn.done]: Channel closed on graceful shutdown + - [streamableClientConn.ctx]: Detached context for connection lifetime + - [streamableClientConn.cancel]: Cancels ctx to terminate SSE streams + +Context handling: + - Connection context is detached from [StreamableClientTransport.Connect] context + using [xcontext.Detach] to preserve context values (for auth middleware) while + preventing premature cancellation of the standalone SSE stream + - Individual POST requests use caller-provided contexts for cancellation +*/ diff --git a/mcp/dsx-exchange-mcp/vendor/github.com/modelcontextprotocol/go-sdk/mcp/streamable_server.go b/mcp/dsx-exchange-mcp/vendor/github.com/modelcontextprotocol/go-sdk/mcp/streamable_server.go new file mode 100644 index 0000000..8a573e5 --- /dev/null +++ b/mcp/dsx-exchange-mcp/vendor/github.com/modelcontextprotocol/go-sdk/mcp/streamable_server.go @@ -0,0 +1,160 @@ +// Copyright 2025 The Go MCP SDK Authors. All rights reserved. +// Use of this source code is governed by an MIT-style +// license that can be found in the LICENSE file. + +// TODO: move server-side streamable HTTP logic from streamable.go to this file. + +package mcp + +/* +Streamable HTTP Server Design + +This document describes the server-side implementation of the MCP streamable +HTTP transport, as defined by the MCP spec: +https://modelcontextprotocol.io/specification/2025-11-25/basic/transports#streamable-http + +# Overview + +The streamable HTTP transport enables MCP communication over HTTP, with +server-sent events (SSE) for server-to-client messages. The implementation +consists of several layered components: + + ┌─────────────────────────────────────────────────────────────────┐ + │ [StreamableHTTPHandler] │ + │ http.Handler that manages sessions and routes HTTP requests │ + └─────────────────────────────────────────────────────────────────┘ + │ + ▼ + ┌─────────────────────────────────────────────────────────────────┐ + │ [StreamableServerTransport] │ + │ transport implementation, one per session; exposes ServeHTTP │ + └─────────────────────────────────────────────────────────────────┘ + │ + ▼ + ┌─────────────────────────────────────────────────────────────────┐ + │ [streamableServerConn] │ + │ Connection implementation, handles message routing │ + └─────────────────────────────────────────────────────────────────┘ + │ + ▼ + ┌─────────────────────────────────────────────────────────────────┐ + │ [stream] │ + │ Logical message channel within a session, may be resumed │ + └─────────────────────────────────────────────────────────────────┘ + +# Sessions + +As with other transports, a session represents a logical MCP connection between +a client and server. In the streamable transport, sessions are identified by a +unique session ID (Mcp-Session-Id header) and persist across multiple HTTP +requests. + +[StreamableHTTPHandler] maintains a map of active sessions ([sessionInfo]), +each containing: + - The [ServerSession] (MCP-level session state) + - The [StreamableServerTransport] (for message I/O) + - Optional timeout management for idle session cleanup + +Sessions are created on the first POST request (typically containing the +initialize request) and destroyed either by: + - Client sending a DELETE request + - Session timeout due to inactivity + - Server explicitly closing the session + +# Streams + +Within a session, there can be multiple concurrent "streams" - logical channels +for message delivery. This is distinct from HTTP streams; a single [stream] may +span multiple HTTP request/response cycles (via resumption). + +There are two types of streams: + +1. Optional standalone SSE stream (id = ""): + - Created when client sends a GET request to the endpoint + - Used for server-initiated messages (requests/notifications to client) + - Persists for the lifetime of the session + - Only one standalone stream per session + +2. Request streams (id = random string): + - Created for each POST request containing JSON-RPC calls + - Used to route responses back to the originating HTTP request + - Completed when all responses have been sent + - Can be resumed via GET with Last-Event-ID if interrupted + +# Message Routing + +When the server writes a message, it must be routed to the correct [stream]: + + - Responses: Routed to the stream that originated the request + - Requests/Notifications made during request handling: Routed to the same + stream as the triggering request (via context) + - Requests/Notifications made outside request handling: Routed to the + standalone SSE stream + +This routing is implemented using: + - [streamableServerConn.requestStreams] maps request IDs to stream IDs + - [idContextKey] is used to store the originating request ID in Context + - [streamableServerConn.streams] maps stream IDs to [stream] objects + +# Stream Resumption + +If an HTTP connection is interrupted (network issues, etc.), clients can +resume a stream by sending a GET request with the Last-Event-ID header. +This requires an [EventStore] to be configured on the server. + + - [EventStore.Open] is called when a new stream is created + - [EventStore.Append] is called for each message written to the stream + - [EventStore.After] is called to replay messages after a given index + - [EventStore.SessionClosed] is called when the session ends + +Event IDs are formatted as "_" to identify both the +stream and position within that stream (see [formatEventID] and [parseEventID]). + +# Stateless Mode + +For simpler deployments, the handler supports "stateless" mode +([StreamableHTTPOptions.Stateless]) where: + - No session ID validation is performed + - Each request creates a temporary session that's closed after the request + - Server-to-client requests are not supported (no way to receive response) + +This mode is useful for simple tool servers that don't need bidirectional +communication. + +# Response Formats + +The server can respond to POST requests in two formats: + +1. text/event-stream (default): Messages sent as SSE events, supports + streaming multiple messages and server-initiated communication during + request handling. + +2. application/json ([StreamableHTTPOptions.JSONResponse]): Single JSON + response, simpler but doesn't support streaming. Server-initiated messages + during request handling go to the standalone SSE stream instead. + +# HTTP Methods + + - POST: Send JSON-RPC messages (requests, responses, notifications) + - GET: Open standalone SSE stream or resume an interrupted stream + - DELETE: Terminate the session + +# Key Implementation Details + +The [stream] struct manages delivery of messages to HTTP responses. + +Fields: + - [stream.w] is the ResponseWriter for the current HTTP response (non-nil indicates claimed) + - [stream.done] is closed to release the hanging HTTP request + - [stream.requests] tracks pending request IDs (stream completes when empty) + +Methods: + - [stream.deliverLocked] delivers a message to the stream + - [stream.close] sends a close event and releases the stream + - [stream.release] releases the stream from the HTTP request, allowing resumption + +[streamableServerConn] handles the [Connection] interface: + - [streamableServerConn.Read] receives messages from the incoming channel (fed by POST handlers) + - [streamableServerConn.Write] routes messages to appropriate streams + - [streamableServerConn.Close] terminates the session and notifies the [EventStore] +*/ diff --git a/mcp/dsx-exchange-mcp/vendor/github.com/modelcontextprotocol/go-sdk/mcp/tool.go b/mcp/dsx-exchange-mcp/vendor/github.com/modelcontextprotocol/go-sdk/mcp/tool.go new file mode 100644 index 0000000..3ecb59d --- /dev/null +++ b/mcp/dsx-exchange-mcp/vendor/github.com/modelcontextprotocol/go-sdk/mcp/tool.go @@ -0,0 +1,140 @@ +// Copyright 2025 The Go MCP SDK Authors. All rights reserved. +// Use of this source code is governed by an MIT-style +// license that can be found in the LICENSE file. + +package mcp + +import ( + "context" + "encoding/json" + "fmt" + "strings" + + "github.com/google/jsonschema-go/jsonschema" + internaljson "github.com/modelcontextprotocol/go-sdk/internal/json" +) + +// A ToolHandler handles a call to tools/call. +// +// This is a low-level API, for use with [Server.AddTool]. It does not do any +// pre- or post-processing of the request or result: the params contain raw +// arguments, no input validation is performed, and the result is returned to +// the user as-is, without any validation of the output. +// +// Most users will write a [ToolHandlerFor] and install it with the generic +// [AddTool] function. +// +// If ToolHandler returns an error, it is treated as a protocol error. By +// contrast, [ToolHandlerFor] automatically populates [CallToolResult.IsError] +// and [CallToolResult.Content] accordingly. +type ToolHandler func(context.Context, *CallToolRequest) (*CallToolResult, error) + +// A ToolHandlerFor handles a call to tools/call with typed arguments and results. +// +// Use [AddTool] to add a ToolHandlerFor to a server. +// +// Unlike [ToolHandler], [ToolHandlerFor] provides significant functionality +// out of the box, and enforces that the tool conforms to the MCP spec: +// - The In type provides a default input schema for the tool, though it may +// be overridden in [AddTool]. +// - The input value is automatically unmarshaled from req.Params.Arguments. +// - The input value is automatically validated against its input schema. +// Invalid input is rejected before getting to the handler. +// - If the Out type is not the empty interface [any], it provides the +// default output schema for the tool (which again may be overridden in +// [AddTool]). +// - The Out value is used to populate result.StructuredOutput. +// - If [CallToolResult.Content] is unset, it is populated with the JSON +// content of the output. +// - An error result is treated as a tool error, rather than a protocol +// error, and is therefore packed into CallToolResult.Content, with +// [IsError] set. +// +// For these reasons, most users can ignore the [CallToolRequest] argument and +// [CallToolResult] return values entirely. In fact, it is permissible to +// return a nil CallToolResult, if you only care about returning a output value +// or error. The effective result will be populated as described above. +type ToolHandlerFor[In, Out any] func(_ context.Context, request *CallToolRequest, input In) (result *CallToolResult, output Out, _ error) + +// A serverTool is a tool definition that is bound to a tool handler. +type serverTool struct { + tool *Tool + handler ToolHandler +} + +// applySchema validates whether data is valid JSON according to the provided +// schema, after applying schema defaults. +// +// Returns the JSON value augmented with defaults. +func applySchema(data json.RawMessage, resolved *jsonschema.Resolved) (json.RawMessage, error) { + // TODO: use reflection to create the struct type to unmarshal into. + // Separate validation from assignment. + + // Use default JSON marshalling for validation. + // + // This avoids inconsistent representation due to custom marshallers, such as + // time.Time (issue #449). + // + // Additionally, unmarshalling into a map ensures that the resulting JSON is + // at least {}, even if data is empty. For example, arguments is technically + // an optional property of callToolParams, and we still want to apply the + // defaults in this case. + // + // TODO(rfindley): in which cases can resolved be nil? + if resolved != nil { + v := make(map[string]any) + if len(data) > 0 { + if err := internaljson.Unmarshal(data, &v); err != nil { + return nil, fmt.Errorf("unmarshaling arguments: %w", err) + } + } + if err := resolved.ApplyDefaults(&v); err != nil { + return nil, fmt.Errorf("applying schema defaults:\n%w", err) + } + if err := resolved.Validate(&v); err != nil { + return nil, err + } + // We must re-marshal with the default values applied. + var err error + data, err = json.Marshal(v) + if err != nil { + return nil, fmt.Errorf("marshalling with defaults: %v", err) + } + } + return data, nil +} + +// validateToolName checks whether name is a valid tool name, reporting a +// non-nil error if not. +func validateToolName(name string) error { + if name == "" { + return fmt.Errorf("tool name cannot be empty") + } + if len(name) > 128 { + return fmt.Errorf("tool name exceeds maximum length of 128 characters (current: %d)", len(name)) + } + // For consistency with other SDKs, report characters in the order the appear + // in the name. + var invalidChars []string + seen := make(map[rune]bool) + for _, r := range name { + if !validToolNameRune(r) { + if !seen[r] { + invalidChars = append(invalidChars, fmt.Sprintf("%q", string(r))) + seen[r] = true + } + } + } + if len(invalidChars) > 0 { + return fmt.Errorf("tool name contains invalid characters: %s", strings.Join(invalidChars, ", ")) + } + return nil +} + +// validToolNameRune reports whether r is valid within tool names. +func validToolNameRune(r rune) bool { + return (r >= 'a' && r <= 'z') || + (r >= 'A' && r <= 'Z') || + (r >= '0' && r <= '9') || + r == '_' || r == '-' || r == '.' +} diff --git a/mcp/dsx-exchange-mcp/vendor/github.com/modelcontextprotocol/go-sdk/mcp/transport.go b/mcp/dsx-exchange-mcp/vendor/github.com/modelcontextprotocol/go-sdk/mcp/transport.go new file mode 100644 index 0000000..5f2a500 --- /dev/null +++ b/mcp/dsx-exchange-mcp/vendor/github.com/modelcontextprotocol/go-sdk/mcp/transport.go @@ -0,0 +1,660 @@ +// Copyright 2025 The Go MCP SDK Authors. All rights reserved. +// Use of this source code is governed by an MIT-style +// license that can be found in the LICENSE file. + +package mcp + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "io" + "log" + "net" + "os" + "sync" + + internaljson "github.com/modelcontextprotocol/go-sdk/internal/json" + "github.com/modelcontextprotocol/go-sdk/internal/jsonrpc2" + "github.com/modelcontextprotocol/go-sdk/internal/xcontext" + "github.com/modelcontextprotocol/go-sdk/jsonrpc" +) + +// ErrConnectionClosed is returned when sending a message to a connection that +// is closed or in the process of closing. +var ErrConnectionClosed = errors.New("connection closed") + +// ErrSessionMissing is returned when the session is known to not be present on +// the server. +var ErrSessionMissing = errors.New("session not found") + +// A Transport is used to create a bidirectional connection between MCP client +// and server. +// +// Transports should be used for at most one call to [Server.Connect] or +// [Client.Connect]. +type Transport interface { + // Connect returns the logical JSON-RPC connection.. + // + // It is called exactly once by [Server.Connect] or [Client.Connect]. + Connect(ctx context.Context) (Connection, error) +} + +// A Connection is a logical bidirectional JSON-RPC connection. +type Connection interface { + // Read reads the next message to process off the connection. + // + // Connections must allow Read to be called concurrently with Close. In + // particular, calling Close should unblock a Read waiting for input. + Read(context.Context) (jsonrpc.Message, error) + + // Write writes a new message to the connection. + // + // Write may be called concurrently, as calls or responses may occur + // concurrently in user code. + Write(context.Context, jsonrpc.Message) error + + // Close closes the connection. It is implicitly called whenever a Read or + // Write fails. + // + // Close may be called multiple times, potentially concurrently. + Close() error + + // TODO(#148): remove SessionID from this interface. + SessionID() string +} + +// A ClientConnection is a [Connection] that is specific to the MCP client. +// +// If client connections implement this interface, they may receive information +// about changes to the client session. +// +// TODO: should this interface be exported? +type clientConnection interface { + Connection + + // sessionUpdated is called whenever the client session state changes. + sessionUpdated(clientSessionState) +} + +// A serverConnection is a Connection that is specific to the MCP server. +// +// If server connections implement this interface, they receive information +// about changes to the server session. +// +// TODO: should this interface be exported? +type serverConnection interface { + Connection + sessionUpdated(ServerSessionState) +} + +// A StdioTransport is a [Transport] that communicates over stdin/stdout using +// newline-delimited JSON. +type StdioTransport struct{} + +// Connect implements the [Transport] interface. +func (*StdioTransport) Connect(context.Context) (Connection, error) { + return newIOConn(rwc{os.Stdin, nopCloserWriter{os.Stdout}}), nil +} + +// nopCloserWriter is an io.WriteCloser with a trivial Close method. +type nopCloserWriter struct { + io.Writer +} + +func (nopCloserWriter) Close() error { return nil } + +// An IOTransport is a [Transport] that communicates over separate +// io.ReadCloser and io.WriteCloser using newline-delimited JSON. +type IOTransport struct { + Reader io.ReadCloser + Writer io.WriteCloser +} + +// Connect implements the [Transport] interface. +func (t *IOTransport) Connect(context.Context) (Connection, error) { + return newIOConn(rwc{t.Reader, t.Writer}), nil +} + +// An InMemoryTransport is a [Transport] that communicates over an in-memory +// network connection, using newline-delimited JSON. +// +// InMemoryTransports should be constructed using [NewInMemoryTransports], +// which returns two transports connected to each other. +type InMemoryTransport struct { + rwc io.ReadWriteCloser +} + +// Connect implements the [Transport] interface. +func (t *InMemoryTransport) Connect(context.Context) (Connection, error) { + return newIOConn(t.rwc), nil +} + +// NewInMemoryTransports returns two [InMemoryTransport] objects that connect +// to each other. +// +// The resulting transports are symmetrical: use either to connect to a server, +// and then the other to connect to a client. Servers must be connected before +// clients, as the client initializes the MCP session during connection. +func NewInMemoryTransports() (*InMemoryTransport, *InMemoryTransport) { + c1, c2 := net.Pipe() + return &InMemoryTransport{c1}, &InMemoryTransport{c2} +} + +type binder[T handler, State any] interface { + // TODO(rfindley): the bind API has gotten too complicated. Simplify. + bind(Connection, *jsonrpc2.Connection, State, func()) T + disconnect(T) +} + +type handler interface { + handle(ctx context.Context, req *jsonrpc.Request) (any, error) +} + +func connect[H handler, State any](ctx context.Context, t Transport, b binder[H, State], s State, onClose func()) (H, error) { + var zero H + mcpConn, err := t.Connect(ctx) + if err != nil { + return zero, err + } + // If logging is configured, write message logs. + reader, writer := jsonrpc2.Reader(mcpConn), jsonrpc2.Writer(mcpConn) + var ( + h H + preempter canceller + ) + bind := func(conn *jsonrpc2.Connection) jsonrpc2.Handler { + h = b.bind(mcpConn, conn, s, onClose) + preempter.conn = conn + return jsonrpc2.HandlerFunc(h.handle) + } + _ = jsonrpc2.NewConnection(ctx, jsonrpc2.ConnectionConfig{ + Reader: reader, + Writer: writer, + Closer: mcpConn, + Bind: bind, + Preempter: &preempter, + OnDone: func() { + b.disconnect(h) + }, + OnInternalError: func(err error) { log.Printf("jsonrpc2 error: %v", err) }, + }) + assert(preempter.conn != nil, "unbound preempter") + return h, nil +} + +// A canceller is a jsonrpc2.Preempter that cancels in-flight requests on MCP +// cancelled notifications. +type canceller struct { + conn *jsonrpc2.Connection +} + +// Preempt implements [jsonrpc2.Preempter]. +func (c *canceller) Preempt(ctx context.Context, req *jsonrpc.Request) (result any, err error) { + if req.Method == notificationCancelled { + var params CancelledParams + if err := internaljson.Unmarshal(req.Params, ¶ms); err != nil { + return nil, err + } + id, err := jsonrpc2.MakeID(params.RequestID) + if err != nil { + return nil, err + } + go c.conn.Cancel(id) + } + return nil, jsonrpc2.ErrNotHandled +} + +// call executes and awaits a jsonrpc2 call on the given connection, +// translating errors into the mcp domain. +func call(ctx context.Context, conn *jsonrpc2.Connection, method string, params Params, result Result) error { + // The "%w"s in this function expose jsonrpc.Error as part of the API. + call := conn.Call(ctx, method, params) + err := call.Await(ctx, result) + switch { + case errors.Is(err, jsonrpc2.ErrClientClosing), errors.Is(err, jsonrpc2.ErrServerClosing): + return fmt.Errorf("%w: calling %q: %v", ErrConnectionClosed, method, err) + case ctx.Err() != nil: + // Notify the peer of cancellation. + err := conn.Notify(xcontext.Detach(ctx), notificationCancelled, &CancelledParams{ + Reason: ctx.Err().Error(), + RequestID: call.ID().Raw(), + }) + // By default, the jsonrpc2 library waits for graceful shutdown when the + // connection is closed, meaning it expects all outgoing and incoming + // requests to complete. However, for MCP this expectation is unrealistic, + // and can lead to hanging shutdown. For example, if a streamable client is + // killed, the server will not be able to detect this event, except via + // keepalive pings (if they are configured), and so outgoing calls may hang + // indefinitely. + // + // Therefore, we choose to eagerly retire calls, removing them from the + // outgoingCalls map, when the caller context is cancelled: if the caller + // will never receive the response, there's no need to track it. + conn.Retire(call, ctx.Err()) + return errors.Join(ctx.Err(), err) + case err != nil: + return fmt.Errorf("calling %q: %w", method, err) + } + return nil +} + +// A LoggingTransport is a [Transport] that delegates to another transport, +// writing RPC logs to an io.Writer. +type LoggingTransport struct { + Transport Transport + Writer io.Writer +} + +// Connect connects the underlying transport, returning a [Connection] that writes +// logs to the configured destination. +func (t *LoggingTransport) Connect(ctx context.Context) (Connection, error) { + delegate, err := t.Transport.Connect(ctx) + if err != nil { + return nil, err + } + return &loggingConn{delegate: delegate, w: t.Writer}, nil +} + +type loggingConn struct { + delegate Connection + + mu sync.Mutex + w io.Writer +} + +func (c *loggingConn) SessionID() string { return c.delegate.SessionID() } + +// Read is a stream middleware that logs incoming messages. +func (s *loggingConn) Read(ctx context.Context) (jsonrpc.Message, error) { + msg, err := s.delegate.Read(ctx) + + if err != nil { + s.mu.Lock() + fmt.Fprintf(s.w, "read error: %v\n", err) + s.mu.Unlock() + } else { + data, err := jsonrpc2.EncodeMessage(msg) + s.mu.Lock() + if err != nil { + fmt.Fprintf(s.w, "LoggingTransport: failed to marshal: %v", err) + } + fmt.Fprintf(s.w, "read: %s\n", string(data)) + s.mu.Unlock() + } + + return msg, err +} + +// Write is a stream middleware that logs outgoing messages. +func (s *loggingConn) Write(ctx context.Context, msg jsonrpc.Message) error { + err := s.delegate.Write(ctx, msg) + if err != nil { + s.mu.Lock() + fmt.Fprintf(s.w, "write error: %v\n", err) + s.mu.Unlock() + } else { + data, err := jsonrpc2.EncodeMessage(msg) + s.mu.Lock() + if err != nil { + fmt.Fprintf(s.w, "LoggingTransport: failed to marshal: %v", err) + } + fmt.Fprintf(s.w, "write: %s\n", string(data)) + s.mu.Unlock() + } + return err +} + +func (s *loggingConn) Close() error { + return s.delegate.Close() +} + +// A rwc binds an io.ReadCloser and io.WriteCloser together to create an +// io.ReadWriteCloser. +type rwc struct { + rc io.ReadCloser + wc io.WriteCloser +} + +func (r rwc) Read(p []byte) (n int, err error) { + return r.rc.Read(p) +} + +func (r rwc) Write(p []byte) (n int, err error) { + return r.wc.Write(p) +} + +func (r rwc) Close() error { + rcErr := r.rc.Close() + + var wcErr error + if r.wc != nil { // we only allow a nil writer in unit tests + wcErr = r.wc.Close() + } + + return errors.Join(rcErr, wcErr) +} + +// An ioConn is a transport that delimits messages with newlines across +// a bidirectional stream, and supports jsonrpc.2 message batching. +// +// See https://github.com/ndjson/ndjson-spec for discussion of newline +// delimited JSON. +// +// See [msgBatch] for more discussion of message batching. +type ioConn struct { + protocolVersion string // negotiated version, set during session initialization. + + writeMu sync.Mutex // guards Write, which must be concurrency safe. + rwc io.ReadWriteCloser // the underlying stream + + // incoming receives messages from the read loop started in [newIOConn]. + incoming <-chan msgOrErr + + // If outgoiBatch has a positive capacity, it will be used to batch requests + // and notifications before sending. + outgoingBatch []jsonrpc.Message + + // Unread messages in the last batch. Since reads are serialized, there is no + // need to guard here. + queue []jsonrpc.Message + + // batches correlate incoming requests to the batch in which they arrived. + // Since writes may be concurrent to reads, we need to guard this with a mutex. + batchMu sync.Mutex + batches map[jsonrpc2.ID]*msgBatch // lazily allocated + + closeOnce sync.Once + closed chan struct{} + closeErr error +} + +type msgOrErr struct { + msg json.RawMessage + err error +} + +func newIOConn(rwc io.ReadWriteCloser) *ioConn { + var ( + incoming = make(chan msgOrErr) + closed = make(chan struct{}) + ) + // Start a goroutine for reads, so that we can select on the incoming channel + // in [ioConn.Read] and unblock the read as soon as Close is called (see #224). + // + // This leaks a goroutine if rwc.Read does not unblock after it is closed, + // but that is unavoidable since AFAIK there is no (easy and portable) way to + // guarantee that reads of stdin are unblocked when closed. + go func() { + dec := json.NewDecoder(rwc) + for { + var raw json.RawMessage + err := dec.Decode(&raw) + // If decoding was successful, check for trailing data at the end of the stream. + if err == nil { + // Read the next byte to check if there is trailing data. + var tr [1]byte + if n, readErr := dec.Buffered().Read(tr[:]); n > 0 { + // If read byte is not a newline, it is an error. + // Support both Unix (\n) and Windows (\r\n) line endings. + if tr[0] != '\n' && tr[0] != '\r' { + err = fmt.Errorf("invalid trailing data at the end of stream") + } + } else if readErr != nil && readErr != io.EOF { + err = readErr + } + } + select { + case incoming <- msgOrErr{msg: raw, err: err}: + case <-closed: + return + } + if err != nil { + return + } + } + }() + return &ioConn{ + rwc: rwc, + incoming: incoming, + closed: closed, + } +} + +func (c *ioConn) SessionID() string { return "" } + +func (c *ioConn) sessionUpdated(state ServerSessionState) { + protocolVersion := "" + if state.InitializeParams != nil { + protocolVersion = state.InitializeParams.ProtocolVersion + } + if protocolVersion == "" { + protocolVersion = protocolVersion20250326 + } + c.protocolVersion = negotiatedVersion(protocolVersion) +} + +// addBatch records a msgBatch for an incoming batch payload. +// It returns an error if batch is malformed, containing previously seen IDs. +// +// See [msgBatch] for more. +func (t *ioConn) addBatch(batch *msgBatch) error { + t.batchMu.Lock() + defer t.batchMu.Unlock() + for id := range batch.unresolved { + if _, ok := t.batches[id]; ok { + return fmt.Errorf("%w: batch contains previously seen request %v", jsonrpc2.ErrInvalidRequest, id.Raw()) + } + } + for id := range batch.unresolved { + if t.batches == nil { + t.batches = make(map[jsonrpc2.ID]*msgBatch) + } + t.batches[id] = batch + } + return nil +} + +// updateBatch records a response in the message batch tracking the +// corresponding incoming call, if any. +// +// The second result reports whether resp was part of a batch. If this is true, +// the first result is nil if the batch is still incomplete, or the full set of +// batch responses if resp completed the batch. +func (t *ioConn) updateBatch(resp *jsonrpc.Response) ([]*jsonrpc.Response, bool) { + t.batchMu.Lock() + defer t.batchMu.Unlock() + + if batch, ok := t.batches[resp.ID]; ok { + idx, ok := batch.unresolved[resp.ID] + if !ok { + panic("internal error: inconsistent batches") + } + batch.responses[idx] = resp + delete(batch.unresolved, resp.ID) + delete(t.batches, resp.ID) + if len(batch.unresolved) == 0 { + return batch.responses, true + } + return nil, true + } + return nil, false +} + +// A msgBatch records information about an incoming batch of jsonrpc.2 calls. +// +// The jsonrpc.2 spec (https://www.jsonrpc.org/specification#batch) says: +// +// "The Server should respond with an Array containing the corresponding +// Response objects, after all of the batch Request objects have been +// processed. A Response object SHOULD exist for each Request object, except +// that there SHOULD NOT be any Response objects for notifications. The Server +// MAY process a batch rpc call as a set of concurrent tasks, processing them +// in any order and with any width of parallelism." +// +// Therefore, a msgBatch keeps track of outstanding calls and their responses. +// When there are no unresolved calls, the response payload is sent. +type msgBatch struct { + unresolved map[jsonrpc2.ID]int + responses []*jsonrpc.Response +} + +func (t *ioConn) Read(ctx context.Context) (jsonrpc.Message, error) { + // As a matter of principle, enforce that reads on a closed context return an + // error. + select { + case <-ctx.Done(): + return nil, ctx.Err() + default: + } + if len(t.queue) > 0 { + next := t.queue[0] + t.queue = t.queue[1:] + return next, nil + } + + var raw json.RawMessage + select { + case <-ctx.Done(): + return nil, ctx.Err() + + case v := <-t.incoming: + if v.err != nil { + return nil, v.err + } + raw = v.msg + + case <-t.closed: + return nil, io.EOF + } + + msgs, batch, err := readBatch(raw) + if err != nil { + return nil, err + } + if batch && t.protocolVersion >= protocolVersion20250618 { + return nil, fmt.Errorf("JSON-RPC batching is not supported in %s and later (request version: %s)", protocolVersion20250618, t.protocolVersion) + } + + t.queue = msgs[1:] + + if batch { + var respBatch *msgBatch // track incoming requests in the batch + for _, msg := range msgs { + if req, ok := msg.(*jsonrpc.Request); ok { + if respBatch == nil { + respBatch = &msgBatch{ + unresolved: make(map[jsonrpc2.ID]int), + } + } + if _, ok := respBatch.unresolved[req.ID]; ok { + return nil, fmt.Errorf("duplicate message ID %q", req.ID) + } + respBatch.unresolved[req.ID] = len(respBatch.responses) + respBatch.responses = append(respBatch.responses, nil) + } + } + if respBatch != nil { + // The batch contains one or more incoming requests to track. + if err := t.addBatch(respBatch); err != nil { + return nil, err + } + } + } + return msgs[0], err +} + +// readBatch reads batch data, which may be either a single JSON-RPC message, +// or an array of JSON-RPC messages. +func readBatch(data []byte) (msgs []jsonrpc.Message, isBatch bool, _ error) { + // Try to read an array of messages first. + var rawBatch []json.RawMessage + if err := internaljson.Unmarshal(data, &rawBatch); err == nil { + if len(rawBatch) == 0 { + return nil, true, fmt.Errorf("empty batch") + } + for _, raw := range rawBatch { + msg, err := jsonrpc2.DecodeMessage(raw) + if err != nil { + return nil, true, err + } + msgs = append(msgs, msg) + } + return msgs, true, nil + } + // Try again with a single message. + msg, err := jsonrpc2.DecodeMessage(data) + return []jsonrpc.Message{msg}, false, err +} + +func (t *ioConn) Write(ctx context.Context, msg jsonrpc.Message) error { + // As in [ioConn.Read], enforce that Writes on a closed context are an error. + select { + case <-ctx.Done(): + return ctx.Err() + default: + } + + t.writeMu.Lock() + defer t.writeMu.Unlock() + + // Batching support: if msg is a Response, it may have completed a batch, so + // check that first. Otherwise, it is a request or notification, and we may + // want to collect it into a batch before sending, if we're configured to use + // outgoing batches. + if resp, ok := msg.(*jsonrpc.Response); ok { + if batch, ok := t.updateBatch(resp); ok { + if len(batch) > 0 { + data, err := marshalMessages(batch) + if err != nil { + return err + } + data = append(data, '\n') + _, err = t.rwc.Write(data) + return err + } + return nil + } + } else if len(t.outgoingBatch) < cap(t.outgoingBatch) { + t.outgoingBatch = append(t.outgoingBatch, msg) + if len(t.outgoingBatch) == cap(t.outgoingBatch) { + data, err := marshalMessages(t.outgoingBatch) + t.outgoingBatch = t.outgoingBatch[:0] + if err != nil { + return err + } + data = append(data, '\n') + _, err = t.rwc.Write(data) + return err + } + return nil + } + data, err := jsonrpc2.EncodeMessage(msg) + if err != nil { + return fmt.Errorf("marshaling message: %v", err) + } + data = append(data, '\n') // newline delimited + _, err = t.rwc.Write(data) + return err +} + +func (t *ioConn) Close() error { + t.closeOnce.Do(func() { + t.closeErr = t.rwc.Close() + close(t.closed) + }) + return t.closeErr +} + +func marshalMessages[T jsonrpc.Message](msgs []T) ([]byte, error) { + var rawMsgs []json.RawMessage + for _, msg := range msgs { + raw, err := jsonrpc2.EncodeMessage(msg) + if err != nil { + return nil, fmt.Errorf("encoding batch message: %w", err) + } + rawMsgs = append(rawMsgs, raw) + } + return json.Marshal(rawMsgs) +} diff --git a/mcp/dsx-exchange-mcp/vendor/github.com/modelcontextprotocol/go-sdk/mcp/util.go b/mcp/dsx-exchange-mcp/vendor/github.com/modelcontextprotocol/go-sdk/mcp/util.go new file mode 100644 index 0000000..8ffaa74 --- /dev/null +++ b/mcp/dsx-exchange-mcp/vendor/github.com/modelcontextprotocol/go-sdk/mcp/util.go @@ -0,0 +1,30 @@ +// Copyright 2025 The Go MCP SDK Authors. All rights reserved. +// Use of this source code is governed by an MIT-style +// license that can be found in the LICENSE file. + +package mcp + +import ( + "encoding/json" + + internaljson "github.com/modelcontextprotocol/go-sdk/internal/json" +) + +func assert(cond bool, msg string) { + if !cond { + panic(msg) + } +} + +// remarshal marshals from to JSON, and then unmarshals into to, which must be +// a pointer type. +func remarshal(from, to any) error { + data, err := json.Marshal(from) + if err != nil { + return err + } + if err := internaljson.Unmarshal(data, to); err != nil { + return err + } + return nil +} diff --git a/mcp/dsx-exchange-mcp/vendor/github.com/modelcontextprotocol/go-sdk/oauthex/auth_meta.go b/mcp/dsx-exchange-mcp/vendor/github.com/modelcontextprotocol/go-sdk/oauthex/auth_meta.go new file mode 100644 index 0000000..b05d80b --- /dev/null +++ b/mcp/dsx-exchange-mcp/vendor/github.com/modelcontextprotocol/go-sdk/oauthex/auth_meta.go @@ -0,0 +1,198 @@ +// Copyright 2025 The Go MCP SDK Authors. All rights reserved. +// Use of this source code is governed by an MIT-style +// license that can be found in the LICENSE file. + +// This file implements Authorization Server Metadata. +// See https://www.rfc-editor.org/rfc/rfc8414.html. + +//go:build mcp_go_client_oauth + +package oauthex + +import ( + "context" + "errors" + "fmt" + "net/http" + "net/url" + + "github.com/modelcontextprotocol/go-sdk/internal/util" +) + +// AuthServerMeta represents the metadata for an OAuth 2.0 authorization server, +// as defined in [RFC 8414]. +// +// Not supported: +// - signed metadata +// +// Note: URL fields in this struct are validated by validateAuthServerMetaURLs to +// prevent XSS attacks. If you add a new URL field, you must also add it to that +// function. +// +// [RFC 8414]: https://tools.ietf.org/html/rfc8414) +type AuthServerMeta struct { + // Issuer is the REQUIRED URL identifying the authorization server. + Issuer string `json:"issuer"` + + // AuthorizationEndpoint is the REQUIRED URL of the server's OAuth 2.0 authorization endpoint. + AuthorizationEndpoint string `json:"authorization_endpoint"` + + // TokenEndpoint is the REQUIRED URL of the server's OAuth 2.0 token endpoint. + TokenEndpoint string `json:"token_endpoint"` + + // JWKSURI is the REQUIRED URL of the server's JSON Web Key Set [JWK] document. + JWKSURI string `json:"jwks_uri"` + + // RegistrationEndpoint is the RECOMMENDED URL of the server's OAuth 2.0 Dynamic Client Registration endpoint. + RegistrationEndpoint string `json:"registration_endpoint,omitempty"` + + // ScopesSupported is a RECOMMENDED JSON array of strings containing a list of the OAuth 2.0 + // "scope" values that this server supports. + ScopesSupported []string `json:"scopes_supported,omitempty"` + + // ResponseTypesSupported is a REQUIRED JSON array of strings containing a list of the OAuth 2.0 + // "response_type" values that this server supports. + ResponseTypesSupported []string `json:"response_types_supported"` + + // ResponseModesSupported is a RECOMMENDED JSON array of strings containing a list of the OAuth 2.0 + // "response_mode" values that this server supports. + ResponseModesSupported []string `json:"response_modes_supported,omitempty"` + + // GrantTypesSupported is a RECOMMENDED JSON array of strings containing a list of the OAuth 2.0 + // grant type values that this server supports. + GrantTypesSupported []string `json:"grant_types_supported,omitempty"` + + // TokenEndpointAuthMethodsSupported is a RECOMMENDED JSON array of strings containing a list of + // client authentication methods supported by this token endpoint. + TokenEndpointAuthMethodsSupported []string `json:"token_endpoint_auth_methods_supported,omitempty"` + + // TokenEndpointAuthSigningAlgValuesSupported is a RECOMMENDED JSON array of strings containing + // a list of the JWS signing algorithms ("alg" values) supported by the token endpoint for + // the signature on the JWT used to authenticate the client. + TokenEndpointAuthSigningAlgValuesSupported []string `json:"token_endpoint_auth_signing_alg_values_supported,omitempty"` + + // ServiceDocumentation is a RECOMMENDED URL of a page containing human-readable documentation + // for the service. + ServiceDocumentation string `json:"service_documentation,omitempty"` + + // UILocalesSupported is a RECOMMENDED JSON array of strings representing supported + // BCP47 [RFC5646] language tag values for display in the user interface. + UILocalesSupported []string `json:"ui_locales_supported,omitempty"` + + // OpPolicyURI is a RECOMMENDED URL that the server provides to the person registering + // the client to read about the server's operator policies. + OpPolicyURI string `json:"op_policy_uri,omitempty"` + + // OpTOSURI is a RECOMMENDED URL that the server provides to the person registering the + // client to read about the server's terms of service. + OpTOSURI string `json:"op_tos_uri,omitempty"` + + // RevocationEndpoint is a RECOMMENDED URL of the server's OAuth 2.0 revocation endpoint. + RevocationEndpoint string `json:"revocation_endpoint,omitempty"` + + // RevocationEndpointAuthMethodsSupported is a RECOMMENDED JSON array of strings containing + // a list of client authentication methods supported by this revocation endpoint. + RevocationEndpointAuthMethodsSupported []string `json:"revocation_endpoint_auth_methods_supported,omitempty"` + + // RevocationEndpointAuthSigningAlgValuesSupported is a RECOMMENDED JSON array of strings + // containing a list of the JWS signing algorithms ("alg" values) supported by the revocation + // endpoint for the signature on the JWT used to authenticate the client. + RevocationEndpointAuthSigningAlgValuesSupported []string `json:"revocation_endpoint_auth_signing_alg_values_supported,omitempty"` + + // IntrospectionEndpoint is a RECOMMENDED URL of the server's OAuth 2.0 introspection endpoint. + IntrospectionEndpoint string `json:"introspection_endpoint,omitempty"` + + // IntrospectionEndpointAuthMethodsSupported is a RECOMMENDED JSON array of strings containing + // a list of client authentication methods supported by this introspection endpoint. + IntrospectionEndpointAuthMethodsSupported []string `json:"introspection_endpoint_auth_methods_supported,omitempty"` + + // IntrospectionEndpointAuthSigningAlgValuesSupported is a RECOMMENDED JSON array of strings + // containing a list of the JWS signing algorithms ("alg" values) supported by the introspection + // endpoint for the signature on the JWT used to authenticate the client. + IntrospectionEndpointAuthSigningAlgValuesSupported []string `json:"introspection_endpoint_auth_signing_alg_values_supported,omitempty"` + + // CodeChallengeMethodsSupported is a RECOMMENDED JSON array of strings containing a list of + // PKCE code challenge methods supported by this authorization server. + CodeChallengeMethodsSupported []string `json:"code_challenge_methods_supported,omitempty"` + + // ClientIDMetadataDocumentSupported is a boolean indicating whether the authorization server + // supports client ID metadata documents. + ClientIDMetadataDocumentSupported bool `json:"client_id_metadata_document_supported,omitempty"` +} + +// GetAuthServerMeta issues a GET request to retrieve authorization server metadata +// from an OAuth authorization server with the given metadataURL. +// +// It follows [RFC 8414]: +// - The metadataURL must use HTTPS or be a local address. +// - The Issuer field is checked against metadataURL.Issuer. +// +// It also verifies that the authorization server supports PKCE and that the URLs +// in the metadata don't use dangerous schemes. +// +// It returns an error if the request fails with a non-4xx status code or the fetched +// metadata doesn't pass security validations. +// It returns nil if the request fails with a 4xx status code. +// +// [RFC 8414]: https://tools.ietf.org/html/rfc8414 +func GetAuthServerMeta(ctx context.Context, metadataURL, issuer string, c *http.Client) (*AuthServerMeta, error) { + u, err := url.Parse(metadataURL) + if err != nil { + return nil, err + } + // Only allow HTTP for local addresses (testing or development purposes). + if !util.IsLoopback(u.Host) && u.Scheme != "https" { + return nil, fmt.Errorf("metadataURL %q does not use HTTPS", metadataURL) + } + asm, err := getJSON[AuthServerMeta](ctx, c, metadataURL, 1<<20) + if err != nil { + var httpErr *httpStatusError + if errors.As(err, &httpErr) { + if 400 <= httpErr.StatusCode && httpErr.StatusCode < 500 { + return nil, nil + } + } + return nil, fmt.Errorf("%v", err) // Do not expose error types. + } + if asm.Issuer != issuer { + // Validate the Issuer field (see RFC 8414, section 3.3). + return nil, fmt.Errorf("metadata issuer %q does not match issuer URL %q", asm.Issuer, issuer) + } + + if len(asm.CodeChallengeMethodsSupported) == 0 { + return nil, fmt.Errorf("authorization server at %s does not implement PKCE", issuer) + } + + // Validate endpoint URLs to prevent XSS attacks (see #526). + if err := validateAuthServerMetaURLs(asm); err != nil { + return nil, err + } + + return asm, nil +} + +// validateAuthServerMetaURLs validates all URL fields in AuthServerMeta +// to ensure they don't use dangerous schemes that could enable XSS attacks. +func validateAuthServerMetaURLs(asm *AuthServerMeta) error { + urls := []struct { + name string + value string + }{ + {"authorization_endpoint", asm.AuthorizationEndpoint}, + {"token_endpoint", asm.TokenEndpoint}, + {"jwks_uri", asm.JWKSURI}, + {"registration_endpoint", asm.RegistrationEndpoint}, + {"service_documentation", asm.ServiceDocumentation}, + {"op_policy_uri", asm.OpPolicyURI}, + {"op_tos_uri", asm.OpTOSURI}, + {"revocation_endpoint", asm.RevocationEndpoint}, + {"introspection_endpoint", asm.IntrospectionEndpoint}, + } + + for _, u := range urls { + if err := checkURLScheme(u.value); err != nil { + return fmt.Errorf("%s: %w", u.name, err) + } + } + return nil +} diff --git a/mcp/dsx-exchange-mcp/vendor/github.com/modelcontextprotocol/go-sdk/oauthex/dcr.go b/mcp/dsx-exchange-mcp/vendor/github.com/modelcontextprotocol/go-sdk/oauthex/dcr.go new file mode 100644 index 0000000..6db3025 --- /dev/null +++ b/mcp/dsx-exchange-mcp/vendor/github.com/modelcontextprotocol/go-sdk/oauthex/dcr.go @@ -0,0 +1,263 @@ +// Copyright 2025 The Go MCP SDK Authors. All rights reserved. +// Use of this source code is governed by an MIT-style +// license that can be found in the LICENSE file. + +// This file implements Authorization Server Metadata. +// See https://www.rfc-editor.org/rfc/rfc8414.html. + +//go:build mcp_go_client_oauth + +package oauthex + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "time" + + internaljson "github.com/modelcontextprotocol/go-sdk/internal/json" +) + +// ClientRegistrationMetadata represents the client metadata fields for the DCR POST request (RFC 7591). +// +// Note: URL fields in this struct are validated by validateClientRegistrationURLs +// to prevent XSS attacks. If you add a new URL field, you must also add it to +// that function. +type ClientRegistrationMetadata struct { + // RedirectURIs is a REQUIRED JSON array of redirection URI strings for use in + // redirect-based flows (such as the authorization code grant). + RedirectURIs []string `json:"redirect_uris"` + + // TokenEndpointAuthMethod is an OPTIONAL string indicator of the requested + // authentication method for the token endpoint. + // If omitted, the default is "client_secret_basic". + TokenEndpointAuthMethod string `json:"token_endpoint_auth_method,omitempty"` + + // GrantTypes is an OPTIONAL JSON array of OAuth 2.0 grant type strings + // that the client will restrict itself to using. + // If omitted, the default is ["authorization_code"]. + GrantTypes []string `json:"grant_types,omitempty"` + + // ResponseTypes is an OPTIONAL JSON array of OAuth 2.0 response type strings + // that the client will restrict itself to using. + // If omitted, the default is ["code"]. + ResponseTypes []string `json:"response_types,omitempty"` + + // ClientName is a RECOMMENDED human-readable name of the client to be presented + // to the end-user. + ClientName string `json:"client_name,omitempty"` + + // ClientURI is a RECOMMENDED URL of a web page providing information about the client. + ClientURI string `json:"client_uri,omitempty"` + + // LogoURI is an OPTIONAL URL of a logo for the client, which may be displayed + // to the end-user. + LogoURI string `json:"logo_uri,omitempty"` + + // Scope is an OPTIONAL string containing a space-separated list of scope values + // that the client will restrict itself to using. + Scope string `json:"scope,omitempty"` + + // Contacts is an OPTIONAL JSON array of strings representing ways to contact + // people responsible for this client (e.g., email addresses). + Contacts []string `json:"contacts,omitempty"` + + // TOSURI is an OPTIONAL URL that the client provides to the end-user + // to read about the client's terms of service. + TOSURI string `json:"tos_uri,omitempty"` + + // PolicyURI is an OPTIONAL URL that the client provides to the end-user + // to read about the client's privacy policy. + PolicyURI string `json:"policy_uri,omitempty"` + + // JWKSURI is an OPTIONAL URL for the client's JSON Web Key Set [JWK] document. + // This is preferred over the 'jwks' parameter. + JWKSURI string `json:"jwks_uri,omitempty"` + + // JWKS is an OPTIONAL client's JSON Web Key Set [JWK] document, passed by value. + // This is an alternative to providing a JWKSURI. + JWKS string `json:"jwks,omitempty"` + + // SoftwareID is an OPTIONAL unique identifier string for the client software, + // constant across all instances and versions. + SoftwareID string `json:"software_id,omitempty"` + + // SoftwareVersion is an OPTIONAL version identifier string for the client software. + SoftwareVersion string `json:"software_version,omitempty"` + + // SoftwareStatement is an OPTIONAL JWT that asserts client metadata values. + // Values in the software statement take precedence over other metadata values. + SoftwareStatement string `json:"software_statement,omitempty"` +} + +// ClientRegistrationResponse represents the fields returned by the Authorization Server +// (RFC 7591, Section 3.2.1 and 3.2.2). +type ClientRegistrationResponse struct { + // ClientRegistrationMetadata contains all registered client metadata, returned by the + // server on success, potentially with modified or defaulted values. + ClientRegistrationMetadata + + // ClientID is the REQUIRED newly issued OAuth 2.0 client identifier. + ClientID string `json:"client_id"` + + // ClientSecret is an OPTIONAL client secret string. + ClientSecret string `json:"client_secret,omitempty"` + + // ClientIDIssuedAt is an OPTIONAL Unix timestamp when the ClientID was issued. + ClientIDIssuedAt time.Time `json:"client_id_issued_at,omitempty"` + + // ClientSecretExpiresAt is the REQUIRED (if client_secret is issued) Unix + // timestamp when the secret expires, or 0 if it never expires. + ClientSecretExpiresAt time.Time `json:"client_secret_expires_at,omitempty"` +} + +func (r *ClientRegistrationResponse) MarshalJSON() ([]byte, error) { + type alias ClientRegistrationResponse + var clientIDIssuedAt int64 + var clientSecretExpiresAt int64 + + if !r.ClientIDIssuedAt.IsZero() { + clientIDIssuedAt = r.ClientIDIssuedAt.Unix() + } + if !r.ClientSecretExpiresAt.IsZero() { + clientSecretExpiresAt = r.ClientSecretExpiresAt.Unix() + } + + return json.Marshal(&struct { + ClientIDIssuedAt int64 `json:"client_id_issued_at,omitempty"` + ClientSecretExpiresAt int64 `json:"client_secret_expires_at,omitempty"` + *alias + }{ + ClientIDIssuedAt: clientIDIssuedAt, + ClientSecretExpiresAt: clientSecretExpiresAt, + alias: (*alias)(r), + }) +} + +func (r *ClientRegistrationResponse) UnmarshalJSON(data []byte) error { + type alias ClientRegistrationResponse + aux := &struct { + ClientIDIssuedAt int64 `json:"client_id_issued_at,omitempty"` + ClientSecretExpiresAt int64 `json:"client_secret_expires_at,omitempty"` + *alias + }{ + alias: (*alias)(r), + } + if err := internaljson.Unmarshal(data, &aux); err != nil { + return err + } + if aux.ClientIDIssuedAt != 0 { + r.ClientIDIssuedAt = time.Unix(aux.ClientIDIssuedAt, 0) + } + if aux.ClientSecretExpiresAt != 0 { + r.ClientSecretExpiresAt = time.Unix(aux.ClientSecretExpiresAt, 0) + } + return nil +} + +// ClientRegistrationError is the error response from the Authorization Server +// for a failed registration attempt (RFC 7591, Section 3.2.2). +type ClientRegistrationError struct { + // ErrorCode is the REQUIRED error code if registration failed (RFC 7591, 3.2.2). + ErrorCode string `json:"error"` + + // ErrorDescription is an OPTIONAL human-readable error message. + ErrorDescription string `json:"error_description,omitempty"` +} + +func (e *ClientRegistrationError) Error() string { + return fmt.Sprintf("registration failed: %s (%s)", e.ErrorCode, e.ErrorDescription) +} + +// RegisterClient performs Dynamic Client Registration according to RFC 7591. +func RegisterClient(ctx context.Context, registrationEndpoint string, clientMeta *ClientRegistrationMetadata, c *http.Client) (*ClientRegistrationResponse, error) { + if registrationEndpoint == "" { + return nil, fmt.Errorf("registration_endpoint is required") + } + + if c == nil { + c = http.DefaultClient + } + + payload, err := json.Marshal(clientMeta) + if err != nil { + return nil, fmt.Errorf("failed to marshal client metadata: %w", err) + } + + req, err := http.NewRequestWithContext(ctx, "POST", registrationEndpoint, bytes.NewBuffer(payload)) + if err != nil { + return nil, fmt.Errorf("failed to create registration request: %w", err) + } + + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Accept", "application/json") + + resp, err := c.Do(req) + if err != nil { + return nil, fmt.Errorf("registration request failed: %w", err) + } + defer resp.Body.Close() + + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("failed to read registration response body: %w", err) + } + + if resp.StatusCode == http.StatusCreated { + var regResponse ClientRegistrationResponse + if err := internaljson.Unmarshal(body, ®Response); err != nil { + return nil, fmt.Errorf("failed to decode successful registration response: %w (%s)", err, string(body)) + } + if regResponse.ClientID == "" { + return nil, fmt.Errorf("registration response is missing required 'client_id' field") + } + // Validate URL fields to prevent XSS attacks (see #526). + if err := validateClientRegistrationURLs(®Response.ClientRegistrationMetadata); err != nil { + return nil, err + } + return ®Response, nil + } + + if resp.StatusCode == http.StatusBadRequest { + var regError ClientRegistrationError + if err := internaljson.Unmarshal(body, ®Error); err != nil { + return nil, fmt.Errorf("failed to decode registration error response: %w (%s)", err, string(body)) + } + return nil, ®Error + } + + return nil, fmt.Errorf("registration failed with status %s: %s", resp.Status, string(body)) +} + +// validateClientRegistrationURLs validates all URL fields in ClientRegistrationMetadata +// to ensure they don't use dangerous schemes that could enable XSS attacks. +func validateClientRegistrationURLs(meta *ClientRegistrationMetadata) error { + // Validate redirect URIs + for i, uri := range meta.RedirectURIs { + if err := checkURLScheme(uri); err != nil { + return fmt.Errorf("redirect_uris[%d]: %w", i, err) + } + } + + // Validate other URL fields + urls := []struct { + name string + value string + }{ + {"client_uri", meta.ClientURI}, + {"logo_uri", meta.LogoURI}, + {"tos_uri", meta.TOSURI}, + {"policy_uri", meta.PolicyURI}, + {"jwks_uri", meta.JWKSURI}, + } + + for _, u := range urls { + if err := checkURLScheme(u.value); err != nil { + return fmt.Errorf("%s: %w", u.name, err) + } + } + return nil +} diff --git a/mcp/dsx-exchange-mcp/vendor/github.com/modelcontextprotocol/go-sdk/oauthex/oauth2.go b/mcp/dsx-exchange-mcp/vendor/github.com/modelcontextprotocol/go-sdk/oauthex/oauth2.go new file mode 100644 index 0000000..836a420 --- /dev/null +++ b/mcp/dsx-exchange-mcp/vendor/github.com/modelcontextprotocol/go-sdk/oauthex/oauth2.go @@ -0,0 +1,80 @@ +// Copyright 2025 The Go MCP SDK Authors. All rights reserved. +// Use of this source code is governed by an MIT-style +// license that can be found in the LICENSE file. + +// Package oauthex implements extensions to OAuth2. + +//go:build mcp_go_client_oauth + +package oauthex + +import ( + "context" + "encoding/json" + "fmt" + "io" + "mime" + "net/http" + "net/url" + "strings" +) + +type httpStatusError struct { + StatusCode int +} + +func (e *httpStatusError) Error() string { + return fmt.Sprintf("bad status %d", e.StatusCode) +} + +// getJSON retrieves JSON and unmarshals JSON from the URL, as specified in both +// RFC 9728 and RFC 8414. +// It will not read more than limit bytes from the body. +func getJSON[T any](ctx context.Context, c *http.Client, url string, limit int64) (*T, error) { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) + if err != nil { + return nil, err + } + if c == nil { + c = http.DefaultClient + } + res, err := c.Do(req) + if err != nil { + return nil, err + } + defer res.Body.Close() + + if res.StatusCode != http.StatusOK { + return nil, &httpStatusError{StatusCode: res.StatusCode} + } + ct := res.Header.Get("Content-Type") + mediaType, _, err := mime.ParseMediaType(ct) + if err != nil || mediaType != "application/json" { + return nil, fmt.Errorf("bad content type %q", ct) + } + + var t T + dec := json.NewDecoder(io.LimitReader(res.Body, limit)) + if err := dec.Decode(&t); err != nil { + return nil, err + } + return &t, nil +} + +// checkURLScheme ensures that its argument is a valid URL with a scheme +// that prevents XSS attacks. +// See #526. +func checkURLScheme(u string) error { + if u == "" { + return nil + } + uu, err := url.Parse(u) + if err != nil { + return err + } + scheme := strings.ToLower(uu.Scheme) + if scheme == "javascript" || scheme == "data" || scheme == "vbscript" { + return fmt.Errorf("URL has disallowed scheme %q", scheme) + } + return nil +} diff --git a/mcp/dsx-exchange-mcp/vendor/github.com/modelcontextprotocol/go-sdk/oauthex/oauthex.go b/mcp/dsx-exchange-mcp/vendor/github.com/modelcontextprotocol/go-sdk/oauthex/oauthex.go new file mode 100644 index 0000000..151da7e --- /dev/null +++ b/mcp/dsx-exchange-mcp/vendor/github.com/modelcontextprotocol/go-sdk/oauthex/oauthex.go @@ -0,0 +1,6 @@ +// Copyright 2025 The Go MCP SDK Authors. All rights reserved. +// Use of this source code is governed by an MIT-style +// license that can be found in the LICENSE file. + +// Package oauthex implements extensions to OAuth2. +package oauthex diff --git a/mcp/dsx-exchange-mcp/vendor/github.com/modelcontextprotocol/go-sdk/oauthex/resource_meta.go b/mcp/dsx-exchange-mcp/vendor/github.com/modelcontextprotocol/go-sdk/oauthex/resource_meta.go new file mode 100644 index 0000000..8b911ca --- /dev/null +++ b/mcp/dsx-exchange-mcp/vendor/github.com/modelcontextprotocol/go-sdk/oauthex/resource_meta.go @@ -0,0 +1,280 @@ +// Copyright 2025 The Go MCP SDK Authors. All rights reserved. +// Use of this source code is governed by an MIT-style +// license that can be found in the LICENSE file. + +// This file implements Protected Resource Metadata. +// See https://www.rfc-editor.org/rfc/rfc9728.html. + +//go:build mcp_go_client_oauth + +package oauthex + +import ( + "context" + "errors" + "fmt" + "net/http" + "net/url" + "path" + "strings" + "unicode" + + "github.com/modelcontextprotocol/go-sdk/internal/util" +) + +const defaultProtectedResourceMetadataURI = "/.well-known/oauth-protected-resource" + +// GetProtectedResourceMetadataFromID issues a GET request to retrieve protected resource +// metadata from a resource server by its ID. +// The resource ID is an HTTPS URL, typically with a host:port and possibly a path. +// For example: +// +// https://example.com/server +// +// This function, following the spec (§3), inserts the default well-known path into the +// URL. In our example, the result would be +// +// https://example.com/.well-known/oauth-protected-resource/server +// +// It then retrieves the metadata at that location using the given client (or the +// default client if nil) and validates its resource field against resourceID. +// +// Deprecated: Use [GetProtectedResourceMetadata] instead. This function will be removed in v1.5.0. +func GetProtectedResourceMetadataFromID(ctx context.Context, resourceID string, c *http.Client) (_ *ProtectedResourceMetadata, err error) { + defer util.Wrapf(&err, "GetProtectedResourceMetadataFromID(%q)", resourceID) + + u, err := url.Parse(resourceID) + if err != nil { + return nil, err + } + // Insert well-known URI into URL. + u.Path = path.Join(defaultProtectedResourceMetadataURI, u.Path) + return GetProtectedResourceMetadata(ctx, u.String(), resourceID, c) +} + +// GetProtectedResourceMetadataFromHeader retrieves protected resource metadata +// using information in the given header, using the given client (or the default +// client if nil). +// It issues a GET request to a URL discovered by parsing the WWW-Authenticate headers in the given request. +// Per RFC 9728 section 3.3, it validates that the resource field of the resulting metadata +// matches the serverURL (the URL that the client used to make the original request to the resource server). +// If there is no metadata URL in the header, it returns nil, nil. +// +// Deprecated: Use [GetProtectedResourceMetadata] instead. This function will be removed in v1.5.0. +func GetProtectedResourceMetadataFromHeader(ctx context.Context, serverURL string, header http.Header, c *http.Client) (_ *ProtectedResourceMetadata, err error) { + headers := header[http.CanonicalHeaderKey("WWW-Authenticate")] + if len(headers) == 0 { + return nil, nil + } + cs, err := ParseWWWAuthenticate(headers) + if err != nil { + return nil, err + } + metadataURL := resourceMetadataURL(cs) + if metadataURL == "" { + return nil, nil + } + return GetProtectedResourceMetadata(ctx, metadataURL, serverURL, c) +} + +// resourceMetadataURL returns a resource metadata URL from the given "WWW-Authenticate" header challenges, +// or the empty string if there is none. +func resourceMetadataURL(cs []Challenge) string { + for _, c := range cs { + if u := c.Params["resource_metadata"]; u != "" { + return u + } + } + return "" +} + +// GetProtectedResourceMetadataFromID issues a GET request to retrieve protected resource +// metadata from a resource server. +// The metadataURL is typically a URL with a host:port and possibly a path. +// The resourceURL is the resource URI the metadataURL is for. +// The following checks are performed: +// - The metadataURL must use HTTPS or be a local address. +// - The resource field of the resulting metadata must match the resourceURL. +// - The authorization_servers field of the resulting metadata is checked for dangerous URL schemes. +func GetProtectedResourceMetadata(ctx context.Context, metadataURL, resourceURL string, c *http.Client) (_ *ProtectedResourceMetadata, err error) { + defer util.Wrapf(&err, "GetProtectedResourceMetadata(%q)", metadataURL) + u, err := url.Parse(metadataURL) + if err != nil { + return nil, err + } + // Only allow HTTP for local addresses (testing or development purposes). + if !util.IsLoopback(u.Host) && u.Scheme != "https" { + return nil, fmt.Errorf("metadataURL %q does not use HTTPS", metadataURL) + } + prm, err := getJSON[ProtectedResourceMetadata](ctx, c, metadataURL, 1<<20) + if err != nil { + return nil, err + } + // Validate the Resource field (see RFC 9728, section 3.3). + if prm.Resource != resourceURL { + return nil, fmt.Errorf("got metadata resource %q, want %q", prm.Resource, resourceURL) + } + // Validate the authorization server URLs to prevent XSS attacks (see #526). + for _, u := range prm.AuthorizationServers { + if err := checkURLScheme(u); err != nil { + return nil, err + } + } + return prm, nil +} + +// ParseWWWAuthenticate parses a WWW-Authenticate header string. +// The header format is defined in RFC 9110, Section 11.6.1, and can contain +// one or more challenges, separated by commas. +// It returns a slice of challenges or an error if one of the headers is malformed. +func ParseWWWAuthenticate(headers []string) ([]Challenge, error) { + var challenges []Challenge + for _, h := range headers { + challengeStrings, err := splitChallenges(h) + if err != nil { + return nil, err + } + for _, cs := range challengeStrings { + if strings.TrimSpace(cs) == "" { + continue + } + challenge, err := parseSingleChallenge(cs) + if err != nil { + return nil, fmt.Errorf("failed to parse challenge %q: %w", cs, err) + } + challenges = append(challenges, challenge) + } + } + return challenges, nil +} + +// splitChallenges splits a header value containing one or more challenges. +// It correctly handles commas within quoted strings and distinguishes between +// commas separating auth-params and commas separating challenges. +func splitChallenges(header string) ([]string, error) { + var challenges []string + inQuotes := false + start := 0 + for i, r := range header { + if r == '"' { + if i > 0 && header[i-1] != '\\' { + inQuotes = !inQuotes + } else if i == 0 { + // A challenge begins with an auth-scheme, which is a token, which cannot contain + // a quote. + return nil, errors.New(`challenge begins with '"'`) + } + } else if r == ',' && !inQuotes { + // This is a potential challenge separator. + // A new challenge does not start with `key=value`. + // We check if the part after the comma looks like a parameter. + lookahead := strings.TrimSpace(header[i+1:]) + eqPos := strings.Index(lookahead, "=") + + isParam := false + if eqPos > 0 { + // Check if the part before '=' is a single token (no spaces). + token := lookahead[:eqPos] + if strings.IndexFunc(token, unicode.IsSpace) == -1 { + isParam = true + } + } + + if !isParam { + // The part after the comma does not look like a parameter, + // so this comma separates challenges. + challenges = append(challenges, header[start:i]) + start = i + 1 + } + } + } + // Add the last (or only) challenge to the list. + challenges = append(challenges, header[start:]) + return challenges, nil +} + +// parseSingleChallenge parses a string containing exactly one challenge. +// challenge = auth-scheme [ 1*SP ( token68 / #auth-param ) ] +func parseSingleChallenge(s string) (Challenge, error) { + s = strings.TrimSpace(s) + if s == "" { + return Challenge{}, errors.New("empty challenge string") + } + + scheme, paramsStr, found := strings.Cut(s, " ") + c := Challenge{Scheme: strings.ToLower(scheme)} + if !found { + return c, nil + } + + params := make(map[string]string) + + // Parse the key-value parameters. + for paramsStr != "" { + // Find the end of the parameter key. + keyEnd := strings.Index(paramsStr, "=") + if keyEnd <= 0 { + return Challenge{}, fmt.Errorf("malformed auth parameter: expected key=value, but got %q", paramsStr) + } + key := strings.TrimSpace(paramsStr[:keyEnd]) + + // Move the string past the key and the '='. + paramsStr = strings.TrimSpace(paramsStr[keyEnd+1:]) + + var value string + if strings.HasPrefix(paramsStr, "\"") { + // The value is a quoted string. + paramsStr = paramsStr[1:] // Consume the opening quote. + var valBuilder strings.Builder + i := 0 + for ; i < len(paramsStr); i++ { + // Handle escaped characters. + if paramsStr[i] == '\\' && i+1 < len(paramsStr) { + valBuilder.WriteByte(paramsStr[i+1]) + i++ // We've consumed two characters. + } else if paramsStr[i] == '"' { + // End of the quoted string. + break + } else { + valBuilder.WriteByte(paramsStr[i]) + } + } + + // A quoted string must be terminated. + if i == len(paramsStr) { + return Challenge{}, fmt.Errorf("unterminated quoted string in auth parameter") + } + + value = valBuilder.String() + // Move the string past the value and the closing quote. + paramsStr = strings.TrimSpace(paramsStr[i+1:]) + } else { + // The value is a token. It ends at the next comma or the end of the string. + commaPos := strings.Index(paramsStr, ",") + if commaPos == -1 { + value = paramsStr + paramsStr = "" + } else { + value = strings.TrimSpace(paramsStr[:commaPos]) + paramsStr = strings.TrimSpace(paramsStr[commaPos:]) // Keep comma for next check + } + } + if value == "" { + return Challenge{}, fmt.Errorf("no value for auth param %q", key) + } + + // Per RFC 9110, parameter keys are case-insensitive. + params[strings.ToLower(key)] = value + + // If there is a comma, consume it and continue to the next parameter. + if strings.HasPrefix(paramsStr, ",") { + paramsStr = strings.TrimSpace(paramsStr[1:]) + } else if paramsStr != "" { + // If there's content but it's not a new parameter, the format is wrong. + return Challenge{}, fmt.Errorf("malformed auth parameter: expected comma after value, but got %q", paramsStr) + } + } + + // Per RFC 9110, the scheme is case-insensitive. + return Challenge{Scheme: strings.ToLower(scheme), Params: params}, nil +} diff --git a/mcp/dsx-exchange-mcp/vendor/github.com/modelcontextprotocol/go-sdk/oauthex/resource_meta_public.go b/mcp/dsx-exchange-mcp/vendor/github.com/modelcontextprotocol/go-sdk/oauthex/resource_meta_public.go new file mode 100644 index 0000000..3bf7d9a --- /dev/null +++ b/mcp/dsx-exchange-mcp/vendor/github.com/modelcontextprotocol/go-sdk/oauthex/resource_meta_public.go @@ -0,0 +1,105 @@ +// Copyright 2025 The Go MCP SDK Authors. All rights reserved. +// Use of this source code is governed by an MIT-style +// license that can be found in the LICENSE file. + +// This file implements Protected Resource Metadata. +// See https://www.rfc-editor.org/rfc/rfc9728.html. + +// This is a temporary file to expose the required objects to the main package. + +package oauthex + +// ProtectedResourceMetadata is the metadata for an OAuth 2.0 protected resource, +// as defined in section 2 of https://www.rfc-editor.org/rfc/rfc9728.html. +// +// The following features are not supported: +// - additional keys (§2, last sentence) +// - human-readable metadata (§2.1) +// - signed metadata (§2.2) +type ProtectedResourceMetadata struct { + // Resource (resource) is the protected resource's resource identifier. + // Required. + Resource string `json:"resource"` + + // AuthorizationServers (authorization_servers) is an optional slice containing a list of + // OAuth authorization server issuer identifiers (as defined in RFC 8414) that can be + // used with this protected resource. + AuthorizationServers []string `json:"authorization_servers,omitempty"` + + // JWKSURI (jwks_uri) is an optional URL of the protected resource's JSON Web Key (JWK) Set + // document. This contains public keys belonging to the protected resource, such as + // signing key(s) that the resource server uses to sign resource responses. + JWKSURI string `json:"jwks_uri,omitempty"` + + // ScopesSupported (scopes_supported) is a recommended slice containing a list of scope + // values (as defined in RFC 6749) used in authorization requests to request access + // to this protected resource. + ScopesSupported []string `json:"scopes_supported,omitempty"` + + // BearerMethodsSupported (bearer_methods_supported) is an optional slice containing + // a list of the supported methods of sending an OAuth 2.0 bearer token to the + // protected resource. Defined values are "header", "body", and "query". + BearerMethodsSupported []string `json:"bearer_methods_supported,omitempty"` + + // ResourceSigningAlgValuesSupported (resource_signing_alg_values_supported) is an optional + // slice of JWS signing algorithms (alg values) supported by the protected + // resource for signing resource responses. + ResourceSigningAlgValuesSupported []string `json:"resource_signing_alg_values_supported,omitempty"` + + // ResourceName (resource_name) is a human-readable name of the protected resource + // intended for display to the end user. It is RECOMMENDED that this field be included. + // This value may be internationalized. + ResourceName string `json:"resource_name,omitempty"` + + // ResourceDocumentation (resource_documentation) is an optional URL of a page containing + // human-readable information for developers using the protected resource. + // This value may be internationalized. + ResourceDocumentation string `json:"resource_documentation,omitempty"` + + // ResourcePolicyURI (resource_policy_uri) is an optional URL of a page containing + // human-readable policy information on how a client can use the data provided. + // This value may be internationalized. + ResourcePolicyURI string `json:"resource_policy_uri,omitempty"` + + // ResourceTOSURI (resource_tos_uri) is an optional URL of a page containing the protected + // resource's human-readable terms of service. This value may be internationalized. + ResourceTOSURI string `json:"resource_tos_uri,omitempty"` + + // TLSClientCertificateBoundAccessTokens (tls_client_certificate_bound_access_tokens) is an + // optional boolean indicating support for mutual-TLS client certificate-bound + // access tokens (RFC 8705). Defaults to false if omitted. + TLSClientCertificateBoundAccessTokens bool `json:"tls_client_certificate_bound_access_tokens,omitempty"` + + // AuthorizationDetailsTypesSupported (authorization_details_types_supported) is an optional + // slice of 'type' values supported by the resource server for the + // 'authorization_details' parameter (RFC 9396). + AuthorizationDetailsTypesSupported []string `json:"authorization_details_types_supported,omitempty"` + + // DPOPSigningAlgValuesSupported (dpop_signing_alg_values_supported) is an optional + // slice of JWS signing algorithms supported by the resource server for validating + // DPoP proof JWTs (RFC 9449). + DPOPSigningAlgValuesSupported []string `json:"dpop_signing_alg_values_supported,omitempty"` + + // DPOPBoundAccessTokensRequired (dpop_bound_access_tokens_required) is an optional boolean + // specifying whether the protected resource always requires the use of DPoP-bound + // access tokens (RFC 9449). Defaults to false if omitted. + DPOPBoundAccessTokensRequired bool `json:"dpop_bound_access_tokens_required,omitempty"` + + // SignedMetadata (signed_metadata) is an optional JWT containing metadata parameters + // about the protected resource as claims. If present, these values take precedence + // over values conveyed in plain JSON. + // TODO:implement. + // Note that §2.2 says it's okay to ignore this. + // SignedMetadata string `json:"signed_metadata,omitempty"` +} + +// Challenge represents a single authentication challenge from a WWW-Authenticate header. +// As per RFC 9110, Section 11.6.1, a challenge consists of a scheme and optional parameters. +type Challenge struct { + // Scheme is the authentication scheme (e.g., "Bearer", "Basic"). + // It is case-insensitive. A parsed value will always be lower-case. + Scheme string + // Params is a map of authentication parameters. + // Keys are case-insensitive. Parsed keys are always lower-case. + Params map[string]string +} diff --git a/mcp/dsx-exchange-mcp/vendor/github.com/segmentio/asm/LICENSE b/mcp/dsx-exchange-mcp/vendor/github.com/segmentio/asm/LICENSE new file mode 100644 index 0000000..29e1ab6 --- /dev/null +++ b/mcp/dsx-exchange-mcp/vendor/github.com/segmentio/asm/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2021 Segment + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/mcp/dsx-exchange-mcp/vendor/github.com/segmentio/asm/ascii/ascii.go b/mcp/dsx-exchange-mcp/vendor/github.com/segmentio/asm/ascii/ascii.go new file mode 100644 index 0000000..4805146 --- /dev/null +++ b/mcp/dsx-exchange-mcp/vendor/github.com/segmentio/asm/ascii/ascii.go @@ -0,0 +1,53 @@ +package ascii + +import _ "github.com/segmentio/asm/cpu" + +// https://graphics.stanford.edu/~seander/bithacks.html#HasLessInWord +const ( + hasLessConstL64 = (^uint64(0)) / 255 + hasLessConstR64 = hasLessConstL64 * 128 + + hasLessConstL32 = (^uint32(0)) / 255 + hasLessConstR32 = hasLessConstL32 * 128 + + hasMoreConstL64 = (^uint64(0)) / 255 + hasMoreConstR64 = hasMoreConstL64 * 128 + + hasMoreConstL32 = (^uint32(0)) / 255 + hasMoreConstR32 = hasMoreConstL32 * 128 +) + +func hasLess64(x, n uint64) bool { + return ((x - (hasLessConstL64 * n)) & ^x & hasLessConstR64) != 0 +} + +func hasLess32(x, n uint32) bool { + return ((x - (hasLessConstL32 * n)) & ^x & hasLessConstR32) != 0 +} + +func hasMore64(x, n uint64) bool { + return (((x + (hasMoreConstL64 * (127 - n))) | x) & hasMoreConstR64) != 0 +} + +func hasMore32(x, n uint32) bool { + return (((x + (hasMoreConstL32 * (127 - n))) | x) & hasMoreConstR32) != 0 +} + +var lowerCase = [256]byte{ + 0x00, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0a, 0x0b, 0x0c, 0x0d, 0x0e, 0x0f, + 0x10, 0x11, 0x12, 0x13, 0x14, 0x15, 0x16, 0x17, 0x18, 0x19, 0x1a, 0x1b, 0x1c, 0x1d, 0x1e, 0x1f, + 0x20, 0x21, 0x22, 0x23, 0x24, 0x25, 0x26, 0x27, 0x28, 0x29, 0x2a, 0x2b, 0x2c, 0x2d, 0x2e, 0x2f, + 0x30, 0x31, 0x32, 0x33, 0x34, 0x35, 0x36, 0x37, 0x38, 0x39, 0x3a, 0x3b, 0x3c, 0x3d, 0x3e, 0x3f, + 0x40, 0x61, 0x62, 0x63, 0x64, 0x65, 0x66, 0x67, 0x68, 0x69, 0x6a, 0x6b, 0x6c, 0x6d, 0x6e, 0x6f, + 0x70, 0x71, 0x72, 0x73, 0x74, 0x75, 0x76, 0x77, 0x78, 0x79, 0x7a, 0x5b, 0x5c, 0x5d, 0x5e, 0x5f, + 0x60, 0x61, 0x62, 0x63, 0x64, 0x65, 0x66, 0x67, 0x68, 0x69, 0x6a, 0x6b, 0x6c, 0x6d, 0x6e, 0x6f, + 0x70, 0x71, 0x72, 0x73, 0x74, 0x75, 0x76, 0x77, 0x78, 0x79, 0x7a, 0x7b, 0x7c, 0x7d, 0x7e, 0x7f, + 0x80, 0x81, 0x82, 0x83, 0x84, 0x85, 0x86, 0x87, 0x88, 0x89, 0x8a, 0x8b, 0x8c, 0x8d, 0x8e, 0x8f, + 0x90, 0x91, 0x92, 0x93, 0x94, 0x95, 0x96, 0x97, 0x98, 0x99, 0x9a, 0x9b, 0x9c, 0x9d, 0x9e, 0x9f, + 0xa0, 0xa1, 0xa2, 0xa3, 0xa4, 0xa5, 0xa6, 0xa7, 0xa8, 0xa9, 0xaa, 0xab, 0xac, 0xad, 0xae, 0xaf, + 0xb0, 0xb1, 0xb2, 0xb3, 0xb4, 0xb5, 0xb6, 0xb7, 0xb8, 0xb9, 0xba, 0xbb, 0xbc, 0xbd, 0xbe, 0xbf, + 0xc0, 0xc1, 0xc2, 0xc3, 0xc4, 0xc5, 0xc6, 0xc7, 0xc8, 0xc9, 0xca, 0xcb, 0xcc, 0xcd, 0xce, 0xcf, + 0xd0, 0xd1, 0xd2, 0xd3, 0xd4, 0xd5, 0xd6, 0xd7, 0xd8, 0xd9, 0xda, 0xdb, 0xdc, 0xdd, 0xde, 0xdf, + 0xe0, 0xe1, 0xe2, 0xe3, 0xe4, 0xe5, 0xe6, 0xe7, 0xe8, 0xe9, 0xea, 0xeb, 0xec, 0xed, 0xee, 0xef, + 0xf0, 0xf1, 0xf2, 0xf3, 0xf4, 0xf5, 0xf6, 0xf7, 0xf8, 0xf9, 0xfa, 0xfb, 0xfc, 0xfd, 0xfe, 0xff, +} diff --git a/mcp/dsx-exchange-mcp/vendor/github.com/segmentio/asm/ascii/equal_fold.go b/mcp/dsx-exchange-mcp/vendor/github.com/segmentio/asm/ascii/equal_fold.go new file mode 100644 index 0000000..d90d8ca --- /dev/null +++ b/mcp/dsx-exchange-mcp/vendor/github.com/segmentio/asm/ascii/equal_fold.go @@ -0,0 +1,30 @@ +package ascii + +import ( + "github.com/segmentio/asm/internal/unsafebytes" +) + +// EqualFold is a version of bytes.EqualFold designed to work on ASCII input +// instead of UTF-8. +// +// When the program has guarantees that the input is composed of ASCII +// characters only, it allows for greater optimizations. +func EqualFold(a, b []byte) bool { + return EqualFoldString(unsafebytes.String(a), unsafebytes.String(b)) +} + +func HasPrefixFold(s, prefix []byte) bool { + return len(s) >= len(prefix) && EqualFold(s[:len(prefix)], prefix) +} + +func HasSuffixFold(s, suffix []byte) bool { + return len(s) >= len(suffix) && EqualFold(s[len(s)-len(suffix):], suffix) +} + +func HasPrefixFoldString(s, prefix string) bool { + return len(s) >= len(prefix) && EqualFoldString(s[:len(prefix)], prefix) +} + +func HasSuffixFoldString(s, suffix string) bool { + return len(s) >= len(suffix) && EqualFoldString(s[len(s)-len(suffix):], suffix) +} diff --git a/mcp/dsx-exchange-mcp/vendor/github.com/segmentio/asm/ascii/equal_fold_amd64.go b/mcp/dsx-exchange-mcp/vendor/github.com/segmentio/asm/ascii/equal_fold_amd64.go new file mode 100644 index 0000000..07cf6cd --- /dev/null +++ b/mcp/dsx-exchange-mcp/vendor/github.com/segmentio/asm/ascii/equal_fold_amd64.go @@ -0,0 +1,13 @@ +// Code generated by command: go run equal_fold_asm.go -pkg ascii -out ../ascii/equal_fold_amd64.s -stubs ../ascii/equal_fold_amd64.go. DO NOT EDIT. + +//go:build !purego +// +build !purego + +package ascii + +// EqualFoldString is a version of strings.EqualFold designed to work on ASCII +// input instead of UTF-8. +// +// When the program has guarantees that the input is composed of ASCII +// characters only, it allows for greater optimizations. +func EqualFoldString(a string, b string) bool diff --git a/mcp/dsx-exchange-mcp/vendor/github.com/segmentio/asm/ascii/equal_fold_amd64.s b/mcp/dsx-exchange-mcp/vendor/github.com/segmentio/asm/ascii/equal_fold_amd64.s new file mode 100644 index 0000000..34495a6 --- /dev/null +++ b/mcp/dsx-exchange-mcp/vendor/github.com/segmentio/asm/ascii/equal_fold_amd64.s @@ -0,0 +1,304 @@ +// Code generated by command: go run equal_fold_asm.go -pkg ascii -out ../ascii/equal_fold_amd64.s -stubs ../ascii/equal_fold_amd64.go. DO NOT EDIT. + +//go:build !purego +// +build !purego + +#include "textflag.h" + +// func EqualFoldString(a string, b string) bool +// Requires: AVX, AVX2, SSE4.1 +TEXT ·EqualFoldString(SB), NOSPLIT, $0-33 + MOVQ a_base+0(FP), CX + MOVQ a_len+8(FP), DX + MOVQ b_base+16(FP), BX + CMPQ DX, b_len+24(FP) + JNE done + XORQ AX, AX + CMPQ DX, $0x10 + JB init_x86 + BTL $0x08, github·com∕segmentio∕asm∕cpu·X86+0(SB) + JCS init_avx + +init_x86: + LEAQ github·com∕segmentio∕asm∕ascii·lowerCase+0(SB), R9 + XORL SI, SI + +cmp8: + CMPQ DX, $0x08 + JB cmp7 + MOVBLZX (CX)(AX*1), DI + MOVBLZX (BX)(AX*1), R8 + MOVB (R9)(DI*1), DI + XORB (R9)(R8*1), DI + ORB DI, SI + MOVBLZX 1(CX)(AX*1), DI + MOVBLZX 1(BX)(AX*1), R8 + MOVB (R9)(DI*1), DI + XORB (R9)(R8*1), DI + ORB DI, SI + MOVBLZX 2(CX)(AX*1), DI + MOVBLZX 2(BX)(AX*1), R8 + MOVB (R9)(DI*1), DI + XORB (R9)(R8*1), DI + ORB DI, SI + MOVBLZX 3(CX)(AX*1), DI + MOVBLZX 3(BX)(AX*1), R8 + MOVB (R9)(DI*1), DI + XORB (R9)(R8*1), DI + ORB DI, SI + MOVBLZX 4(CX)(AX*1), DI + MOVBLZX 4(BX)(AX*1), R8 + MOVB (R9)(DI*1), DI + XORB (R9)(R8*1), DI + ORB DI, SI + MOVBLZX 5(CX)(AX*1), DI + MOVBLZX 5(BX)(AX*1), R8 + MOVB (R9)(DI*1), DI + XORB (R9)(R8*1), DI + ORB DI, SI + MOVBLZX 6(CX)(AX*1), DI + MOVBLZX 6(BX)(AX*1), R8 + MOVB (R9)(DI*1), DI + XORB (R9)(R8*1), DI + ORB DI, SI + MOVBLZX 7(CX)(AX*1), DI + MOVBLZX 7(BX)(AX*1), R8 + MOVB (R9)(DI*1), DI + XORB (R9)(R8*1), DI + ORB DI, SI + JNE done + ADDQ $0x08, AX + SUBQ $0x08, DX + JMP cmp8 + +cmp7: + CMPQ DX, $0x07 + JB cmp6 + MOVBLZX 6(CX)(AX*1), DI + MOVBLZX 6(BX)(AX*1), R8 + MOVB (R9)(DI*1), DI + XORB (R9)(R8*1), DI + ORB DI, SI + +cmp6: + CMPQ DX, $0x06 + JB cmp5 + MOVBLZX 5(CX)(AX*1), DI + MOVBLZX 5(BX)(AX*1), R8 + MOVB (R9)(DI*1), DI + XORB (R9)(R8*1), DI + ORB DI, SI + +cmp5: + CMPQ DX, $0x05 + JB cmp4 + MOVBLZX 4(CX)(AX*1), DI + MOVBLZX 4(BX)(AX*1), R8 + MOVB (R9)(DI*1), DI + XORB (R9)(R8*1), DI + ORB DI, SI + +cmp4: + CMPQ DX, $0x04 + JB cmp3 + MOVBLZX 3(CX)(AX*1), DI + MOVBLZX 3(BX)(AX*1), R8 + MOVB (R9)(DI*1), DI + XORB (R9)(R8*1), DI + ORB DI, SI + +cmp3: + CMPQ DX, $0x03 + JB cmp2 + MOVBLZX 2(CX)(AX*1), DI + MOVBLZX 2(BX)(AX*1), R8 + MOVB (R9)(DI*1), DI + XORB (R9)(R8*1), DI + ORB DI, SI + +cmp2: + CMPQ DX, $0x02 + JB cmp1 + MOVBLZX 1(CX)(AX*1), DI + MOVBLZX 1(BX)(AX*1), R8 + MOVB (R9)(DI*1), DI + XORB (R9)(R8*1), DI + ORB DI, SI + +cmp1: + CMPQ DX, $0x01 + JB success + MOVBLZX (CX)(AX*1), DI + MOVBLZX (BX)(AX*1), R8 + MOVB (R9)(DI*1), DI + XORB (R9)(R8*1), DI + ORB DI, SI + +done: + SETEQ ret+32(FP) + RET + +success: + MOVB $0x01, ret+32(FP) + RET + +init_avx: + MOVB $0x20, SI + PINSRB $0x00, SI, X12 + VPBROADCASTB X12, Y12 + MOVB $0x1f, SI + PINSRB $0x00, SI, X13 + VPBROADCASTB X13, Y13 + MOVB $0x9a, SI + PINSRB $0x00, SI, X14 + VPBROADCASTB X14, Y14 + MOVB $0x01, SI + PINSRB $0x00, SI, X15 + VPBROADCASTB X15, Y15 + +cmp128: + CMPQ DX, $0x80 + JB cmp64 + VMOVDQU (CX)(AX*1), Y0 + VMOVDQU 32(CX)(AX*1), Y1 + VMOVDQU 64(CX)(AX*1), Y2 + VMOVDQU 96(CX)(AX*1), Y3 + VMOVDQU (BX)(AX*1), Y4 + VMOVDQU 32(BX)(AX*1), Y5 + VMOVDQU 64(BX)(AX*1), Y6 + VMOVDQU 96(BX)(AX*1), Y7 + VXORPD Y0, Y4, Y4 + VPCMPEQB Y12, Y4, Y8 + VORPD Y12, Y0, Y0 + VPADDB Y13, Y0, Y0 + VPCMPGTB Y0, Y14, Y0 + VPAND Y8, Y0, Y0 + VPAND Y15, Y0, Y0 + VPSLLW $0x05, Y0, Y0 + VPCMPEQB Y4, Y0, Y0 + VXORPD Y1, Y5, Y5 + VPCMPEQB Y12, Y5, Y9 + VORPD Y12, Y1, Y1 + VPADDB Y13, Y1, Y1 + VPCMPGTB Y1, Y14, Y1 + VPAND Y9, Y1, Y1 + VPAND Y15, Y1, Y1 + VPSLLW $0x05, Y1, Y1 + VPCMPEQB Y5, Y1, Y1 + VXORPD Y2, Y6, Y6 + VPCMPEQB Y12, Y6, Y10 + VORPD Y12, Y2, Y2 + VPADDB Y13, Y2, Y2 + VPCMPGTB Y2, Y14, Y2 + VPAND Y10, Y2, Y2 + VPAND Y15, Y2, Y2 + VPSLLW $0x05, Y2, Y2 + VPCMPEQB Y6, Y2, Y2 + VXORPD Y3, Y7, Y7 + VPCMPEQB Y12, Y7, Y11 + VORPD Y12, Y3, Y3 + VPADDB Y13, Y3, Y3 + VPCMPGTB Y3, Y14, Y3 + VPAND Y11, Y3, Y3 + VPAND Y15, Y3, Y3 + VPSLLW $0x05, Y3, Y3 + VPCMPEQB Y7, Y3, Y3 + VPAND Y1, Y0, Y0 + VPAND Y3, Y2, Y2 + VPAND Y2, Y0, Y0 + ADDQ $0x80, AX + SUBQ $0x80, DX + VPMOVMSKB Y0, SI + XORL $0xffffffff, SI + JNE done + JMP cmp128 + +cmp64: + CMPQ DX, $0x40 + JB cmp32 + VMOVDQU (CX)(AX*1), Y0 + VMOVDQU 32(CX)(AX*1), Y1 + VMOVDQU (BX)(AX*1), Y2 + VMOVDQU 32(BX)(AX*1), Y3 + VXORPD Y0, Y2, Y2 + VPCMPEQB Y12, Y2, Y4 + VORPD Y12, Y0, Y0 + VPADDB Y13, Y0, Y0 + VPCMPGTB Y0, Y14, Y0 + VPAND Y4, Y0, Y0 + VPAND Y15, Y0, Y0 + VPSLLW $0x05, Y0, Y0 + VPCMPEQB Y2, Y0, Y0 + VXORPD Y1, Y3, Y3 + VPCMPEQB Y12, Y3, Y5 + VORPD Y12, Y1, Y1 + VPADDB Y13, Y1, Y1 + VPCMPGTB Y1, Y14, Y1 + VPAND Y5, Y1, Y1 + VPAND Y15, Y1, Y1 + VPSLLW $0x05, Y1, Y1 + VPCMPEQB Y3, Y1, Y1 + VPAND Y1, Y0, Y0 + ADDQ $0x40, AX + SUBQ $0x40, DX + VPMOVMSKB Y0, SI + XORL $0xffffffff, SI + JNE done + +cmp32: + CMPQ DX, $0x20 + JB cmp16 + VMOVDQU (CX)(AX*1), Y0 + VMOVDQU (BX)(AX*1), Y1 + VXORPD Y0, Y1, Y1 + VPCMPEQB Y12, Y1, Y2 + VORPD Y12, Y0, Y0 + VPADDB Y13, Y0, Y0 + VPCMPGTB Y0, Y14, Y0 + VPAND Y2, Y0, Y0 + VPAND Y15, Y0, Y0 + VPSLLW $0x05, Y0, Y0 + VPCMPEQB Y1, Y0, Y0 + ADDQ $0x20, AX + SUBQ $0x20, DX + VPMOVMSKB Y0, SI + XORL $0xffffffff, SI + JNE done + +cmp16: + CMPQ DX, $0x10 + JLE cmp_tail + VMOVDQU (CX)(AX*1), X0 + VMOVDQU (BX)(AX*1), X1 + VXORPD X0, X1, X1 + VPCMPEQB X12, X1, X2 + VORPD X12, X0, X0 + VPADDB X13, X0, X0 + VPCMPGTB X0, X14, X0 + VPAND X2, X0, X0 + VPAND X15, X0, X0 + VPSLLW $0x05, X0, X0 + VPCMPEQB X1, X0, X0 + ADDQ $0x10, AX + SUBQ $0x10, DX + VPMOVMSKB X0, SI + XORL $0x0000ffff, SI + JNE done + +cmp_tail: + SUBQ $0x10, DX + ADDQ DX, AX + VMOVDQU (CX)(AX*1), X0 + VMOVDQU (BX)(AX*1), X1 + VXORPD X0, X1, X1 + VPCMPEQB X12, X1, X2 + VORPD X12, X0, X0 + VPADDB X13, X0, X0 + VPCMPGTB X0, X14, X0 + VPAND X2, X0, X0 + VPAND X15, X0, X0 + VPSLLW $0x05, X0, X0 + VPCMPEQB X1, X0, X0 + VPMOVMSKB X0, AX + XORL $0x0000ffff, AX + JMP done diff --git a/mcp/dsx-exchange-mcp/vendor/github.com/segmentio/asm/ascii/equal_fold_default.go b/mcp/dsx-exchange-mcp/vendor/github.com/segmentio/asm/ascii/equal_fold_default.go new file mode 100644 index 0000000..1ae5a13 --- /dev/null +++ b/mcp/dsx-exchange-mcp/vendor/github.com/segmentio/asm/ascii/equal_fold_default.go @@ -0,0 +1,60 @@ +//go:build purego || !amd64 +// +build purego !amd64 + +package ascii + +// EqualFoldString is a version of strings.EqualFold designed to work on ASCII +// input instead of UTF-8. +// +// When the program has guarantees that the input is composed of ASCII +// characters only, it allows for greater optimizations. +func EqualFoldString(a, b string) bool { + if len(a) != len(b) { + return false + } + + var cmp byte + + for len(a) >= 8 { + cmp |= lowerCase[a[0]] ^ lowerCase[b[0]] + cmp |= lowerCase[a[1]] ^ lowerCase[b[1]] + cmp |= lowerCase[a[2]] ^ lowerCase[b[2]] + cmp |= lowerCase[a[3]] ^ lowerCase[b[3]] + cmp |= lowerCase[a[4]] ^ lowerCase[b[4]] + cmp |= lowerCase[a[5]] ^ lowerCase[b[5]] + cmp |= lowerCase[a[6]] ^ lowerCase[b[6]] + cmp |= lowerCase[a[7]] ^ lowerCase[b[7]] + + if cmp != 0 { + return false + } + + a = a[8:] + b = b[8:] + } + + switch len(a) { + case 7: + cmp |= lowerCase[a[6]] ^ lowerCase[b[6]] + fallthrough + case 6: + cmp |= lowerCase[a[5]] ^ lowerCase[b[5]] + fallthrough + case 5: + cmp |= lowerCase[a[4]] ^ lowerCase[b[4]] + fallthrough + case 4: + cmp |= lowerCase[a[3]] ^ lowerCase[b[3]] + fallthrough + case 3: + cmp |= lowerCase[a[2]] ^ lowerCase[b[2]] + fallthrough + case 2: + cmp |= lowerCase[a[1]] ^ lowerCase[b[1]] + fallthrough + case 1: + cmp |= lowerCase[a[0]] ^ lowerCase[b[0]] + } + + return cmp == 0 +} diff --git a/mcp/dsx-exchange-mcp/vendor/github.com/segmentio/asm/ascii/valid.go b/mcp/dsx-exchange-mcp/vendor/github.com/segmentio/asm/ascii/valid.go new file mode 100644 index 0000000..a5168ef --- /dev/null +++ b/mcp/dsx-exchange-mcp/vendor/github.com/segmentio/asm/ascii/valid.go @@ -0,0 +1,18 @@ +package ascii + +import "github.com/segmentio/asm/internal/unsafebytes" + +// Valid returns true if b contains only ASCII characters. +func Valid(b []byte) bool { + return ValidString(unsafebytes.String(b)) +} + +// ValidBytes returns true if b is an ASCII character. +func ValidByte(b byte) bool { + return b <= 0x7f +} + +// ValidBytes returns true if b is an ASCII character. +func ValidRune(r rune) bool { + return r <= 0x7f +} diff --git a/mcp/dsx-exchange-mcp/vendor/github.com/segmentio/asm/ascii/valid_amd64.go b/mcp/dsx-exchange-mcp/vendor/github.com/segmentio/asm/ascii/valid_amd64.go new file mode 100644 index 0000000..72dc7b4 --- /dev/null +++ b/mcp/dsx-exchange-mcp/vendor/github.com/segmentio/asm/ascii/valid_amd64.go @@ -0,0 +1,9 @@ +// Code generated by command: go run valid_asm.go -pkg ascii -out ../ascii/valid_amd64.s -stubs ../ascii/valid_amd64.go. DO NOT EDIT. + +//go:build !purego +// +build !purego + +package ascii + +// ValidString returns true if s contains only ASCII characters. +func ValidString(s string) bool diff --git a/mcp/dsx-exchange-mcp/vendor/github.com/segmentio/asm/ascii/valid_amd64.s b/mcp/dsx-exchange-mcp/vendor/github.com/segmentio/asm/ascii/valid_amd64.s new file mode 100644 index 0000000..0214b0c --- /dev/null +++ b/mcp/dsx-exchange-mcp/vendor/github.com/segmentio/asm/ascii/valid_amd64.s @@ -0,0 +1,132 @@ +// Code generated by command: go run valid_asm.go -pkg ascii -out ../ascii/valid_amd64.s -stubs ../ascii/valid_amd64.go. DO NOT EDIT. + +//go:build !purego +// +build !purego + +#include "textflag.h" + +// func ValidString(s string) bool +// Requires: AVX, AVX2, SSE4.1 +TEXT ·ValidString(SB), NOSPLIT, $0-17 + MOVQ s_base+0(FP), AX + MOVQ s_len+8(FP), CX + MOVQ $0x8080808080808080, DX + CMPQ CX, $0x10 + JB cmp8 + BTL $0x08, github·com∕segmentio∕asm∕cpu·X86+0(SB) + JCS init_avx + +cmp8: + CMPQ CX, $0x08 + JB cmp4 + TESTQ DX, (AX) + JNZ invalid + ADDQ $0x08, AX + SUBQ $0x08, CX + JMP cmp8 + +cmp4: + CMPQ CX, $0x04 + JB cmp3 + TESTL $0x80808080, (AX) + JNZ invalid + ADDQ $0x04, AX + SUBQ $0x04, CX + +cmp3: + CMPQ CX, $0x03 + JB cmp2 + MOVWLZX (AX), CX + MOVBLZX 2(AX), AX + SHLL $0x10, AX + ORL CX, AX + TESTL $0x80808080, AX + JMP done + +cmp2: + CMPQ CX, $0x02 + JB cmp1 + TESTW $0x8080, (AX) + JMP done + +cmp1: + CMPQ CX, $0x00 + JE done + TESTB $0x80, (AX) + +done: + SETEQ ret+16(FP) + RET + +invalid: + MOVB $0x00, ret+16(FP) + RET + +init_avx: + PINSRQ $0x00, DX, X4 + VPBROADCASTQ X4, Y4 + +cmp256: + CMPQ CX, $0x00000100 + JB cmp128 + VMOVDQU (AX), Y0 + VPOR 32(AX), Y0, Y0 + VMOVDQU 64(AX), Y1 + VPOR 96(AX), Y1, Y1 + VMOVDQU 128(AX), Y2 + VPOR 160(AX), Y2, Y2 + VMOVDQU 192(AX), Y3 + VPOR 224(AX), Y3, Y3 + VPOR Y1, Y0, Y0 + VPOR Y3, Y2, Y2 + VPOR Y2, Y0, Y0 + VPTEST Y0, Y4 + JNZ invalid + ADDQ $0x00000100, AX + SUBQ $0x00000100, CX + JMP cmp256 + +cmp128: + CMPQ CX, $0x80 + JB cmp64 + VMOVDQU (AX), Y0 + VPOR 32(AX), Y0, Y0 + VMOVDQU 64(AX), Y1 + VPOR 96(AX), Y1, Y1 + VPOR Y1, Y0, Y0 + VPTEST Y0, Y4 + JNZ invalid + ADDQ $0x80, AX + SUBQ $0x80, CX + +cmp64: + CMPQ CX, $0x40 + JB cmp32 + VMOVDQU (AX), Y0 + VPOR 32(AX), Y0, Y0 + VPTEST Y0, Y4 + JNZ invalid + ADDQ $0x40, AX + SUBQ $0x40, CX + +cmp32: + CMPQ CX, $0x20 + JB cmp16 + VPTEST (AX), Y4 + JNZ invalid + ADDQ $0x20, AX + SUBQ $0x20, CX + +cmp16: + CMPQ CX, $0x10 + JLE cmp_tail + VPTEST (AX), X4 + JNZ invalid + ADDQ $0x10, AX + SUBQ $0x10, CX + +cmp_tail: + SUBQ $0x10, CX + ADDQ CX, AX + VPTEST (AX), X4 + JMP done diff --git a/mcp/dsx-exchange-mcp/vendor/github.com/segmentio/asm/ascii/valid_default.go b/mcp/dsx-exchange-mcp/vendor/github.com/segmentio/asm/ascii/valid_default.go new file mode 100644 index 0000000..715a090 --- /dev/null +++ b/mcp/dsx-exchange-mcp/vendor/github.com/segmentio/asm/ascii/valid_default.go @@ -0,0 +1,48 @@ +//go:build purego || !amd64 +// +build purego !amd64 + +package ascii + +import ( + "unsafe" +) + +// ValidString returns true if s contains only ASCII characters. +func ValidString(s string) bool { + p := *(*unsafe.Pointer)(unsafe.Pointer(&s)) + i := uintptr(0) + n := uintptr(len(s)) + + for i+8 <= n { + if (*(*uint64)(unsafe.Pointer(uintptr(p) + i)) & 0x8080808080808080) != 0 { + return false + } + i += 8 + } + + if i+4 <= n { + if (*(*uint32)(unsafe.Pointer(uintptr(p) + i)) & 0x80808080) != 0 { + return false + } + i += 4 + } + + if i == n { + return true + } + + p = unsafe.Pointer(uintptr(p) + i) + + var x uint32 + switch n - i { + case 3: + x = uint32(*(*uint16)(p)) | uint32(*(*uint8)(unsafe.Pointer(uintptr(p) + 2)))<<16 + case 2: + x = uint32(*(*uint16)(p)) + case 1: + x = uint32(*(*uint8)(p)) + default: + return true + } + return (x & 0x80808080) == 0 +} diff --git a/mcp/dsx-exchange-mcp/vendor/github.com/segmentio/asm/ascii/valid_print.go b/mcp/dsx-exchange-mcp/vendor/github.com/segmentio/asm/ascii/valid_print.go new file mode 100644 index 0000000..aa0db7f --- /dev/null +++ b/mcp/dsx-exchange-mcp/vendor/github.com/segmentio/asm/ascii/valid_print.go @@ -0,0 +1,18 @@ +package ascii + +import "github.com/segmentio/asm/internal/unsafebytes" + +// ValidPrint returns true if b contains only printable ASCII characters. +func ValidPrint(b []byte) bool { + return ValidPrintString(unsafebytes.String(b)) +} + +// ValidPrintBytes returns true if b is an ASCII character. +func ValidPrintByte(b byte) bool { + return 0x20 <= b && b <= 0x7e +} + +// ValidPrintBytes returns true if b is an ASCII character. +func ValidPrintRune(r rune) bool { + return 0x20 <= r && r <= 0x7e +} diff --git a/mcp/dsx-exchange-mcp/vendor/github.com/segmentio/asm/ascii/valid_print_amd64.go b/mcp/dsx-exchange-mcp/vendor/github.com/segmentio/asm/ascii/valid_print_amd64.go new file mode 100644 index 0000000..b146266 --- /dev/null +++ b/mcp/dsx-exchange-mcp/vendor/github.com/segmentio/asm/ascii/valid_print_amd64.go @@ -0,0 +1,9 @@ +// Code generated by command: go run valid_print_asm.go -pkg ascii -out ../ascii/valid_print_amd64.s -stubs ../ascii/valid_print_amd64.go. DO NOT EDIT. + +//go:build !purego +// +build !purego + +package ascii + +// ValidPrintString returns true if s contains only printable ASCII characters. +func ValidPrintString(s string) bool diff --git a/mcp/dsx-exchange-mcp/vendor/github.com/segmentio/asm/ascii/valid_print_amd64.s b/mcp/dsx-exchange-mcp/vendor/github.com/segmentio/asm/ascii/valid_print_amd64.s new file mode 100644 index 0000000..bc2e20a --- /dev/null +++ b/mcp/dsx-exchange-mcp/vendor/github.com/segmentio/asm/ascii/valid_print_amd64.s @@ -0,0 +1,185 @@ +// Code generated by command: go run valid_print_asm.go -pkg ascii -out ../ascii/valid_print_amd64.s -stubs ../ascii/valid_print_amd64.go. DO NOT EDIT. + +//go:build !purego +// +build !purego + +#include "textflag.h" + +// func ValidPrintString(s string) bool +// Requires: AVX, AVX2, SSE4.1 +TEXT ·ValidPrintString(SB), NOSPLIT, $0-17 + MOVQ s_base+0(FP), AX + MOVQ s_len+8(FP), CX + CMPQ CX, $0x10 + JB init_x86 + BTL $0x08, github·com∕segmentio∕asm∕cpu·X86+0(SB) + JCS init_avx + +init_x86: + CMPQ CX, $0x08 + JB cmp4 + MOVQ $0xdfdfdfdfdfdfdfe0, DX + MOVQ $0x0101010101010101, BX + MOVQ $0x8080808080808080, SI + +cmp8: + MOVQ (AX), DI + MOVQ DI, R8 + LEAQ (DI)(DX*1), R9 + NOTQ R8 + ANDQ R8, R9 + LEAQ (DI)(BX*1), R8 + ORQ R8, DI + ORQ R9, DI + ADDQ $0x08, AX + SUBQ $0x08, CX + TESTQ SI, DI + JNE done + CMPQ CX, $0x08 + JB cmp4 + JMP cmp8 + +cmp4: + CMPQ CX, $0x04 + JB cmp3 + MOVL (AX), DX + MOVL DX, BX + LEAL 3755991008(DX), SI + NOTL BX + ANDL BX, SI + LEAL 16843009(DX), BX + ORL BX, DX + ORL SI, DX + ADDQ $0x04, AX + SUBQ $0x04, CX + TESTL $0x80808080, DX + JNE done + +cmp3: + CMPQ CX, $0x03 + JB cmp2 + MOVWLZX (AX), DX + MOVBLZX 2(AX), AX + SHLL $0x10, AX + ORL DX, AX + ORL $0x20000000, AX + JMP final + +cmp2: + CMPQ CX, $0x02 + JB cmp1 + MOVWLZX (AX), AX + ORL $0x20200000, AX + JMP final + +cmp1: + CMPQ CX, $0x00 + JE done + MOVBLZX (AX), AX + ORL $0x20202000, AX + +final: + MOVL AX, CX + LEAL 3755991008(AX), DX + NOTL CX + ANDL CX, DX + LEAL 16843009(AX), CX + ORL CX, AX + ORL DX, AX + TESTL $0x80808080, AX + +done: + SETEQ ret+16(FP) + RET + +init_avx: + MOVB $0x1f, DL + PINSRB $0x00, DX, X8 + VPBROADCASTB X8, Y8 + MOVB $0x7e, DL + PINSRB $0x00, DX, X9 + VPBROADCASTB X9, Y9 + +cmp128: + CMPQ CX, $0x80 + JB cmp64 + VMOVDQU (AX), Y0 + VMOVDQU 32(AX), Y1 + VMOVDQU 64(AX), Y2 + VMOVDQU 96(AX), Y3 + VPCMPGTB Y8, Y0, Y4 + VPCMPGTB Y9, Y0, Y0 + VPANDN Y4, Y0, Y0 + VPCMPGTB Y8, Y1, Y5 + VPCMPGTB Y9, Y1, Y1 + VPANDN Y5, Y1, Y1 + VPCMPGTB Y8, Y2, Y6 + VPCMPGTB Y9, Y2, Y2 + VPANDN Y6, Y2, Y2 + VPCMPGTB Y8, Y3, Y7 + VPCMPGTB Y9, Y3, Y3 + VPANDN Y7, Y3, Y3 + VPAND Y1, Y0, Y0 + VPAND Y3, Y2, Y2 + VPAND Y2, Y0, Y0 + ADDQ $0x80, AX + SUBQ $0x80, CX + VPMOVMSKB Y0, DX + XORL $0xffffffff, DX + JNE done + JMP cmp128 + +cmp64: + CMPQ CX, $0x40 + JB cmp32 + VMOVDQU (AX), Y0 + VMOVDQU 32(AX), Y1 + VPCMPGTB Y8, Y0, Y2 + VPCMPGTB Y9, Y0, Y0 + VPANDN Y2, Y0, Y0 + VPCMPGTB Y8, Y1, Y3 + VPCMPGTB Y9, Y1, Y1 + VPANDN Y3, Y1, Y1 + VPAND Y1, Y0, Y0 + ADDQ $0x40, AX + SUBQ $0x40, CX + VPMOVMSKB Y0, DX + XORL $0xffffffff, DX + JNE done + +cmp32: + CMPQ CX, $0x20 + JB cmp16 + VMOVDQU (AX), Y0 + VPCMPGTB Y8, Y0, Y1 + VPCMPGTB Y9, Y0, Y0 + VPANDN Y1, Y0, Y0 + ADDQ $0x20, AX + SUBQ $0x20, CX + VPMOVMSKB Y0, DX + XORL $0xffffffff, DX + JNE done + +cmp16: + CMPQ CX, $0x10 + JLE cmp_tail + VMOVDQU (AX), X0 + VPCMPGTB X8, X0, X1 + VPCMPGTB X9, X0, X0 + VPANDN X1, X0, X0 + ADDQ $0x10, AX + SUBQ $0x10, CX + VPMOVMSKB X0, DX + XORL $0x0000ffff, DX + JNE done + +cmp_tail: + SUBQ $0x10, CX + ADDQ CX, AX + VMOVDQU (AX), X0 + VPCMPGTB X8, X0, X1 + VPCMPGTB X9, X0, X0 + VPANDN X1, X0, X0 + VPMOVMSKB X0, DX + XORL $0x0000ffff, DX + JMP done diff --git a/mcp/dsx-exchange-mcp/vendor/github.com/segmentio/asm/ascii/valid_print_default.go b/mcp/dsx-exchange-mcp/vendor/github.com/segmentio/asm/ascii/valid_print_default.go new file mode 100644 index 0000000..c4dc748 --- /dev/null +++ b/mcp/dsx-exchange-mcp/vendor/github.com/segmentio/asm/ascii/valid_print_default.go @@ -0,0 +1,46 @@ +//go:build purego || !amd64 +// +build purego !amd64 + +package ascii + +import "unsafe" + +// ValidString returns true if s contains only printable ASCII characters. +func ValidPrintString(s string) bool { + p := *(*unsafe.Pointer)(unsafe.Pointer(&s)) + i := uintptr(0) + n := uintptr(len(s)) + + for i+8 <= n { + if hasLess64(*(*uint64)(unsafe.Pointer(uintptr(p) + i)), 0x20) || hasMore64(*(*uint64)(unsafe.Pointer(uintptr(p) + i)), 0x7e) { + return false + } + i += 8 + } + + if i+4 <= n { + if hasLess32(*(*uint32)(unsafe.Pointer(uintptr(p) + i)), 0x20) || hasMore32(*(*uint32)(unsafe.Pointer(uintptr(p) + i)), 0x7e) { + return false + } + i += 4 + } + + if i == n { + return true + } + + p = unsafe.Pointer(uintptr(p) + i) + + var x uint32 + switch n - i { + case 3: + x = 0x20000000 | uint32(*(*uint16)(p)) | uint32(*(*uint8)(unsafe.Pointer(uintptr(p) + 2)))<<16 + case 2: + x = 0x20200000 | uint32(*(*uint16)(p)) + case 1: + x = 0x20202000 | uint32(*(*uint8)(p)) + default: + return true + } + return !(hasLess32(x, 0x20) || hasMore32(x, 0x7e)) +} diff --git a/mcp/dsx-exchange-mcp/vendor/github.com/segmentio/asm/base64/base64.go b/mcp/dsx-exchange-mcp/vendor/github.com/segmentio/asm/base64/base64.go new file mode 100644 index 0000000..dd2128d --- /dev/null +++ b/mcp/dsx-exchange-mcp/vendor/github.com/segmentio/asm/base64/base64.go @@ -0,0 +1,67 @@ +package base64 + +import ( + "encoding/base64" +) + +const ( + StdPadding rune = base64.StdPadding + NoPadding rune = base64.NoPadding + + encodeStd = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/" + encodeURL = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_" + encodeIMAP = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+," + + letterRange = int8('Z' - 'A' + 1) +) + +// StdEncoding is the standard base64 encoding, as defined in RFC 4648. +var StdEncoding = NewEncoding(encodeStd) + +// URLEncoding is the alternate base64 encoding defined in RFC 4648. +// It is typically used in URLs and file names. +var URLEncoding = NewEncoding(encodeURL) + +// RawStdEncoding is the standard unpadded base64 encoding defined in RFC 4648 section 3.2. +// This is the same as StdEncoding but omits padding characters. +var RawStdEncoding = StdEncoding.WithPadding(NoPadding) + +// RawURLEncoding is the unpadded alternate base64 encoding defined in RFC 4648. +// This is the same as URLEncoding but omits padding characters. +var RawURLEncoding = URLEncoding.WithPadding(NoPadding) + +// NewEncoding returns a new padded Encoding defined by the given alphabet, +// which must be a 64-byte string that does not contain the padding character +// or CR / LF ('\r', '\n'). Unlike the standard library, the encoding alphabet +// cannot be abitrary, and it must follow one of the know standard encoding +// variants. +// +// Required alphabet values: +// * [0,26): characters 'A'..'Z' +// * [26,52): characters 'a'..'z' +// * [52,62): characters '0'..'9' +// Flexible alphabet value options: +// * RFC 4648, RFC 1421, RFC 2045, RFC 2152, RFC 4880: '+' and '/' +// * RFC 4648 URI: '-' and '_' +// * RFC 3501: '+' and ',' +// +// The resulting Encoding uses the default padding character ('='), which may +// be changed or disabled via WithPadding. The padding characters is urestricted, +// but it must be a character outside of the encoder alphabet. +func NewEncoding(encoder string) *Encoding { + if len(encoder) != 64 { + panic("encoding alphabet is not 64-bytes long") + } + + if _, ok := allowedEncoding[encoder]; !ok { + panic("non-standard encoding alphabets are not supported") + } + + return newEncoding(encoder) +} + +var allowedEncoding = map[string]struct{}{ + encodeStd: {}, + encodeURL: {}, + encodeIMAP: {}, +} diff --git a/mcp/dsx-exchange-mcp/vendor/github.com/segmentio/asm/base64/base64_amd64.go b/mcp/dsx-exchange-mcp/vendor/github.com/segmentio/asm/base64/base64_amd64.go new file mode 100644 index 0000000..e4940d7 --- /dev/null +++ b/mcp/dsx-exchange-mcp/vendor/github.com/segmentio/asm/base64/base64_amd64.go @@ -0,0 +1,160 @@ +//go:build amd64 && !purego +// +build amd64,!purego + +package base64 + +import ( + "encoding/base64" + + "github.com/segmentio/asm/cpu" + "github.com/segmentio/asm/cpu/x86" + "github.com/segmentio/asm/internal/unsafebytes" +) + +// An Encoding is a radix 64 encoding/decoding scheme, defined by a +// 64-character alphabet. +type Encoding struct { + enc func(dst []byte, src []byte, lut *int8) (int, int) + enclut [32]int8 + + dec func(dst []byte, src []byte, lut *int8) (int, int) + declut [48]int8 + + base *base64.Encoding +} + +const ( + minEncodeLen = 28 + minDecodeLen = 45 +) + +func newEncoding(encoder string) *Encoding { + e := &Encoding{base: base64.NewEncoding(encoder)} + if cpu.X86.Has(x86.AVX2) { + e.enableEncodeAVX2(encoder) + e.enableDecodeAVX2(encoder) + } + return e +} + +func (e *Encoding) enableEncodeAVX2(encoder string) { + // Translate values 0..63 to the Base64 alphabet. There are five sets: + // + // From To Add Index Example + // [0..25] [65..90] +65 0 ABCDEFGHIJKLMNOPQRSTUVWXYZ + // [26..51] [97..122] +71 1 abcdefghijklmnopqrstuvwxyz + // [52..61] [48..57] -4 [2..11] 0123456789 + // [62] [43] -19 12 + + // [63] [47] -16 13 / + tab := [32]int8{int8(encoder[0]), int8(encoder[letterRange]) - letterRange} + for i, ch := range encoder[2*letterRange:] { + tab[2+i] = int8(ch) - 2*letterRange - int8(i) + } + + e.enc = encodeAVX2 + e.enclut = tab +} + +func (e *Encoding) enableDecodeAVX2(encoder string) { + c62, c63 := int8(encoder[62]), int8(encoder[63]) + url := c63 == '_' + if url { + c63 = '/' + } + + // Translate values from the Base64 alphabet using five sets. Values outside + // of these ranges are considered invalid: + // + // From To Add Index Example + // [47] [63] +16 1 / + // [43] [62] +19 2 + + // [48..57] [52..61] +4 3 0123456789 + // [65..90] [0..25] -65 4,5 ABCDEFGHIJKLMNOPQRSTUVWXYZ + // [97..122] [26..51] -71 6,7 abcdefghijklmnopqrstuvwxyz + tab := [48]int8{ + 0, 63 - c63, 62 - c62, 4, -65, -65, -71, -71, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x15, 0x11, 0x11, 0x11, 0x11, 0x11, 0x11, 0x11, + 0x11, 0x11, 0x13, 0x1B, 0x1B, 0x1B, 0x1B, 0x1B, + } + tab[(c62&15)+16] = 0x1A + tab[(c63&15)+16] = 0x1A + + if url { + e.dec = decodeAVX2URI + } else { + e.dec = decodeAVX2 + } + e.declut = tab +} + +// WithPadding creates a duplicate Encoding updated with a specified padding +// character, or NoPadding to disable padding. The padding character must not +// be contained in the encoding alphabet, must not be '\r' or '\n', and must +// be no greater than '\xFF'. +func (enc Encoding) WithPadding(padding rune) *Encoding { + enc.base = enc.base.WithPadding(padding) + return &enc +} + +// Strict creates a duplicate encoding updated with strict decoding enabled. +// This requires that trailing padding bits are zero. +func (enc Encoding) Strict() *Encoding { + enc.base = enc.base.Strict() + return &enc +} + +// Encode encodes src using the defined encoding alphabet. +// This will write EncodedLen(len(src)) bytes to dst. +func (enc *Encoding) Encode(dst, src []byte) { + if len(src) >= minEncodeLen && enc.enc != nil { + d, s := enc.enc(dst, src, &enc.enclut[0]) + dst = dst[d:] + src = src[s:] + } + enc.base.Encode(dst, src) +} + +// Encode encodes src using the encoding enc, writing +// EncodedLen(len(src)) bytes to dst. +func (enc *Encoding) EncodeToString(src []byte) string { + buf := make([]byte, enc.base.EncodedLen(len(src))) + enc.Encode(buf, src) + return string(buf) +} + +// EncodedLen calculates the base64-encoded byte length for a message +// of length n. +func (enc *Encoding) EncodedLen(n int) int { + return enc.base.EncodedLen(n) +} + +// Decode decodes src using the defined encoding alphabet. +// This will write DecodedLen(len(src)) bytes to dst and return the number of +// bytes written. +func (enc *Encoding) Decode(dst, src []byte) (n int, err error) { + var d, s int + if len(src) >= minDecodeLen && enc.dec != nil { + d, s = enc.dec(dst, src, &enc.declut[0]) + dst = dst[d:] + src = src[s:] + } + n, err = enc.base.Decode(dst, src) + n += d + return +} + +// DecodeString decodes the base64 encoded string s, returns the decoded +// value as bytes. +func (enc *Encoding) DecodeString(s string) ([]byte, error) { + src := unsafebytes.BytesOf(s) + dst := make([]byte, enc.base.DecodedLen(len(s))) + n, err := enc.Decode(dst, src) + return dst[:n], err +} + +// DecodedLen calculates the decoded byte length for a base64-encoded message +// of length n. +func (enc *Encoding) DecodedLen(n int) int { + return enc.base.DecodedLen(n) +} diff --git a/mcp/dsx-exchange-mcp/vendor/github.com/segmentio/asm/base64/base64_default.go b/mcp/dsx-exchange-mcp/vendor/github.com/segmentio/asm/base64/base64_default.go new file mode 100644 index 0000000..f5d3d64 --- /dev/null +++ b/mcp/dsx-exchange-mcp/vendor/github.com/segmentio/asm/base64/base64_default.go @@ -0,0 +1,14 @@ +//go:build purego || !amd64 +// +build purego !amd64 + +package base64 + +import "encoding/base64" + +// An Encoding is a radix 64 encoding/decoding scheme, defined by a +// 64-character alphabet. +type Encoding = base64.Encoding + +func newEncoding(encoder string) *Encoding { + return base64.NewEncoding(encoder) +} diff --git a/mcp/dsx-exchange-mcp/vendor/github.com/segmentio/asm/base64/decode_amd64.go b/mcp/dsx-exchange-mcp/vendor/github.com/segmentio/asm/base64/decode_amd64.go new file mode 100644 index 0000000..1dae5b4 --- /dev/null +++ b/mcp/dsx-exchange-mcp/vendor/github.com/segmentio/asm/base64/decode_amd64.go @@ -0,0 +1,10 @@ +// Code generated by command: go run decode_asm.go -pkg base64 -out ../base64/decode_amd64.s -stubs ../base64/decode_amd64.go. DO NOT EDIT. + +//go:build !purego +// +build !purego + +package base64 + +func decodeAVX2(dst []byte, src []byte, lut *int8) (int, int) + +func decodeAVX2URI(dst []byte, src []byte, lut *int8) (int, int) diff --git a/mcp/dsx-exchange-mcp/vendor/github.com/segmentio/asm/base64/decode_amd64.s b/mcp/dsx-exchange-mcp/vendor/github.com/segmentio/asm/base64/decode_amd64.s new file mode 100644 index 0000000..cc6c779 --- /dev/null +++ b/mcp/dsx-exchange-mcp/vendor/github.com/segmentio/asm/base64/decode_amd64.s @@ -0,0 +1,144 @@ +// Code generated by command: go run decode_asm.go -pkg base64 -out ../base64/decode_amd64.s -stubs ../base64/decode_amd64.go. DO NOT EDIT. + +//go:build !purego +// +build !purego + +#include "textflag.h" + +DATA b64_dec_lut_hi<>+0(SB)/8, $0x0804080402011010 +DATA b64_dec_lut_hi<>+8(SB)/8, $0x1010101010101010 +DATA b64_dec_lut_hi<>+16(SB)/8, $0x0804080402011010 +DATA b64_dec_lut_hi<>+24(SB)/8, $0x1010101010101010 +GLOBL b64_dec_lut_hi<>(SB), RODATA|NOPTR, $32 + +DATA b64_dec_madd1<>+0(SB)/8, $0x0140014001400140 +DATA b64_dec_madd1<>+8(SB)/8, $0x0140014001400140 +DATA b64_dec_madd1<>+16(SB)/8, $0x0140014001400140 +DATA b64_dec_madd1<>+24(SB)/8, $0x0140014001400140 +GLOBL b64_dec_madd1<>(SB), RODATA|NOPTR, $32 + +DATA b64_dec_madd2<>+0(SB)/8, $0x0001100000011000 +DATA b64_dec_madd2<>+8(SB)/8, $0x0001100000011000 +DATA b64_dec_madd2<>+16(SB)/8, $0x0001100000011000 +DATA b64_dec_madd2<>+24(SB)/8, $0x0001100000011000 +GLOBL b64_dec_madd2<>(SB), RODATA|NOPTR, $32 + +DATA b64_dec_shuf_lo<>+0(SB)/8, $0x0000000000000000 +DATA b64_dec_shuf_lo<>+8(SB)/8, $0x0600010200000000 +GLOBL b64_dec_shuf_lo<>(SB), RODATA|NOPTR, $16 + +DATA b64_dec_shuf<>+0(SB)/8, $0x090a040506000102 +DATA b64_dec_shuf<>+8(SB)/8, $0x000000000c0d0e08 +DATA b64_dec_shuf<>+16(SB)/8, $0x0c0d0e08090a0405 +DATA b64_dec_shuf<>+24(SB)/8, $0x0000000000000000 +GLOBL b64_dec_shuf<>(SB), RODATA|NOPTR, $32 + +// func decodeAVX2(dst []byte, src []byte, lut *int8) (int, int) +// Requires: AVX, AVX2, SSE4.1 +TEXT ·decodeAVX2(SB), NOSPLIT, $0-72 + MOVQ dst_base+0(FP), AX + MOVQ src_base+24(FP), DX + MOVQ lut+48(FP), SI + MOVQ src_len+32(FP), DI + MOVB $0x2f, CL + PINSRB $0x00, CX, X8 + VPBROADCASTB X8, Y8 + XORQ CX, CX + XORQ BX, BX + VPXOR Y7, Y7, Y7 + VPERMQ $0x44, (SI), Y6 + VPERMQ $0x44, 16(SI), Y4 + VMOVDQA b64_dec_lut_hi<>+0(SB), Y5 + +loop: + VMOVDQU (DX)(BX*1), Y0 + VPSRLD $0x04, Y0, Y2 + VPAND Y8, Y0, Y3 + VPSHUFB Y3, Y4, Y3 + VPAND Y8, Y2, Y2 + VPSHUFB Y2, Y5, Y9 + VPTEST Y9, Y3 + JNE done + VPCMPEQB Y8, Y0, Y3 + VPADDB Y3, Y2, Y2 + VPSHUFB Y2, Y6, Y2 + VPADDB Y0, Y2, Y0 + VPMADDUBSW b64_dec_madd1<>+0(SB), Y0, Y0 + VPMADDWD b64_dec_madd2<>+0(SB), Y0, Y0 + VEXTRACTI128 $0x01, Y0, X1 + VPSHUFB b64_dec_shuf_lo<>+0(SB), X1, X1 + VPSHUFB b64_dec_shuf<>+0(SB), Y0, Y0 + VPBLENDD $0x08, Y1, Y0, Y1 + VPBLENDD $0xc0, Y7, Y1, Y1 + VMOVDQU Y1, (AX)(CX*1) + ADDQ $0x18, CX + ADDQ $0x20, BX + SUBQ $0x20, DI + CMPQ DI, $0x2d + JB done + JMP loop + +done: + MOVQ CX, ret+56(FP) + MOVQ BX, ret1+64(FP) + VZEROUPPER + RET + +// func decodeAVX2URI(dst []byte, src []byte, lut *int8) (int, int) +// Requires: AVX, AVX2, SSE4.1 +TEXT ·decodeAVX2URI(SB), NOSPLIT, $0-72 + MOVB $0x2f, AL + PINSRB $0x00, AX, X0 + VPBROADCASTB X0, Y0 + MOVB $0x5f, AL + PINSRB $0x00, AX, X1 + VPBROADCASTB X1, Y1 + MOVQ dst_base+0(FP), AX + MOVQ src_base+24(FP), DX + MOVQ lut+48(FP), SI + MOVQ src_len+32(FP), DI + MOVB $0x2f, CL + PINSRB $0x00, CX, X10 + VPBROADCASTB X10, Y10 + XORQ CX, CX + XORQ BX, BX + VPXOR Y9, Y9, Y9 + VPERMQ $0x44, (SI), Y8 + VPERMQ $0x44, 16(SI), Y6 + VMOVDQA b64_dec_lut_hi<>+0(SB), Y7 + +loop: + VMOVDQU (DX)(BX*1), Y2 + VPCMPEQB Y2, Y1, Y4 + VPBLENDVB Y4, Y0, Y2, Y2 + VPSRLD $0x04, Y2, Y4 + VPAND Y10, Y2, Y5 + VPSHUFB Y5, Y6, Y5 + VPAND Y10, Y4, Y4 + VPSHUFB Y4, Y7, Y11 + VPTEST Y11, Y5 + JNE done + VPCMPEQB Y10, Y2, Y5 + VPADDB Y5, Y4, Y4 + VPSHUFB Y4, Y8, Y4 + VPADDB Y2, Y4, Y2 + VPMADDUBSW b64_dec_madd1<>+0(SB), Y2, Y2 + VPMADDWD b64_dec_madd2<>+0(SB), Y2, Y2 + VEXTRACTI128 $0x01, Y2, X3 + VPSHUFB b64_dec_shuf_lo<>+0(SB), X3, X3 + VPSHUFB b64_dec_shuf<>+0(SB), Y2, Y2 + VPBLENDD $0x08, Y3, Y2, Y3 + VPBLENDD $0xc0, Y9, Y3, Y3 + VMOVDQU Y3, (AX)(CX*1) + ADDQ $0x18, CX + ADDQ $0x20, BX + SUBQ $0x20, DI + CMPQ DI, $0x2d + JB done + JMP loop + +done: + MOVQ CX, ret+56(FP) + MOVQ BX, ret1+64(FP) + VZEROUPPER + RET diff --git a/mcp/dsx-exchange-mcp/vendor/github.com/segmentio/asm/base64/encode_amd64.go b/mcp/dsx-exchange-mcp/vendor/github.com/segmentio/asm/base64/encode_amd64.go new file mode 100644 index 0000000..c38060f --- /dev/null +++ b/mcp/dsx-exchange-mcp/vendor/github.com/segmentio/asm/base64/encode_amd64.go @@ -0,0 +1,8 @@ +// Code generated by command: go run encode_asm.go -pkg base64 -out ../base64/encode_amd64.s -stubs ../base64/encode_amd64.go. DO NOT EDIT. + +//go:build !purego +// +build !purego + +package base64 + +func encodeAVX2(dst []byte, src []byte, lut *int8) (int, int) diff --git a/mcp/dsx-exchange-mcp/vendor/github.com/segmentio/asm/base64/encode_amd64.s b/mcp/dsx-exchange-mcp/vendor/github.com/segmentio/asm/base64/encode_amd64.s new file mode 100644 index 0000000..2edd27a --- /dev/null +++ b/mcp/dsx-exchange-mcp/vendor/github.com/segmentio/asm/base64/encode_amd64.s @@ -0,0 +1,88 @@ +// Code generated by command: go run encode_asm.go -pkg base64 -out ../base64/encode_amd64.s -stubs ../base64/encode_amd64.go. DO NOT EDIT. + +//go:build !purego +// +build !purego + +#include "textflag.h" + +// func encodeAVX2(dst []byte, src []byte, lut *int8) (int, int) +// Requires: AVX, AVX2, SSE4.1 +TEXT ·encodeAVX2(SB), NOSPLIT, $0-72 + MOVQ dst_base+0(FP), AX + MOVQ src_base+24(FP), DX + MOVQ lut+48(FP), SI + MOVQ src_len+32(FP), DI + MOVB $0x33, CL + PINSRB $0x00, CX, X4 + VPBROADCASTB X4, Y4 + MOVB $0x19, CL + PINSRB $0x00, CX, X5 + VPBROADCASTB X5, Y5 + XORQ CX, CX + XORQ BX, BX + + // Load the 16-byte LUT into both lanes of the register + VPERMQ $0x44, (SI), Y3 + + // Load the first block using a mask to avoid potential fault + VMOVDQU b64_enc_load<>+0(SB), Y0 + VPMASKMOVD -4(DX)(BX*1), Y0, Y0 + +loop: + VPSHUFB b64_enc_shuf<>+0(SB), Y0, Y0 + VPAND b64_enc_mask1<>+0(SB), Y0, Y1 + VPSLLW $0x08, Y1, Y2 + VPSLLW $0x04, Y1, Y1 + VPBLENDW $0xaa, Y2, Y1, Y2 + VPAND b64_enc_mask2<>+0(SB), Y0, Y1 + VPMULHUW b64_enc_mult<>+0(SB), Y1, Y0 + VPOR Y0, Y2, Y0 + VPSUBUSB Y4, Y0, Y1 + VPCMPGTB Y5, Y0, Y2 + VPSUBB Y2, Y1, Y1 + VPSHUFB Y1, Y3, Y1 + VPADDB Y0, Y1, Y0 + VMOVDQU Y0, (AX)(CX*1) + ADDQ $0x20, CX + ADDQ $0x18, BX + SUBQ $0x18, DI + CMPQ DI, $0x20 + JB done + VMOVDQU -4(DX)(BX*1), Y0 + JMP loop + +done: + MOVQ CX, ret+56(FP) + MOVQ BX, ret1+64(FP) + VZEROUPPER + RET + +DATA b64_enc_load<>+0(SB)/8, $0x8000000000000000 +DATA b64_enc_load<>+8(SB)/8, $0x8000000080000000 +DATA b64_enc_load<>+16(SB)/8, $0x8000000080000000 +DATA b64_enc_load<>+24(SB)/8, $0x8000000080000000 +GLOBL b64_enc_load<>(SB), RODATA|NOPTR, $32 + +DATA b64_enc_shuf<>+0(SB)/8, $0x0809070805060405 +DATA b64_enc_shuf<>+8(SB)/8, $0x0e0f0d0e0b0c0a0b +DATA b64_enc_shuf<>+16(SB)/8, $0x0405030401020001 +DATA b64_enc_shuf<>+24(SB)/8, $0x0a0b090a07080607 +GLOBL b64_enc_shuf<>(SB), RODATA|NOPTR, $32 + +DATA b64_enc_mask1<>+0(SB)/8, $0x003f03f0003f03f0 +DATA b64_enc_mask1<>+8(SB)/8, $0x003f03f0003f03f0 +DATA b64_enc_mask1<>+16(SB)/8, $0x003f03f0003f03f0 +DATA b64_enc_mask1<>+24(SB)/8, $0x003f03f0003f03f0 +GLOBL b64_enc_mask1<>(SB), RODATA|NOPTR, $32 + +DATA b64_enc_mask2<>+0(SB)/8, $0x0fc0fc000fc0fc00 +DATA b64_enc_mask2<>+8(SB)/8, $0x0fc0fc000fc0fc00 +DATA b64_enc_mask2<>+16(SB)/8, $0x0fc0fc000fc0fc00 +DATA b64_enc_mask2<>+24(SB)/8, $0x0fc0fc000fc0fc00 +GLOBL b64_enc_mask2<>(SB), RODATA|NOPTR, $32 + +DATA b64_enc_mult<>+0(SB)/8, $0x0400004004000040 +DATA b64_enc_mult<>+8(SB)/8, $0x0400004004000040 +DATA b64_enc_mult<>+16(SB)/8, $0x0400004004000040 +DATA b64_enc_mult<>+24(SB)/8, $0x0400004004000040 +GLOBL b64_enc_mult<>(SB), RODATA|NOPTR, $32 diff --git a/mcp/dsx-exchange-mcp/vendor/github.com/segmentio/asm/cpu/arm/arm.go b/mcp/dsx-exchange-mcp/vendor/github.com/segmentio/asm/cpu/arm/arm.go new file mode 100644 index 0000000..47c695a --- /dev/null +++ b/mcp/dsx-exchange-mcp/vendor/github.com/segmentio/asm/cpu/arm/arm.go @@ -0,0 +1,80 @@ +package arm + +import ( + "github.com/segmentio/asm/cpu/cpuid" + . "golang.org/x/sys/cpu" +) + +type CPU cpuid.CPU + +func (cpu CPU) Has(feature Feature) bool { + return cpuid.CPU(cpu).Has(cpuid.Feature(feature)) +} + +func (cpu *CPU) set(feature Feature, enable bool) { + (*cpuid.CPU)(cpu).Set(cpuid.Feature(feature), enable) +} + +type Feature cpuid.Feature + +const ( + SWP Feature = 1 << iota // SWP instruction support + HALF // Half-word load and store support + THUMB // ARM Thumb instruction set + BIT26 // Address space limited to 26-bits + FASTMUL // 32-bit operand, 64-bit result multiplication support + FPA // Floating point arithmetic support + VFP // Vector floating point support + EDSP // DSP Extensions support + JAVA // Java instruction set + IWMMXT // Intel Wireless MMX technology support + CRUNCH // MaverickCrunch context switching and handling + THUMBEE // Thumb EE instruction set + NEON // NEON instruction set + VFPv3 // Vector floating point version 3 support + VFPv3D16 // Vector floating point version 3 D8-D15 + TLS // Thread local storage support + VFPv4 // Vector floating point version 4 support + IDIVA // Integer divide instruction support in ARM mode + IDIVT // Integer divide instruction support in Thumb mode + VFPD32 // Vector floating point version 3 D15-D31 + LPAE // Large Physical Address Extensions + EVTSTRM // Event stream support + AES // AES hardware implementation + PMULL // Polynomial multiplication instruction set + SHA1 // SHA1 hardware implementation + SHA2 // SHA2 hardware implementation + CRC32 // CRC32 hardware implementation +) + +func ABI() CPU { + cpu := CPU(0) + cpu.set(SWP, ARM.HasSWP) + cpu.set(HALF, ARM.HasHALF) + cpu.set(THUMB, ARM.HasTHUMB) + cpu.set(BIT26, ARM.Has26BIT) + cpu.set(FASTMUL, ARM.HasFASTMUL) + cpu.set(FPA, ARM.HasFPA) + cpu.set(VFP, ARM.HasVFP) + cpu.set(EDSP, ARM.HasEDSP) + cpu.set(JAVA, ARM.HasJAVA) + cpu.set(IWMMXT, ARM.HasIWMMXT) + cpu.set(CRUNCH, ARM.HasCRUNCH) + cpu.set(THUMBEE, ARM.HasTHUMBEE) + cpu.set(NEON, ARM.HasNEON) + cpu.set(VFPv3, ARM.HasVFPv3) + cpu.set(VFPv3D16, ARM.HasVFPv3D16) + cpu.set(TLS, ARM.HasTLS) + cpu.set(VFPv4, ARM.HasVFPv4) + cpu.set(IDIVA, ARM.HasIDIVA) + cpu.set(IDIVT, ARM.HasIDIVT) + cpu.set(VFPD32, ARM.HasVFPD32) + cpu.set(LPAE, ARM.HasLPAE) + cpu.set(EVTSTRM, ARM.HasEVTSTRM) + cpu.set(AES, ARM.HasAES) + cpu.set(PMULL, ARM.HasPMULL) + cpu.set(SHA1, ARM.HasSHA1) + cpu.set(SHA2, ARM.HasSHA2) + cpu.set(CRC32, ARM.HasCRC32) + return cpu +} diff --git a/mcp/dsx-exchange-mcp/vendor/github.com/segmentio/asm/cpu/arm64/arm64.go b/mcp/dsx-exchange-mcp/vendor/github.com/segmentio/asm/cpu/arm64/arm64.go new file mode 100644 index 0000000..0c5134c --- /dev/null +++ b/mcp/dsx-exchange-mcp/vendor/github.com/segmentio/asm/cpu/arm64/arm64.go @@ -0,0 +1,74 @@ +package arm64 + +import ( + "github.com/segmentio/asm/cpu/cpuid" + . "golang.org/x/sys/cpu" +) + +type CPU cpuid.CPU + +func (cpu CPU) Has(feature Feature) bool { + return cpuid.CPU(cpu).Has(cpuid.Feature(feature)) +} + +func (cpu *CPU) set(feature Feature, enable bool) { + (*cpuid.CPU)(cpu).Set(cpuid.Feature(feature), enable) +} + +type Feature cpuid.Feature + +const ( + FP Feature = 1 << iota // Floating-point instruction set (always available) + ASIMD // Advanced SIMD (always available) + EVTSTRM // Event stream support + AES // AES hardware implementation + PMULL // Polynomial multiplication instruction set + SHA1 // SHA1 hardware implementation + SHA2 // SHA2 hardware implementation + CRC32 // CRC32 hardware implementation + ATOMICS // Atomic memory operation instruction set + FPHP // Half precision floating-point instruction set + ASIMDHP // Advanced SIMD half precision instruction set + CPUID // CPUID identification scheme registers + ASIMDRDM // Rounding double multiply add/subtract instruction set + JSCVT // Javascript conversion from floating-point to integer + FCMA // Floating-point multiplication and addition of complex numbers + LRCPC // Release Consistent processor consistent support + DCPOP // Persistent memory support + SHA3 // SHA3 hardware implementation + SM3 // SM3 hardware implementation + SM4 // SM4 hardware implementation + ASIMDDP // Advanced SIMD double precision instruction set + SHA512 // SHA512 hardware implementation + SVE // Scalable Vector Extensions + ASIMDFHM // Advanced SIMD multiplication FP16 to FP32 +) + +func ABI() CPU { + cpu := CPU(0) + cpu.set(FP, ARM64.HasFP) + cpu.set(ASIMD, ARM64.HasASIMD) + cpu.set(EVTSTRM, ARM64.HasEVTSTRM) + cpu.set(AES, ARM64.HasAES) + cpu.set(PMULL, ARM64.HasPMULL) + cpu.set(SHA1, ARM64.HasSHA1) + cpu.set(SHA2, ARM64.HasSHA2) + cpu.set(CRC32, ARM64.HasCRC32) + cpu.set(ATOMICS, ARM64.HasATOMICS) + cpu.set(FPHP, ARM64.HasFPHP) + cpu.set(ASIMDHP, ARM64.HasASIMDHP) + cpu.set(CPUID, ARM64.HasCPUID) + cpu.set(ASIMDRDM, ARM64.HasASIMDRDM) + cpu.set(JSCVT, ARM64.HasJSCVT) + cpu.set(FCMA, ARM64.HasFCMA) + cpu.set(LRCPC, ARM64.HasLRCPC) + cpu.set(DCPOP, ARM64.HasDCPOP) + cpu.set(SHA3, ARM64.HasSHA3) + cpu.set(SM3, ARM64.HasSM3) + cpu.set(SM4, ARM64.HasSM4) + cpu.set(ASIMDDP, ARM64.HasASIMDDP) + cpu.set(SHA512, ARM64.HasSHA512) + cpu.set(SVE, ARM64.HasSVE) + cpu.set(ASIMDFHM, ARM64.HasASIMDFHM) + return cpu +} diff --git a/mcp/dsx-exchange-mcp/vendor/github.com/segmentio/asm/cpu/cpu.go b/mcp/dsx-exchange-mcp/vendor/github.com/segmentio/asm/cpu/cpu.go new file mode 100644 index 0000000..6ddf497 --- /dev/null +++ b/mcp/dsx-exchange-mcp/vendor/github.com/segmentio/asm/cpu/cpu.go @@ -0,0 +1,22 @@ +// Pakage cpu provides APIs to detect CPU features available at runtime. +package cpu + +import ( + "github.com/segmentio/asm/cpu/arm" + "github.com/segmentio/asm/cpu/arm64" + "github.com/segmentio/asm/cpu/x86" +) + +var ( + // X86 is the bitset representing the set of the x86 instruction sets are + // supported by the CPU. + X86 = x86.ABI() + + // ARM is the bitset representing which parts of the arm instruction sets + // are supported by the CPU. + ARM = arm.ABI() + + // ARM64 is the bitset representing which parts of the arm64 instruction + // sets are supported by the CPU. + ARM64 = arm64.ABI() +) diff --git a/mcp/dsx-exchange-mcp/vendor/github.com/segmentio/asm/cpu/cpuid/cpuid.go b/mcp/dsx-exchange-mcp/vendor/github.com/segmentio/asm/cpu/cpuid/cpuid.go new file mode 100644 index 0000000..0949d3d --- /dev/null +++ b/mcp/dsx-exchange-mcp/vendor/github.com/segmentio/asm/cpu/cpuid/cpuid.go @@ -0,0 +1,32 @@ +// Package cpuid provides generic types used to represent CPU features supported +// by the architecture. +package cpuid + +// CPU is a bitset of feature flags representing the capabilities of various CPU +// architeectures that this package provides optimized assembly routines for. +// +// The intent is to provide a stable ABI between the Go code that generate the +// assembly, and the program that uses the library functions. +type CPU uint64 + +// Feature represents a single CPU feature. +type Feature uint64 + +const ( + // None is a Feature value that has no CPU features enabled. + None Feature = 0 + // All is a Feature value that has all CPU features enabled. + All Feature = 0xFFFFFFFFFFFFFFFF +) + +func (cpu CPU) Has(feature Feature) bool { + return (Feature(cpu) & feature) == feature +} + +func (cpu *CPU) Set(feature Feature, enabled bool) { + if enabled { + *cpu |= CPU(feature) + } else { + *cpu &= ^CPU(feature) + } +} diff --git a/mcp/dsx-exchange-mcp/vendor/github.com/segmentio/asm/cpu/x86/x86.go b/mcp/dsx-exchange-mcp/vendor/github.com/segmentio/asm/cpu/x86/x86.go new file mode 100644 index 0000000..9e93537 --- /dev/null +++ b/mcp/dsx-exchange-mcp/vendor/github.com/segmentio/asm/cpu/x86/x86.go @@ -0,0 +1,76 @@ +package x86 + +import ( + "github.com/segmentio/asm/cpu/cpuid" + . "golang.org/x/sys/cpu" +) + +type CPU cpuid.CPU + +func (cpu CPU) Has(feature Feature) bool { + return cpuid.CPU(cpu).Has(cpuid.Feature(feature)) +} + +func (cpu *CPU) set(feature Feature, enable bool) { + (*cpuid.CPU)(cpu).Set(cpuid.Feature(feature), enable) +} + +type Feature cpuid.Feature + +const ( + SSE Feature = 1 << iota // SSE functions + SSE2 // P4 SSE functions + SSE3 // Prescott SSE3 functions + SSE41 // Penryn SSE4.1 functions + SSE42 // Nehalem SSE4.2 functions + SSE4A // AMD Barcelona microarchitecture SSE4a instructions + SSSE3 // Conroe SSSE3 functions + AVX // AVX functions + AVX2 // AVX2 functions + AVX512BF16 // AVX-512 BFLOAT16 Instructions + AVX512BITALG // AVX-512 Bit Algorithms + AVX512BW // AVX-512 Byte and Word Instructions + AVX512CD // AVX-512 Conflict Detection Instructions + AVX512DQ // AVX-512 Doubleword and Quadword Instructions + AVX512ER // AVX-512 Exponential and Reciprocal Instructions + AVX512F // AVX-512 Foundation + AVX512IFMA // AVX-512 Integer Fused Multiply-Add Instructions + AVX512PF // AVX-512 Prefetch Instructions + AVX512VBMI // AVX-512 Vector Bit Manipulation Instructions + AVX512VBMI2 // AVX-512 Vector Bit Manipulation Instructions, Version 2 + AVX512VL // AVX-512 Vector Length Extensions + AVX512VNNI // AVX-512 Vector Neural Network Instructions + AVX512VP2INTERSECT // AVX-512 Intersect for D/Q + AVX512VPOPCNTDQ // AVX-512 Vector Population Count Doubleword and Quadword + CMOV // Conditional move +) + +func ABI() CPU { + cpu := CPU(0) + cpu.set(SSE, true) // TODO: golang.org/x/sys/cpu assumes all CPUs have SEE? + cpu.set(SSE2, X86.HasSSE2) + cpu.set(SSE3, X86.HasSSE3) + cpu.set(SSE41, X86.HasSSE41) + cpu.set(SSE42, X86.HasSSE42) + cpu.set(SSE4A, false) // TODO: add upstream support in golang.org/x/sys/cpu? + cpu.set(SSSE3, X86.HasSSSE3) + cpu.set(AVX, X86.HasAVX) + cpu.set(AVX2, X86.HasAVX2) + cpu.set(AVX512BF16, X86.HasAVX512BF16) + cpu.set(AVX512BITALG, X86.HasAVX512BITALG) + cpu.set(AVX512BW, X86.HasAVX512BW) + cpu.set(AVX512CD, X86.HasAVX512CD) + cpu.set(AVX512DQ, X86.HasAVX512DQ) + cpu.set(AVX512ER, X86.HasAVX512ER) + cpu.set(AVX512F, X86.HasAVX512F) + cpu.set(AVX512IFMA, X86.HasAVX512IFMA) + cpu.set(AVX512PF, X86.HasAVX512PF) + cpu.set(AVX512VBMI, X86.HasAVX512VBMI) + cpu.set(AVX512VBMI2, X86.HasAVX512VBMI2) + cpu.set(AVX512VL, X86.HasAVX512VL) + cpu.set(AVX512VNNI, X86.HasAVX512VNNI) + cpu.set(AVX512VP2INTERSECT, false) // TODO: add upstream support in golang.org/x/sys/cpu? + cpu.set(AVX512VPOPCNTDQ, X86.HasAVX512VPOPCNTDQ) + cpu.set(CMOV, true) // TODO: golang.org/x/sys/cpu assumes all CPUs have CMOV? + return cpu +} diff --git a/mcp/dsx-exchange-mcp/vendor/github.com/segmentio/asm/internal/unsafebytes/unsafebytes.go b/mcp/dsx-exchange-mcp/vendor/github.com/segmentio/asm/internal/unsafebytes/unsafebytes.go new file mode 100644 index 0000000..913c9cc --- /dev/null +++ b/mcp/dsx-exchange-mcp/vendor/github.com/segmentio/asm/internal/unsafebytes/unsafebytes.go @@ -0,0 +1,20 @@ +package unsafebytes + +import "unsafe" + +func Pointer(b []byte) *byte { + return *(**byte)(unsafe.Pointer(&b)) +} + +func String(b []byte) string { + return *(*string)(unsafe.Pointer(&b)) +} + +func BytesOf(s string) []byte { + return *(*[]byte)(unsafe.Pointer(&sliceHeader{str: s, cap: len(s)})) +} + +type sliceHeader struct { + str string + cap int +} diff --git a/mcp/dsx-exchange-mcp/vendor/github.com/segmentio/asm/keyset/keyset.go b/mcp/dsx-exchange-mcp/vendor/github.com/segmentio/asm/keyset/keyset.go new file mode 100644 index 0000000..1943c5f --- /dev/null +++ b/mcp/dsx-exchange-mcp/vendor/github.com/segmentio/asm/keyset/keyset.go @@ -0,0 +1,40 @@ +package keyset + +import ( + "bytes" + + "github.com/segmentio/asm/cpu" + "github.com/segmentio/asm/cpu/arm64" + "github.com/segmentio/asm/cpu/x86" +) + +// New prepares a set of keys for use with Lookup. +// +// An optimized routine is used if the processor supports AVX instructions and +// the maximum length of any of the keys is less than or equal to 16. If New +// returns nil, this indicates that an optimized routine is not available, and +// the caller should use a fallback. +func New(keys [][]byte) []byte { + maxWidth, hasNullByte := checkKeys(keys) + if hasNullByte || maxWidth > 16 || !(cpu.X86.Has(x86.AVX) || cpu.ARM64.Has(arm64.ASIMD)) { + return nil + } + + set := make([]byte, len(keys)*16) + for i, k := range keys { + copy(set[i*16:], k) + } + return set +} + +func checkKeys(keys [][]byte) (maxWidth int, hasNullByte bool) { + for _, k := range keys { + if len(k) > maxWidth { + maxWidth = len(k) + } + if bytes.IndexByte(k, 0) >= 0 { + hasNullByte = true + } + } + return +} diff --git a/mcp/dsx-exchange-mcp/vendor/github.com/segmentio/asm/keyset/keyset_amd64.go b/mcp/dsx-exchange-mcp/vendor/github.com/segmentio/asm/keyset/keyset_amd64.go new file mode 100644 index 0000000..9554ee6 --- /dev/null +++ b/mcp/dsx-exchange-mcp/vendor/github.com/segmentio/asm/keyset/keyset_amd64.go @@ -0,0 +1,10 @@ +// Code generated by command: go run keyset_asm.go -pkg keyset -out ../keyset/keyset_amd64.s -stubs ../keyset/keyset_amd64.go. DO NOT EDIT. + +//go:build !purego +// +build !purego + +package keyset + +// Lookup searches for a key in a set of keys, returning its index if +// found. If the key cannot be found, the number of keys is returned. +func Lookup(keyset []byte, key []byte) int diff --git a/mcp/dsx-exchange-mcp/vendor/github.com/segmentio/asm/keyset/keyset_amd64.s b/mcp/dsx-exchange-mcp/vendor/github.com/segmentio/asm/keyset/keyset_amd64.s new file mode 100644 index 0000000..e27d2c4 --- /dev/null +++ b/mcp/dsx-exchange-mcp/vendor/github.com/segmentio/asm/keyset/keyset_amd64.s @@ -0,0 +1,108 @@ +// Code generated by command: go run keyset_asm.go -pkg keyset -out ../keyset/keyset_amd64.s -stubs ../keyset/keyset_amd64.go. DO NOT EDIT. + +//go:build !purego +// +build !purego + +#include "textflag.h" + +// func Lookup(keyset []byte, key []byte) int +// Requires: AVX +TEXT ·Lookup(SB), NOSPLIT, $0-56 + MOVQ keyset_base+0(FP), AX + MOVQ keyset_len+8(FP), CX + SHRQ $0x04, CX + MOVQ key_base+24(FP), DX + MOVQ key_len+32(FP), BX + MOVQ key_cap+40(FP), SI + CMPQ BX, $0x10 + JA not_found + CMPQ SI, $0x10 + JB safe_load + +load: + VMOVUPS (DX), X0 + +prepare: + VPXOR X2, X2, X2 + VPCMPEQB X1, X1, X1 + LEAQ blend_masks<>+16(SB), DX + SUBQ BX, DX + VMOVUPS (DX), X3 + VPBLENDVB X3, X0, X2, X0 + XORQ DX, DX + MOVQ CX, BX + SHRQ $0x02, BX + SHLQ $0x02, BX + +bigloop: + CMPQ DX, BX + JE loop + VPCMPEQB (AX), X0, X8 + VPTEST X1, X8 + JCS done + VPCMPEQB 16(AX), X0, X9 + VPTEST X1, X9 + JCS found1 + VPCMPEQB 32(AX), X0, X10 + VPTEST X1, X10 + JCS found2 + VPCMPEQB 48(AX), X0, X11 + VPTEST X1, X11 + JCS found3 + ADDQ $0x04, DX + ADDQ $0x40, AX + JMP bigloop + +loop: + CMPQ DX, CX + JE done + VPCMPEQB (AX), X0, X2 + VPTEST X1, X2 + JCS done + INCQ DX + ADDQ $0x10, AX + JMP loop + JMP done + +found3: + INCQ DX + +found2: + INCQ DX + +found1: + INCQ DX + +done: + MOVQ DX, ret+48(FP) + RET + +not_found: + MOVQ CX, ret+48(FP) + RET + +safe_load: + MOVQ DX, SI + ANDQ $0x00000fff, SI + CMPQ SI, $0x00000ff0 + JBE load + MOVQ $0xfffffffffffffff0, SI + ADDQ BX, SI + VMOVUPS (DX)(SI*1), X0 + LEAQ shuffle_masks<>+16(SB), DX + SUBQ BX, DX + VMOVUPS (DX), X1 + VPSHUFB X1, X0, X0 + JMP prepare + +DATA blend_masks<>+0(SB)/8, $0xffffffffffffffff +DATA blend_masks<>+8(SB)/8, $0xffffffffffffffff +DATA blend_masks<>+16(SB)/8, $0x0000000000000000 +DATA blend_masks<>+24(SB)/8, $0x0000000000000000 +GLOBL blend_masks<>(SB), RODATA|NOPTR, $32 + +DATA shuffle_masks<>+0(SB)/8, $0x0706050403020100 +DATA shuffle_masks<>+8(SB)/8, $0x0f0e0d0c0b0a0908 +DATA shuffle_masks<>+16(SB)/8, $0x0706050403020100 +DATA shuffle_masks<>+24(SB)/8, $0x0f0e0d0c0b0a0908 +GLOBL shuffle_masks<>(SB), RODATA|NOPTR, $32 diff --git a/mcp/dsx-exchange-mcp/vendor/github.com/segmentio/asm/keyset/keyset_arm64.go b/mcp/dsx-exchange-mcp/vendor/github.com/segmentio/asm/keyset/keyset_arm64.go new file mode 100644 index 0000000..feafabe --- /dev/null +++ b/mcp/dsx-exchange-mcp/vendor/github.com/segmentio/asm/keyset/keyset_arm64.go @@ -0,0 +1,8 @@ +//go:build !purego +// +build !purego + +package keyset + +// Lookup searches for a key in a set of keys, returning its index if +// found. If the key cannot be found, the number of keys is returned. +func Lookup(keyset []byte, key []byte) int diff --git a/mcp/dsx-exchange-mcp/vendor/github.com/segmentio/asm/keyset/keyset_arm64.s b/mcp/dsx-exchange-mcp/vendor/github.com/segmentio/asm/keyset/keyset_arm64.s new file mode 100644 index 0000000..20acb99 --- /dev/null +++ b/mcp/dsx-exchange-mcp/vendor/github.com/segmentio/asm/keyset/keyset_arm64.s @@ -0,0 +1,143 @@ +//go:build !purego +// +build !purego + +#include "textflag.h" + +// func Lookup(keyset []byte, key []byte) int +TEXT ·Lookup(SB), NOSPLIT, $0-56 + MOVD keyset+0(FP), R0 + MOVD keyset_len+8(FP), R1 + MOVD key+24(FP), R2 + MOVD key_len+32(FP), R3 + MOVD key_cap+40(FP), R4 + + // None of the keys in the set are greater than 16 bytes, so if the input + // key is we can jump straight to not found. + CMP $16, R3 + BHI notfound + + // We'll be moving the keyset pointer (R0) forward as we compare keys, so + // make a copy of the starting point (R6). Also add the byte length (R1) to + // obtain a pointer to the end of the keyset (R5). + MOVD R0, R6 + ADD R0, R1, R5 + + // Prepare a 64-bit mask of all ones. + MOVD $-1, R7 + + // Prepare a vector of all zeroes. + VMOV ZR, V1.B16 + + // Check that it's safe to load 16 bytes of input. If cap(input)<16, jump + // to a check that determines whether a tail load is necessary (to avoid a + // page fault). + CMP $16, R4 + BLO safeload + +load: + // Load the input key (V0) and pad with zero bytes (V1). To blend the two + // vectors, we load a mask for the particular key length and then use TBL + // to select bytes from either V0 or V1. + VLD1 (R2), [V0.B16] + MOVD $blend_masks<>(SB), R10 + ADD R3<<4, R10, R10 + VLD1 (R10), [V2.B16] + VTBL V2.B16, [V0.B16, V1.B16], V3.B16 + +loop: + // Loop through each 16 byte key in the keyset. + CMP R0, R5 + BEQ notfound + + // Load and compare the next key. + VLD1.P 16(R0), [V4.B16] + VCMEQ V3.B16, V4.B16, V5.B16 + VMOV V5.D[0], R8 + VMOV V5.D[1], R9 + AND R8, R9, R9 + + // If the masks match, we found the key. + CMP R9, R7 + BEQ found + JMP loop + +found: + // If the key was found, take the position in the keyset and convert it + // to an index. The keyset pointer (R0) will be 1 key past the match, so + // subtract the starting pointer (R6), divide by 16 to convert from byte + // length to an index, and then subtract one. + SUB R6, R0, R0 + ADD R0>>4, ZR, R0 + SUB $1, R0, R0 + MOVD R0, ret+48(FP) + RET + +notfound: + // Return the number of keys in the keyset, which is the byte length (R1) + // divided by 16. + ADD R1>>4, ZR, R1 + MOVD R1, ret+48(FP) + RET + +safeload: + // Check if the input crosses a page boundary. If not, jump back. + AND $4095, R2, R12 + CMP $4080, R12 + BLS load + + // If it does cross a page boundary, we must assume that loading 16 bytes + // will cause a fault. Instead, we load the 16 bytes up to and including the + // key and then shuffle the key forward in the register. We can shuffle and + // pad with zeroes at the same time to avoid having to also blend (as load + // does). + MOVD $16, R12 + SUB R3, R12, R12 + SUB R12, R2, R2 + VLD1 (R2), [V0.B16] + MOVD $shuffle_masks<>(SB), R10 + ADD R12, R10, R10 + VLD1 (R10), [V2.B16] + VTBL V2.B16, [V0.B16, V1.B16], V3.B16 + JMP loop + +DATA blend_masks<>+0(SB)/8, $0x1010101010101010 +DATA blend_masks<>+8(SB)/8, $0x1010101010101010 +DATA blend_masks<>+16(SB)/8, $0x1010101010101000 +DATA blend_masks<>+24(SB)/8, $0x1010101010101010 +DATA blend_masks<>+32(SB)/8, $0x1010101010100100 +DATA blend_masks<>+40(SB)/8, $0x1010101010101010 +DATA blend_masks<>+48(SB)/8, $0x1010101010020100 +DATA blend_masks<>+56(SB)/8, $0x1010101010101010 +DATA blend_masks<>+64(SB)/8, $0x1010101003020100 +DATA blend_masks<>+72(SB)/8, $0x1010101010101010 +DATA blend_masks<>+80(SB)/8, $0x1010100403020100 +DATA blend_masks<>+88(SB)/8, $0x1010101010101010 +DATA blend_masks<>+96(SB)/8, $0x1010050403020100 +DATA blend_masks<>+104(SB)/8, $0x1010101010101010 +DATA blend_masks<>+112(SB)/8, $0x1006050403020100 +DATA blend_masks<>+120(SB)/8, $0x1010101010101010 +DATA blend_masks<>+128(SB)/8, $0x0706050403020100 +DATA blend_masks<>+136(SB)/8, $0x1010101010101010 +DATA blend_masks<>+144(SB)/8, $0x0706050403020100 +DATA blend_masks<>+152(SB)/8, $0x1010101010101008 +DATA blend_masks<>+160(SB)/8, $0x0706050403020100 +DATA blend_masks<>+168(SB)/8, $0x1010101010100908 +DATA blend_masks<>+176(SB)/8, $0x0706050403020100 +DATA blend_masks<>+184(SB)/8, $0x10101010100A0908 +DATA blend_masks<>+192(SB)/8, $0x0706050403020100 +DATA blend_masks<>+200(SB)/8, $0x101010100B0A0908 +DATA blend_masks<>+208(SB)/8, $0x0706050403020100 +DATA blend_masks<>+216(SB)/8, $0x1010100C0B0A0908 +DATA blend_masks<>+224(SB)/8, $0x0706050403020100 +DATA blend_masks<>+232(SB)/8, $0x10100D0C0B0A0908 +DATA blend_masks<>+240(SB)/8, $0x0706050403020100 +DATA blend_masks<>+248(SB)/8, $0x100E0D0C0B0A0908 +DATA blend_masks<>+256(SB)/8, $0x0706050403020100 +DATA blend_masks<>+264(SB)/8, $0x0F0E0D0C0B0A0908 +GLOBL blend_masks<>(SB), RODATA|NOPTR, $272 + +DATA shuffle_masks<>+0(SB)/8, $0x0706050403020100 +DATA shuffle_masks<>+8(SB)/8, $0x0F0E0D0C0B0A0908 +DATA shuffle_masks<>+16(SB)/8, $0x1010101010101010 +DATA shuffle_masks<>+24(SB)/8, $0x1010101010101010 +GLOBL shuffle_masks<>(SB), RODATA|NOPTR, $32 diff --git a/mcp/dsx-exchange-mcp/vendor/github.com/segmentio/asm/keyset/keyset_default.go b/mcp/dsx-exchange-mcp/vendor/github.com/segmentio/asm/keyset/keyset_default.go new file mode 100644 index 0000000..1fa7d3f --- /dev/null +++ b/mcp/dsx-exchange-mcp/vendor/github.com/segmentio/asm/keyset/keyset_default.go @@ -0,0 +1,19 @@ +//go:build purego || !(amd64 || arm64) +// +build purego !amd64,!arm64 + +package keyset + +func Lookup(keyset []byte, key []byte) int { + if len(key) > 16 { + return len(keyset) / 16 + } + var padded [16]byte + copy(padded[:], key) + + for i := 0; i < len(keyset); i += 16 { + if string(padded[:]) == string(keyset[i:i+16]) { + return i / 16 + } + } + return len(keyset) / 16 +} diff --git a/mcp/dsx-exchange-mcp/vendor/github.com/segmentio/encoding/LICENSE b/mcp/dsx-exchange-mcp/vendor/github.com/segmentio/encoding/LICENSE new file mode 100644 index 0000000..1fbffdf --- /dev/null +++ b/mcp/dsx-exchange-mcp/vendor/github.com/segmentio/encoding/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2019 Segment.io, Inc. + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/mcp/dsx-exchange-mcp/vendor/github.com/segmentio/encoding/ascii/equal_fold.go b/mcp/dsx-exchange-mcp/vendor/github.com/segmentio/encoding/ascii/equal_fold.go new file mode 100644 index 0000000..4207f17 --- /dev/null +++ b/mcp/dsx-exchange-mcp/vendor/github.com/segmentio/encoding/ascii/equal_fold.go @@ -0,0 +1,40 @@ +//go:generate go run equal_fold_asm.go -out equal_fold_amd64.s -stubs equal_fold_amd64.go +package ascii + +import ( + "github.com/segmentio/asm/ascii" +) + +// EqualFold is a version of bytes.EqualFold designed to work on ASCII input +// instead of UTF-8. +// +// When the program has guarantees that the input is composed of ASCII +// characters only, it allows for greater optimizations. +func EqualFold(a, b []byte) bool { + return ascii.EqualFold(a, b) +} + +func HasPrefixFold(s, prefix []byte) bool { + return ascii.HasPrefixFold(s, prefix) +} + +func HasSuffixFold(s, suffix []byte) bool { + return ascii.HasSuffixFold(s, suffix) +} + +// EqualFoldString is a version of strings.EqualFold designed to work on ASCII +// input instead of UTF-8. +// +// When the program has guarantees that the input is composed of ASCII +// characters only, it allows for greater optimizations. +func EqualFoldString(a, b string) bool { + return ascii.EqualFoldString(a, b) +} + +func HasPrefixFoldString(s, prefix string) bool { + return ascii.HasPrefixFoldString(s, prefix) +} + +func HasSuffixFoldString(s, suffix string) bool { + return ascii.HasSuffixFoldString(s, suffix) +} diff --git a/mcp/dsx-exchange-mcp/vendor/github.com/segmentio/encoding/ascii/valid.go b/mcp/dsx-exchange-mcp/vendor/github.com/segmentio/encoding/ascii/valid.go new file mode 100644 index 0000000..68b7c6c --- /dev/null +++ b/mcp/dsx-exchange-mcp/vendor/github.com/segmentio/encoding/ascii/valid.go @@ -0,0 +1,26 @@ +//go:generate go run valid_asm.go -out valid_amd64.s -stubs valid_amd64.go +package ascii + +import ( + "github.com/segmentio/asm/ascii" +) + +// Valid returns true if b contains only ASCII characters. +func Valid(b []byte) bool { + return ascii.Valid(b) +} + +// ValidBytes returns true if b is an ASCII character. +func ValidByte(b byte) bool { + return ascii.ValidByte(b) +} + +// ValidBytes returns true if b is an ASCII character. +func ValidRune(r rune) bool { + return ascii.ValidRune(r) +} + +// ValidString returns true if s contains only ASCII characters. +func ValidString(s string) bool { + return ascii.ValidString(s) +} diff --git a/mcp/dsx-exchange-mcp/vendor/github.com/segmentio/encoding/ascii/valid_print.go b/mcp/dsx-exchange-mcp/vendor/github.com/segmentio/encoding/ascii/valid_print.go new file mode 100644 index 0000000..241f584 --- /dev/null +++ b/mcp/dsx-exchange-mcp/vendor/github.com/segmentio/encoding/ascii/valid_print.go @@ -0,0 +1,26 @@ +//go:generate go run valid_print_asm.go -out valid_print_amd64.s -stubs valid_print_amd64.go +package ascii + +import ( + "github.com/segmentio/asm/ascii" +) + +// Valid returns true if b contains only printable ASCII characters. +func ValidPrint(b []byte) bool { + return ascii.ValidPrint(b) +} + +// ValidBytes returns true if b is an ASCII character. +func ValidPrintByte(b byte) bool { + return ascii.ValidPrintByte(b) +} + +// ValidBytes returns true if b is an ASCII character. +func ValidPrintRune(r rune) bool { + return ascii.ValidPrintRune(r) +} + +// ValidString returns true if s contains only printable ASCII characters. +func ValidPrintString(s string) bool { + return ascii.ValidPrintString(s) +} diff --git a/mcp/dsx-exchange-mcp/vendor/github.com/segmentio/encoding/iso8601/parse.go b/mcp/dsx-exchange-mcp/vendor/github.com/segmentio/encoding/iso8601/parse.go new file mode 100644 index 0000000..6fbe5dc --- /dev/null +++ b/mcp/dsx-exchange-mcp/vendor/github.com/segmentio/encoding/iso8601/parse.go @@ -0,0 +1,185 @@ +package iso8601 + +import ( + "encoding/binary" + "errors" + "time" + "unsafe" +) + +var ( + errInvalidTimestamp = errors.New("invalid ISO8601 timestamp") + errMonthOutOfRange = errors.New("month out of range") + errDayOutOfRange = errors.New("day out of range") + errHourOutOfRange = errors.New("hour out of range") + errMinuteOutOfRange = errors.New("minute out of range") + errSecondOutOfRange = errors.New("second out of range") +) + +// Parse parses an ISO8601 timestamp, e.g. "2021-03-25T21:36:12Z". +func Parse(input string) (time.Time, error) { + b := unsafeStringToBytes(input) + if len(b) >= 20 && len(b) <= 30 && b[len(b)-1] == 'Z' { + if len(b) == 21 || (len(b) > 21 && b[19] != '.') { + return time.Time{}, errInvalidTimestamp + } + + t1 := binary.LittleEndian.Uint64(b) + t2 := binary.LittleEndian.Uint64(b[8:16]) + t3 := uint64(b[16]) | uint64(b[17])<<8 | uint64(b[18])<<16 | uint64('Z')<<24 + + // Check for valid separators by masking input with " - - T : : Z". + // If separators are all valid, replace them with a '0' (0x30) byte and + // check all bytes are now numeric. + if !match(t1, mask1) || !match(t2, mask2) || !match(t3, mask3) { + return time.Time{}, errInvalidTimestamp + } + t1 ^= replace1 + t2 ^= replace2 + t3 ^= replace3 + if (nonNumeric(t1) | nonNumeric(t2) | nonNumeric(t3)) != 0 { + return time.Time{}, errInvalidTimestamp + } + + t1 -= zero + t2 -= zero + t3 -= zero + year := (t1&0xF)*1000 + (t1>>8&0xF)*100 + (t1>>16&0xF)*10 + (t1 >> 24 & 0xF) + month := (t1>>40&0xF)*10 + (t1 >> 48 & 0xF) + day := (t2&0xF)*10 + (t2 >> 8 & 0xF) + hour := (t2>>24&0xF)*10 + (t2 >> 32 & 0xF) + minute := (t2>>48&0xF)*10 + (t2 >> 56) + second := (t3>>8&0xF)*10 + (t3 >> 16) + + nanos := int64(0) + if len(b) > 20 { + for _, c := range b[20 : len(b)-1] { + if c < '0' || c > '9' { + return time.Time{}, errInvalidTimestamp + } + nanos = (nanos * 10) + int64(c-'0') + } + nanos *= pow10[30-len(b)] + } + + if err := validate(year, month, day, hour, minute, second); err != nil { + return time.Time{}, err + } + + unixSeconds := int64(daysSinceEpoch(year, month, day))*86400 + int64(hour*3600+minute*60+second) + return time.Unix(unixSeconds, nanos).UTC(), nil + } + + // Fallback to using time.Parse(). + t, err := time.Parse(time.RFC3339Nano, input) + if err != nil { + // Override (and don't wrap) the error here. The error returned by + // time.Parse() is dynamic, and includes a reference to the input + // string. By overriding the error, we guarantee that the input string + // doesn't escape. + return time.Time{}, errInvalidTimestamp + } + return t, nil +} + +var pow10 = []int64{1, 10, 100, 1000, 1e4, 1e5, 1e6, 1e7, 1e8} + +const ( + mask1 = 0x2d00002d00000000 // YYYY-MM- + mask2 = 0x00003a0000540000 // DDTHH:MM + mask3 = 0x000000005a00003a // :SSZ____ + + // Generate masks that replace the separators with a numeric byte. + // The input must have valid separators. XOR with the separator bytes + // to zero them out and then XOR with 0x30 to replace them with '0'. + replace1 = mask1 ^ 0x3000003000000000 + replace2 = mask2 ^ 0x0000300000300000 + replace3 = mask3 ^ 0x3030303030000030 + + lsb = ^uint64(0) / 255 + msb = lsb * 0x80 + + zero = lsb * '0' + nine = lsb * '9' +) + +func validate(year, month, day, hour, minute, second uint64) error { + if day == 0 || day > 31 { + return errDayOutOfRange + } + if month == 0 || month > 12 { + return errMonthOutOfRange + } + if hour >= 24 { + return errHourOutOfRange + } + if minute >= 60 { + return errMinuteOutOfRange + } + if second >= 60 { + return errSecondOutOfRange + } + if month == 2 && (day > 29 || (day == 29 && !isLeapYear(year))) { + return errDayOutOfRange + } + if day == 31 { + switch month { + case 4, 6, 9, 11: + return errDayOutOfRange + } + } + return nil +} + +func match(u, mask uint64) bool { + return (u & mask) == mask +} + +func nonNumeric(u uint64) uint64 { + // Derived from https://graphics.stanford.edu/~seander/bithacks.html#HasLessInWord. + // Subtract '0' (0x30) from each byte so that the MSB is set in each byte + // if there's a byte less than '0' (0x30). Add 0x46 (0x7F-'9') so that the + // MSB is set if there's a byte greater than '9' (0x39). To handle overflow + // when adding 0x46, include the MSB from the input bytes in the final mask. + // Remove all but the MSBs and then you're left with a mask where each + // non-numeric byte from the input has its MSB set in the output. + return ((u - zero) | (u + (^msb - nine)) | u) & msb +} + +func daysSinceEpoch(year, month, day uint64) uint64 { + // Derived from https://blog.reverberate.org/2020/05/12/optimizing-date-algorithms.html. + monthAdjusted := month - 3 + var carry uint64 + if monthAdjusted > month { + carry = 1 + } + var adjust uint64 + if carry == 1 { + adjust = 12 + } + yearAdjusted := year + 4800 - carry + monthDays := ((monthAdjusted+adjust)*62719 + 769) / 2048 + leapDays := yearAdjusted/4 - yearAdjusted/100 + yearAdjusted/400 + return yearAdjusted*365 + leapDays + monthDays + (day - 1) - 2472632 +} + +func isLeapYear(y uint64) bool { + return (y%4) == 0 && ((y%100) != 0 || (y%400) == 0) +} + +func unsafeStringToBytes(s string) []byte { + return *(*[]byte)(unsafe.Pointer(&sliceHeader{ + Data: *(*unsafe.Pointer)(unsafe.Pointer(&s)), + Len: len(s), + Cap: len(s), + })) +} + +// sliceHeader is like reflect.SliceHeader but the Data field is a +// unsafe.Pointer instead of being a uintptr to avoid invalid +// conversions from uintptr to unsafe.Pointer. +type sliceHeader struct { + Data unsafe.Pointer + Len int + Cap int +} diff --git a/mcp/dsx-exchange-mcp/vendor/github.com/segmentio/encoding/iso8601/valid.go b/mcp/dsx-exchange-mcp/vendor/github.com/segmentio/encoding/iso8601/valid.go new file mode 100644 index 0000000..187b4ef --- /dev/null +++ b/mcp/dsx-exchange-mcp/vendor/github.com/segmentio/encoding/iso8601/valid.go @@ -0,0 +1,179 @@ +package iso8601 + +// ValidFlags is a bitset type used to configure the behavior of the Valid +// function. +type ValidFlags int + +const ( + // Strict is a validation flag used to represent a string iso8601 validation + // (this is the default). + Strict ValidFlags = 0 + + // AllowSpaceSeparator allows the presence of a space instead of a 'T' as + // separator between the date and time. + AllowSpaceSeparator ValidFlags = 1 << iota + + // AllowMissingTime allows the value to contain only a date. + AllowMissingTime + + // AllowMissingSubsecond allows the value to contain only a date and time. + AllowMissingSubsecond + + // AllowMissingTimezone allows the value to be missing the timezone + // information. + AllowMissingTimezone + + // AllowNumericTimezone allows the value to represent timezones in their + // numeric form. + AllowNumericTimezone + + // Flexible is a combination of all validation flag that allow for + // non-strict checking of the input value. + Flexible = AllowSpaceSeparator | AllowMissingTime | AllowMissingSubsecond | AllowMissingTimezone | AllowNumericTimezone +) + +// Valid check value to verify whether or not it is a valid iso8601 time +// representation. +func Valid(value string, flags ValidFlags) bool { + var ok bool + + // year + if value, ok = readDigits(value, 4, 4); !ok { + return false + } + + if value, ok = readByte(value, '-'); !ok { + return false + } + + // month + if value, ok = readDigits(value, 2, 2); !ok { + return false + } + + if value, ok = readByte(value, '-'); !ok { + return false + } + + // day + if value, ok = readDigits(value, 2, 2); !ok { + return false + } + + if len(value) == 0 && (flags&AllowMissingTime) != 0 { + return true // date only + } + + // separator + if value, ok = readByte(value, 'T'); !ok { + if (flags & AllowSpaceSeparator) == 0 { + return false + } + if value, ok = readByte(value, ' '); !ok { + return false + } + } + + // hour + if value, ok = readDigits(value, 2, 2); !ok { + return false + } + + if value, ok = readByte(value, ':'); !ok { + return false + } + + // minute + if value, ok = readDigits(value, 2, 2); !ok { + return false + } + + if value, ok = readByte(value, ':'); !ok { + return false + } + + // second + if value, ok = readDigits(value, 2, 2); !ok { + return false + } + + // microsecond + if value, ok = readByte(value, '.'); !ok { + if (flags & AllowMissingSubsecond) == 0 { + return false + } + } else { + if value, ok = readDigits(value, 1, 9); !ok { + return false + } + } + + if len(value) == 0 && (flags&AllowMissingTimezone) != 0 { + return true // date and time + } + + // timezone + if value, ok = readByte(value, 'Z'); ok { + return len(value) == 0 + } + + if (flags & AllowSpaceSeparator) != 0 { + value, _ = readByte(value, ' ') + } + + if value, ok = readByte(value, '+'); !ok { + if value, ok = readByte(value, '-'); !ok { + return false + } + } + + // timezone hour + if value, ok = readDigits(value, 2, 2); !ok { + return false + } + + if value, ok = readByte(value, ':'); !ok { + if (flags & AllowNumericTimezone) == 0 { + return false + } + } + + // timezone minute + if value, ok = readDigits(value, 2, 2); !ok { + return false + } + + return len(value) == 0 +} + +func readDigits(value string, min, max int) (string, bool) { + if len(value) < min { + return value, false + } + + i := 0 + + for i < max && i < len(value) && isDigit(value[i]) { + i++ + } + + if i < max && i < min { + return value, false + } + + return value[i:], true +} + +func readByte(value string, c byte) (string, bool) { + if len(value) == 0 { + return value, false + } + if value[0] != c { + return value, false + } + return value[1:], true +} + +func isDigit(c byte) bool { + return '0' <= c && c <= '9' +} diff --git a/mcp/dsx-exchange-mcp/vendor/github.com/segmentio/encoding/json/README.md b/mcp/dsx-exchange-mcp/vendor/github.com/segmentio/encoding/json/README.md new file mode 100644 index 0000000..c5ed94b --- /dev/null +++ b/mcp/dsx-exchange-mcp/vendor/github.com/segmentio/encoding/json/README.md @@ -0,0 +1,76 @@ +# encoding/json [![GoDoc](https://godoc.org/github.com/segmentio/encoding/json?status.svg)](https://godoc.org/github.com/segmentio/encoding/json) + +Go package offering a replacement implementation of the standard library's +[`encoding/json`](https://golang.org/pkg/encoding/json/) package, with much +better performance. + +## Usage + +The exported API of this package mirrors the standard library's +[`encoding/json`](https://golang.org/pkg/encoding/json/) package, the only +change needed to take advantage of the performance improvements is the import +path of the `json` package, from: +```go +import ( + "encoding/json" +) +``` +to +```go +import ( + "github.com/segmentio/encoding/json" +) +``` + +One way to gain higher encoding throughput is to disable HTML escaping. +It allows the string encoding to use a much more efficient code path which +does not require parsing UTF-8 runes most of the time. + +## Performance Improvements + +The internal implementation uses a fair amount of unsafe operations (untyped +code, pointer arithmetic, etc...) to avoid using reflection as much as possible, +which is often the reason why serialization code has a large CPU and memory +footprint. + +The package aims for zero unnecessary dynamic memory allocations and hot code +paths that are mostly free from calls into the reflect package. + +## Compatibility with encoding/json + +This package aims to be a drop-in replacement, therefore it is tested to behave +exactly like the standard library's package. However, there are still a few +missing features that have not been ported yet: + +- Streaming decoder, currently the `Decoder` implementation offered by the +package does not support progressively reading values from a JSON array (unlike +the standard library). In our experience this is a very rare use-case, if you +need it you're better off sticking to the standard library, or spend a bit of +time implementing it in here ;) + +Note that none of those features should result in performance degradations if +they were implemented in the package, and we welcome contributions! + +## Trade-offs + +As one would expect, we had to make a couple of trade-offs to achieve greater +performance than the standard library, but there were also features that we +did not want to give away. + +Other open-source packages offering a reduced CPU and memory footprint usually +do so by designing a different API, or require code generation (therefore adding +complexity to the build process). These were not acceptable conditions for us, +as we were not willing to trade off developer productivity for better runtime +performance. To achieve this, we chose to exactly replicate the standard +library interfaces and behavior, which meant the package implementation was the +only area that we were able to work with. The internals of this package make +heavy use of unsafe pointer arithmetics and other performance optimizations, +and therefore are not as approachable as typical Go programs. Basically, we put +a bigger burden on maintainers to achieve better runtime cost without +sacrificing developer productivity. + +For these reasons, we also don't believe that this code should be ported upstream +to the standard `encoding/json` package. The standard library has to remain +readable and approachable to maximize stability and maintainability, and make +projects like this one possible because a high quality reference implementation +already exists. diff --git a/mcp/dsx-exchange-mcp/vendor/github.com/segmentio/encoding/json/codec.go b/mcp/dsx-exchange-mcp/vendor/github.com/segmentio/encoding/json/codec.go new file mode 100644 index 0000000..77fe264 --- /dev/null +++ b/mcp/dsx-exchange-mcp/vendor/github.com/segmentio/encoding/json/codec.go @@ -0,0 +1,1240 @@ +package json + +import ( + "encoding" + "encoding/json" + "fmt" + "maps" + "math/big" + "reflect" + "sort" + "strconv" + "strings" + "sync/atomic" + "time" + "unicode" + "unsafe" + + "github.com/segmentio/asm/keyset" +) + +const ( + // 1000 is the value used by the standard encoding/json package. + // + // https://cs.opensource.google/go/go/+/refs/tags/go1.17.3:src/encoding/json/encode.go;drc=refs%2Ftags%2Fgo1.17.3;l=300 + startDetectingCyclesAfter = 1000 +) + +type codec struct { + encode encodeFunc + decode decodeFunc +} + +type encoder struct { + flags AppendFlags + // ptrDepth tracks the depth of pointer cycles, when it reaches the value + // of startDetectingCyclesAfter, the ptrSeen map is allocated and the + // encoder starts tracking pointers it has seen as an attempt to detect + // whether it has entered a pointer cycle and needs to error before the + // goroutine runs out of stack space. + ptrDepth uint32 + ptrSeen map[unsafe.Pointer]struct{} +} + +type decoder struct { + flags ParseFlags +} + +type ( + encodeFunc func(encoder, []byte, unsafe.Pointer) ([]byte, error) + decodeFunc func(decoder, []byte, unsafe.Pointer) ([]byte, error) +) + +type ( + emptyFunc func(unsafe.Pointer) bool + sortFunc func([]reflect.Value) +) + +// Eventually consistent cache mapping go types to dynamically generated +// codecs. +// +// Note: using a uintptr as key instead of reflect.Type shaved ~15ns off of +// the ~30ns Marhsal/Unmarshal functions which were dominated by the map +// lookup time for simple types like bool, int, etc.. +var cache atomic.Pointer[map[unsafe.Pointer]codec] + +func cacheLoad() map[unsafe.Pointer]codec { + p := cache.Load() + if p == nil { + return nil + } + + return *p +} + +func cacheStore(typ reflect.Type, cod codec, oldCodecs map[unsafe.Pointer]codec) { + newCodecs := make(map[unsafe.Pointer]codec, len(oldCodecs)+1) + maps.Copy(newCodecs, oldCodecs) + newCodecs[typeid(typ)] = cod + + cache.Store(&newCodecs) +} + +func typeid(t reflect.Type) unsafe.Pointer { + return (*iface)(unsafe.Pointer(&t)).ptr +} + +func constructCachedCodec(t reflect.Type, cache map[unsafe.Pointer]codec) codec { + c := constructCodec(t, map[reflect.Type]*structType{}, t.Kind() == reflect.Ptr) + + if inlined(t) { + c.encode = constructInlineValueEncodeFunc(c.encode) + } + + cacheStore(t, c, cache) + return c +} + +func constructCodec(t reflect.Type, seen map[reflect.Type]*structType, canAddr bool) (c codec) { + switch t { + case nullType, nil: + c = codec{encode: encoder.encodeNull, decode: decoder.decodeNull} + + case numberType: + c = codec{encode: encoder.encodeNumber, decode: decoder.decodeNumber} + + case bytesType: + c = codec{encode: encoder.encodeBytes, decode: decoder.decodeBytes} + + case durationType: + c = codec{encode: encoder.encodeDuration, decode: decoder.decodeDuration} + + case timeType: + c = codec{encode: encoder.encodeTime, decode: decoder.decodeTime} + + case interfaceType: + c = codec{encode: encoder.encodeInterface, decode: decoder.decodeInterface} + + case rawMessageType: + c = codec{encode: encoder.encodeRawMessage, decode: decoder.decodeRawMessage} + + case numberPtrType: + c = constructPointerCodec(numberPtrType, nil) + + case durationPtrType: + c = constructPointerCodec(durationPtrType, nil) + + case timePtrType: + c = constructPointerCodec(timePtrType, nil) + + case rawMessagePtrType: + c = constructPointerCodec(rawMessagePtrType, nil) + } + + if c.encode != nil { + return + } + + switch t.Kind() { + case reflect.Bool: + c = codec{encode: encoder.encodeBool, decode: decoder.decodeBool} + + case reflect.Int: + c = codec{encode: encoder.encodeInt, decode: decoder.decodeInt} + + case reflect.Int8: + c = codec{encode: encoder.encodeInt8, decode: decoder.decodeInt8} + + case reflect.Int16: + c = codec{encode: encoder.encodeInt16, decode: decoder.decodeInt16} + + case reflect.Int32: + c = codec{encode: encoder.encodeInt32, decode: decoder.decodeInt32} + + case reflect.Int64: + c = codec{encode: encoder.encodeInt64, decode: decoder.decodeInt64} + + case reflect.Uint: + c = codec{encode: encoder.encodeUint, decode: decoder.decodeUint} + + case reflect.Uintptr: + c = codec{encode: encoder.encodeUintptr, decode: decoder.decodeUintptr} + + case reflect.Uint8: + c = codec{encode: encoder.encodeUint8, decode: decoder.decodeUint8} + + case reflect.Uint16: + c = codec{encode: encoder.encodeUint16, decode: decoder.decodeUint16} + + case reflect.Uint32: + c = codec{encode: encoder.encodeUint32, decode: decoder.decodeUint32} + + case reflect.Uint64: + c = codec{encode: encoder.encodeUint64, decode: decoder.decodeUint64} + + case reflect.Float32: + c = codec{encode: encoder.encodeFloat32, decode: decoder.decodeFloat32} + + case reflect.Float64: + c = codec{encode: encoder.encodeFloat64, decode: decoder.decodeFloat64} + + case reflect.String: + c = codec{encode: encoder.encodeString, decode: decoder.decodeString} + + case reflect.Interface: + c = constructInterfaceCodec(t) + + case reflect.Array: + c = constructArrayCodec(t, seen, canAddr) + + case reflect.Slice: + c = constructSliceCodec(t, seen) + + case reflect.Map: + c = constructMapCodec(t, seen) + + case reflect.Struct: + c = constructStructCodec(t, seen, canAddr) + + case reflect.Ptr: + c = constructPointerCodec(t, seen) + + default: + c = constructUnsupportedTypeCodec(t) + } + + p := reflect.PointerTo(t) + + if canAddr { + switch { + case p.Implements(jsonMarshalerType): + c.encode = constructJSONMarshalerEncodeFunc(t, true) + case p.Implements(textMarshalerType): + c.encode = constructTextMarshalerEncodeFunc(t, true) + } + } + + switch { + case t.Implements(jsonMarshalerType): + c.encode = constructJSONMarshalerEncodeFunc(t, false) + case t.Implements(textMarshalerType): + c.encode = constructTextMarshalerEncodeFunc(t, false) + } + + switch { + case p.Implements(jsonUnmarshalerType): + c.decode = constructJSONUnmarshalerDecodeFunc(t, true) + case p.Implements(textUnmarshalerType): + c.decode = constructTextUnmarshalerDecodeFunc(t, true) + } + + return +} + +func constructStringCodec(t reflect.Type, seen map[reflect.Type]*structType, canAddr bool) codec { + c := constructCodec(t, seen, canAddr) + return codec{ + encode: constructStringEncodeFunc(c.encode), + decode: constructStringDecodeFunc(c.decode), + } +} + +func constructStringEncodeFunc(encode encodeFunc) encodeFunc { + return func(e encoder, b []byte, p unsafe.Pointer) ([]byte, error) { + return e.encodeToString(b, p, encode) + } +} + +func constructStringDecodeFunc(decode decodeFunc) decodeFunc { + return func(d decoder, b []byte, p unsafe.Pointer) ([]byte, error) { + return d.decodeFromString(b, p, decode) + } +} + +func constructStringToIntDecodeFunc(t reflect.Type, decode decodeFunc) decodeFunc { + return func(d decoder, b []byte, p unsafe.Pointer) ([]byte, error) { + return d.decodeFromStringToInt(b, p, t, decode) + } +} + +func constructArrayCodec(t reflect.Type, seen map[reflect.Type]*structType, canAddr bool) codec { + e := t.Elem() + c := constructCodec(e, seen, canAddr) + s := alignedSize(e) + return codec{ + encode: constructArrayEncodeFunc(s, t, c.encode), + decode: constructArrayDecodeFunc(s, t, c.decode), + } +} + +func constructArrayEncodeFunc(size uintptr, t reflect.Type, encode encodeFunc) encodeFunc { + n := t.Len() + return func(e encoder, b []byte, p unsafe.Pointer) ([]byte, error) { + return e.encodeArray(b, p, n, size, t, encode) + } +} + +func constructArrayDecodeFunc(size uintptr, t reflect.Type, decode decodeFunc) decodeFunc { + n := t.Len() + return func(d decoder, b []byte, p unsafe.Pointer) ([]byte, error) { + return d.decodeArray(b, p, n, size, t, decode) + } +} + +func constructSliceCodec(t reflect.Type, seen map[reflect.Type]*structType) codec { + e := t.Elem() + s := alignedSize(e) + + if e.Kind() == reflect.Uint8 { + // Go 1.7+ behavior: slices of byte types (and aliases) may override the + // default encoding and decoding behaviors by implementing marshaler and + // unmarshaler interfaces. + p := reflect.PointerTo(e) + c := codec{} + + switch { + case e.Implements(jsonMarshalerType): + c.encode = constructJSONMarshalerEncodeFunc(e, false) + case e.Implements(textMarshalerType): + c.encode = constructTextMarshalerEncodeFunc(e, false) + case p.Implements(jsonMarshalerType): + c.encode = constructJSONMarshalerEncodeFunc(e, true) + case p.Implements(textMarshalerType): + c.encode = constructTextMarshalerEncodeFunc(e, true) + } + + switch { + case e.Implements(jsonUnmarshalerType): + c.decode = constructJSONUnmarshalerDecodeFunc(e, false) + case e.Implements(textUnmarshalerType): + c.decode = constructTextUnmarshalerDecodeFunc(e, false) + case p.Implements(jsonUnmarshalerType): + c.decode = constructJSONUnmarshalerDecodeFunc(e, true) + case p.Implements(textUnmarshalerType): + c.decode = constructTextUnmarshalerDecodeFunc(e, true) + } + + if c.encode != nil { + c.encode = constructSliceEncodeFunc(s, t, c.encode) + } else { + c.encode = encoder.encodeBytes + } + + if c.decode != nil { + c.decode = constructSliceDecodeFunc(s, t, c.decode) + } else { + c.decode = decoder.decodeBytes + } + + return c + } + + c := constructCodec(e, seen, true) + return codec{ + encode: constructSliceEncodeFunc(s, t, c.encode), + decode: constructSliceDecodeFunc(s, t, c.decode), + } +} + +func constructSliceEncodeFunc(size uintptr, t reflect.Type, encode encodeFunc) encodeFunc { + return func(e encoder, b []byte, p unsafe.Pointer) ([]byte, error) { + return e.encodeSlice(b, p, size, t, encode) + } +} + +func constructSliceDecodeFunc(size uintptr, t reflect.Type, decode decodeFunc) decodeFunc { + return func(d decoder, b []byte, p unsafe.Pointer) ([]byte, error) { + return d.decodeSlice(b, p, size, t, decode) + } +} + +func constructMapCodec(t reflect.Type, seen map[reflect.Type]*structType) codec { + var sortKeys sortFunc + k := t.Key() + v := t.Elem() + + // Faster implementations for some common cases. + switch { + case k == stringType && v == interfaceType: + return codec{ + encode: encoder.encodeMapStringInterface, + decode: decoder.decodeMapStringInterface, + } + + case k == stringType && v == rawMessageType: + return codec{ + encode: encoder.encodeMapStringRawMessage, + decode: decoder.decodeMapStringRawMessage, + } + + case k == stringType && v == stringType: + return codec{ + encode: encoder.encodeMapStringString, + decode: decoder.decodeMapStringString, + } + + case k == stringType && v == stringsType: + return codec{ + encode: encoder.encodeMapStringStringSlice, + decode: decoder.decodeMapStringStringSlice, + } + + case k == stringType && v == boolType: + return codec{ + encode: encoder.encodeMapStringBool, + decode: decoder.decodeMapStringBool, + } + } + + kc := codec{} + vc := constructCodec(v, seen, false) + + if k.Implements(textMarshalerType) || reflect.PointerTo(k).Implements(textUnmarshalerType) { + kc.encode = constructTextMarshalerEncodeFunc(k, false) + kc.decode = constructTextUnmarshalerDecodeFunc(k, true) + + sortKeys = func(keys []reflect.Value) { + sort.Slice(keys, func(i, j int) bool { + // This is a performance abomination but the use case is rare + // enough that it shouldn't be a problem in practice. + k1, _ := keys[i].Interface().(encoding.TextMarshaler).MarshalText() + k2, _ := keys[j].Interface().(encoding.TextMarshaler).MarshalText() + return string(k1) < string(k2) + }) + } + } else { + switch k.Kind() { + case reflect.String: + kc.encode = encoder.encodeString + kc.decode = decoder.decodeString + + sortKeys = func(keys []reflect.Value) { + sort.Slice(keys, func(i, j int) bool { return keys[i].String() < keys[j].String() }) + } + + case reflect.Int, + reflect.Int8, + reflect.Int16, + reflect.Int32, + reflect.Int64: + kc = constructStringCodec(k, seen, false) + + sortKeys = func(keys []reflect.Value) { + sort.Slice(keys, func(i, j int) bool { return intStringsAreSorted(keys[i].Int(), keys[j].Int()) }) + } + + case reflect.Uint, + reflect.Uintptr, + reflect.Uint8, + reflect.Uint16, + reflect.Uint32, + reflect.Uint64: + kc = constructStringCodec(k, seen, false) + + sortKeys = func(keys []reflect.Value) { + sort.Slice(keys, func(i, j int) bool { return uintStringsAreSorted(keys[i].Uint(), keys[j].Uint()) }) + } + + default: + return constructUnsupportedTypeCodec(t) + } + } + + if inlined(v) { + vc.encode = constructInlineValueEncodeFunc(vc.encode) + } + + return codec{ + encode: constructMapEncodeFunc(t, kc.encode, vc.encode, sortKeys), + decode: constructMapDecodeFunc(t, kc.decode, vc.decode), + } +} + +func constructMapEncodeFunc(t reflect.Type, encodeKey, encodeValue encodeFunc, sortKeys sortFunc) encodeFunc { + return func(e encoder, b []byte, p unsafe.Pointer) ([]byte, error) { + return e.encodeMap(b, p, t, encodeKey, encodeValue, sortKeys) + } +} + +func constructMapDecodeFunc(t reflect.Type, decodeKey, decodeValue decodeFunc) decodeFunc { + kt := t.Key() + vt := t.Elem() + kz := reflect.Zero(kt) + vz := reflect.Zero(vt) + return func(d decoder, b []byte, p unsafe.Pointer) ([]byte, error) { + return d.decodeMap(b, p, t, kt, vt, kz, vz, decodeKey, decodeValue) + } +} + +func constructStructCodec(t reflect.Type, seen map[reflect.Type]*structType, canAddr bool) codec { + st := constructStructType(t, seen, canAddr) + return codec{ + encode: constructStructEncodeFunc(st), + decode: constructStructDecodeFunc(st), + } +} + +func constructStructType(t reflect.Type, seen map[reflect.Type]*structType, canAddr bool) *structType { + // Used for preventing infinite recursion on types that have pointers to + // themselves. + st := seen[t] + + if st == nil { + st = &structType{ + fields: make([]structField, 0, t.NumField()), + fieldsIndex: make(map[string]*structField), + ficaseIndex: make(map[string]*structField), + typ: t, + } + + seen[t] = st + st.fields = appendStructFields(st.fields, t, 0, seen, canAddr) + + for i := range st.fields { + f := &st.fields[i] + s := strings.ToLower(f.name) + st.fieldsIndex[f.name] = f + // When there is ambiguity because multiple fields have the same + // case-insensitive representation, the first field must win. + if _, exists := st.ficaseIndex[s]; !exists { + st.ficaseIndex[s] = f + } + } + + // At a certain point the linear scan provided by keyset is less + // efficient than a map. The 32 was chosen based on benchmarks in the + // segmentio/asm repo run with an Intel Kaby Lake processor and go1.17. + if len(st.fields) <= 32 { + keys := make([][]byte, len(st.fields)) + for i, f := range st.fields { + keys[i] = []byte(f.name) + } + st.keyset = keyset.New(keys) + } + } + + return st +} + +func constructStructEncodeFunc(st *structType) encodeFunc { + return func(e encoder, b []byte, p unsafe.Pointer) ([]byte, error) { + return e.encodeStruct(b, p, st) + } +} + +func constructStructDecodeFunc(st *structType) decodeFunc { + return func(d decoder, b []byte, p unsafe.Pointer) ([]byte, error) { + return d.decodeStruct(b, p, st) + } +} + +func constructEmbeddedStructPointerCodec(t reflect.Type, unexported bool, offset uintptr, field codec) codec { + return codec{ + encode: constructEmbeddedStructPointerEncodeFunc(t, unexported, offset, field.encode), + decode: constructEmbeddedStructPointerDecodeFunc(t, unexported, offset, field.decode), + } +} + +func constructEmbeddedStructPointerEncodeFunc(t reflect.Type, unexported bool, offset uintptr, encode encodeFunc) encodeFunc { + return func(e encoder, b []byte, p unsafe.Pointer) ([]byte, error) { + return e.encodeEmbeddedStructPointer(b, p, t, unexported, offset, encode) + } +} + +func constructEmbeddedStructPointerDecodeFunc(t reflect.Type, unexported bool, offset uintptr, decode decodeFunc) decodeFunc { + return func(d decoder, b []byte, p unsafe.Pointer) ([]byte, error) { + return d.decodeEmbeddedStructPointer(b, p, t, unexported, offset, decode) + } +} + +func appendStructFields(fields []structField, t reflect.Type, offset uintptr, seen map[reflect.Type]*structType, canAddr bool) []structField { + type embeddedField struct { + index int + offset uintptr + pointer bool + unexported bool + subtype *structType + subfield *structField + } + + names := make(map[string]struct{}) + embedded := make([]embeddedField, 0, 10) + + for i := range t.NumField() { + f := t.Field(i) + + var ( + name = f.Name + anonymous = f.Anonymous + tag = false + omitempty = false + stringify = false + unexported = len(f.PkgPath) != 0 + ) + + if unexported && !anonymous { // unexported + continue + } + + if parts := strings.Split(f.Tag.Get("json"), ","); len(parts) != 0 { + if len(parts[0]) != 0 { + name, tag = parts[0], true + } + + if name == "-" && len(parts) == 1 { // ignored + continue + } + + if !isValidTag(name) { + name = f.Name + } + + for _, tag := range parts[1:] { + switch tag { + case "omitempty": + omitempty = true + case "string": + stringify = true + } + } + } + + if anonymous && !tag { // embedded + typ := f.Type + ptr := f.Type.Kind() == reflect.Ptr + + if ptr { + typ = f.Type.Elem() + } + + if typ.Kind() == reflect.Struct { + // When the embedded fields is inlined the fields can be looked + // up by offset from the address of the wrapping object, so we + // simply add the embedded struct fields to the list of fields + // of the current struct type. + subtype := constructStructType(typ, seen, canAddr) + + for j := range subtype.fields { + embedded = append(embedded, embeddedField{ + index: i<<32 | j, + offset: offset + f.Offset, + pointer: ptr, + unexported: unexported, + subtype: subtype, + subfield: &subtype.fields[j], + }) + } + + continue + } + + if unexported { // ignore unexported non-struct types + continue + } + } + + codec := constructCodec(f.Type, seen, canAddr) + + if stringify { + // https://golang.org/pkg/encoding/json/#Marshal + // + // The "string" option signals that a field is stored as JSON inside + // a JSON-encoded string. It applies only to fields of string, + // floating point, integer, or boolean types. This extra level of + // encoding is sometimes used when communicating with JavaScript + // programs: + typ := f.Type + + if typ.Kind() == reflect.Ptr { + typ = typ.Elem() + } + + switch typ.Kind() { + case reflect.Int, + reflect.Int8, + reflect.Int16, + reflect.Int32, + reflect.Int64, + reflect.Uint, + reflect.Uintptr, + reflect.Uint8, + reflect.Uint16, + reflect.Uint32, + reflect.Uint64: + codec.encode = constructStringEncodeFunc(codec.encode) + codec.decode = constructStringToIntDecodeFunc(typ, codec.decode) + case reflect.Bool, + reflect.Float32, + reflect.Float64, + reflect.String: + codec.encode = constructStringEncodeFunc(codec.encode) + codec.decode = constructStringDecodeFunc(codec.decode) + } + } + + fields = append(fields, structField{ + codec: codec, + offset: offset + f.Offset, + empty: emptyFuncOf(f.Type), + tag: tag, + omitempty: omitempty, + name: name, + index: i << 32, + typ: f.Type, + zero: reflect.Zero(f.Type), + }) + + names[name] = struct{}{} + } + + // Only unambiguous embedded fields must be serialized. + ambiguousNames := make(map[string]int) + ambiguousTags := make(map[string]int) + + // Embedded types can never override a field that was already present at + // the top-level. + for name := range names { + ambiguousNames[name]++ + ambiguousTags[name]++ + } + + for _, embfield := range embedded { + ambiguousNames[embfield.subfield.name]++ + if embfield.subfield.tag { + ambiguousTags[embfield.subfield.name]++ + } + } + + for _, embfield := range embedded { + subfield := *embfield.subfield + + if ambiguousNames[subfield.name] > 1 && (!subfield.tag || ambiguousTags[subfield.name] != 1) { + continue // ambiguous embedded field + } + + if embfield.pointer { + subfield.codec = constructEmbeddedStructPointerCodec(embfield.subtype.typ, embfield.unexported, subfield.offset, subfield.codec) + subfield.offset = embfield.offset + } else { + subfield.offset += embfield.offset + } + + // To prevent dominant flags more than one level below the embedded one. + subfield.tag = false + + // To ensure the order of the fields in the output is the same is in the + // struct type. + subfield.index = embfield.index + + fields = append(fields, subfield) + } + + for i := range fields { + name := fields[i].name + fields[i].json = encodeKeyFragment(name, 0) + fields[i].html = encodeKeyFragment(name, EscapeHTML) + } + + sort.Slice(fields, func(i, j int) bool { return fields[i].index < fields[j].index }) + return fields +} + +func encodeKeyFragment(s string, flags AppendFlags) string { + b := make([]byte, 1, len(s)+4) + b[0] = ',' + e := encoder{flags: flags} + b, _ = e.encodeString(b, unsafe.Pointer(&s)) + b = append(b, ':') + return *(*string)(unsafe.Pointer(&b)) +} + +func constructPointerCodec(t reflect.Type, seen map[reflect.Type]*structType) codec { + e := t.Elem() + c := constructCodec(e, seen, true) + return codec{ + encode: constructPointerEncodeFunc(e, c.encode), + decode: constructPointerDecodeFunc(e, c.decode), + } +} + +func constructPointerEncodeFunc(t reflect.Type, encode encodeFunc) encodeFunc { + return func(e encoder, b []byte, p unsafe.Pointer) ([]byte, error) { + return e.encodePointer(b, p, t, encode) + } +} + +func constructPointerDecodeFunc(t reflect.Type, decode decodeFunc) decodeFunc { + return func(d decoder, b []byte, p unsafe.Pointer) ([]byte, error) { + return d.decodePointer(b, p, t, decode) + } +} + +func constructInterfaceCodec(t reflect.Type) codec { + return codec{ + encode: constructMaybeEmptyInterfaceEncoderFunc(t), + decode: constructMaybeEmptyInterfaceDecoderFunc(t), + } +} + +func constructMaybeEmptyInterfaceEncoderFunc(t reflect.Type) encodeFunc { + return func(e encoder, b []byte, p unsafe.Pointer) ([]byte, error) { + return e.encodeMaybeEmptyInterface(b, p, t) + } +} + +func constructMaybeEmptyInterfaceDecoderFunc(t reflect.Type) decodeFunc { + return func(d decoder, b []byte, p unsafe.Pointer) ([]byte, error) { + return d.decodeMaybeEmptyInterface(b, p, t) + } +} + +func constructUnsupportedTypeCodec(t reflect.Type) codec { + return codec{ + encode: constructUnsupportedTypeEncodeFunc(t), + decode: constructUnsupportedTypeDecodeFunc(t), + } +} + +func constructUnsupportedTypeEncodeFunc(t reflect.Type) encodeFunc { + return func(e encoder, b []byte, p unsafe.Pointer) ([]byte, error) { + return e.encodeUnsupportedTypeError(b, p, t) + } +} + +func constructUnsupportedTypeDecodeFunc(t reflect.Type) decodeFunc { + return func(d decoder, b []byte, p unsafe.Pointer) ([]byte, error) { + return d.decodeUnmarshalTypeError(b, p, t) + } +} + +func constructJSONMarshalerEncodeFunc(t reflect.Type, pointer bool) encodeFunc { + return func(e encoder, b []byte, p unsafe.Pointer) ([]byte, error) { + return e.encodeJSONMarshaler(b, p, t, pointer) + } +} + +func constructJSONUnmarshalerDecodeFunc(t reflect.Type, pointer bool) decodeFunc { + return func(d decoder, b []byte, p unsafe.Pointer) ([]byte, error) { + return d.decodeJSONUnmarshaler(b, p, t, pointer) + } +} + +func constructTextMarshalerEncodeFunc(t reflect.Type, pointer bool) encodeFunc { + return func(e encoder, b []byte, p unsafe.Pointer) ([]byte, error) { + return e.encodeTextMarshaler(b, p, t, pointer) + } +} + +func constructTextUnmarshalerDecodeFunc(t reflect.Type, pointer bool) decodeFunc { + return func(d decoder, b []byte, p unsafe.Pointer) ([]byte, error) { + return d.decodeTextUnmarshaler(b, p, t, pointer) + } +} + +func constructInlineValueEncodeFunc(encode encodeFunc) encodeFunc { + return func(e encoder, b []byte, p unsafe.Pointer) ([]byte, error) { + return encode(e, b, noescape(unsafe.Pointer(&p))) + } +} + +// noescape hides a pointer from escape analysis. noescape is +// the identity function but escape analysis doesn't think the +// output depends on the input. noescape is inlined and currently +// compiles down to zero instructions. +// USE CAREFULLY! +// This was copied from the runtime; see issues 23382 and 7921. +// +//go:nosplit +func noescape(p unsafe.Pointer) unsafe.Pointer { + x := uintptr(p) + return unsafe.Pointer(x ^ 0) +} + +func alignedSize(t reflect.Type) uintptr { + a := t.Align() + s := t.Size() + return align(uintptr(a), uintptr(s)) +} + +func align(align, size uintptr) uintptr { + if align != 0 && (size%align) != 0 { + size = ((size / align) + 1) * align + } + return size +} + +func inlined(t reflect.Type) bool { + switch t.Kind() { + case reflect.Ptr: + return true + case reflect.Map: + return true + case reflect.Struct: + return t.NumField() == 1 && inlined(t.Field(0).Type) + default: + return false + } +} + +func isValidTag(s string) bool { + if s == "" { + return false + } + for _, c := range s { + switch { + case strings.ContainsRune("!#$%&()*+-./:;<=>?@[]^_{|}~ ", c): + // Backslash and quote chars are reserved, but + // otherwise any punctuation chars are allowed + // in a tag name. + default: + if !unicode.IsLetter(c) && !unicode.IsDigit(c) { + return false + } + } + } + return true +} + +func emptyFuncOf(t reflect.Type) emptyFunc { + switch t { + case bytesType, rawMessageType: + return func(p unsafe.Pointer) bool { return (*slice)(p).len == 0 } + } + + switch t.Kind() { + case reflect.Array: + if t.Len() == 0 { + return func(unsafe.Pointer) bool { return true } + } + + case reflect.Map: + return func(p unsafe.Pointer) bool { return reflect.NewAt(t, p).Elem().Len() == 0 } + + case reflect.Slice: + return func(p unsafe.Pointer) bool { return (*slice)(p).len == 0 } + + case reflect.String: + return func(p unsafe.Pointer) bool { return len(*(*string)(p)) == 0 } + + case reflect.Bool: + return func(p unsafe.Pointer) bool { return !*(*bool)(p) } + + case reflect.Int, reflect.Uint: + return func(p unsafe.Pointer) bool { return *(*uint)(p) == 0 } + + case reflect.Uintptr: + return func(p unsafe.Pointer) bool { return *(*uintptr)(p) == 0 } + + case reflect.Int8, reflect.Uint8: + return func(p unsafe.Pointer) bool { return *(*uint8)(p) == 0 } + + case reflect.Int16, reflect.Uint16: + return func(p unsafe.Pointer) bool { return *(*uint16)(p) == 0 } + + case reflect.Int32, reflect.Uint32: + return func(p unsafe.Pointer) bool { return *(*uint32)(p) == 0 } + + case reflect.Int64, reflect.Uint64: + return func(p unsafe.Pointer) bool { return *(*uint64)(p) == 0 } + + case reflect.Float32: + return func(p unsafe.Pointer) bool { return *(*float32)(p) == 0 } + + case reflect.Float64: + return func(p unsafe.Pointer) bool { return *(*float64)(p) == 0 } + + case reflect.Ptr: + return func(p unsafe.Pointer) bool { return *(*unsafe.Pointer)(p) == nil } + + case reflect.Interface: + return func(p unsafe.Pointer) bool { return (*iface)(p).ptr == nil } + } + + return func(unsafe.Pointer) bool { return false } +} + +type iface struct { + typ unsafe.Pointer + ptr unsafe.Pointer +} + +type slice struct { + data unsafe.Pointer + len int + cap int +} + +type structType struct { + fields []structField + fieldsIndex map[string]*structField + ficaseIndex map[string]*structField + keyset []byte + typ reflect.Type +} + +type structField struct { + codec codec + offset uintptr + empty emptyFunc + tag bool + omitempty bool + json string + html string + name string + typ reflect.Type + zero reflect.Value + index int +} + +func unmarshalTypeError(b []byte, t reflect.Type) error { + return &UnmarshalTypeError{Value: strconv.Quote(prefix(b)), Type: t} +} + +func unmarshalOverflow(b []byte, t reflect.Type) error { + return &UnmarshalTypeError{Value: "number " + prefix(b) + " overflows", Type: t} +} + +func unexpectedEOF(b []byte) error { + return syntaxError(b, "unexpected end of JSON input") +} + +var syntaxErrorMsgOffset = ^uintptr(0) + +func init() { + t := reflect.TypeOf(SyntaxError{}) + for i := range t.NumField() { + if f := t.Field(i); f.Type.Kind() == reflect.String { + syntaxErrorMsgOffset = f.Offset + } + } +} + +func syntaxError(b []byte, msg string, args ...any) error { + e := new(SyntaxError) + i := syntaxErrorMsgOffset + if i != ^uintptr(0) { + s := "json: " + fmt.Sprintf(msg, args...) + ": " + prefix(b) + p := unsafe.Pointer(e) + // Hack to set the unexported `msg` field. + *(*string)(unsafe.Pointer(uintptr(p) + i)) = s + } + return e +} + +func objectKeyError(b []byte, err error) ([]byte, error) { + if len(b) == 0 { + return nil, unexpectedEOF(b) + } + switch err.(type) { + case *UnmarshalTypeError: + err = syntaxError(b, "invalid character '%c' looking for beginning of object key", b[0]) + } + return b, err +} + +func prefix(b []byte) string { + if len(b) < 32 { + return string(b) + } + return string(b[:32]) + "..." +} + +func intStringsAreSorted(i0, i1 int64) bool { + var b0, b1 [32]byte + return string(strconv.AppendInt(b0[:0], i0, 10)) < string(strconv.AppendInt(b1[:0], i1, 10)) +} + +func uintStringsAreSorted(u0, u1 uint64) bool { + var b0, b1 [32]byte + return string(strconv.AppendUint(b0[:0], u0, 10)) < string(strconv.AppendUint(b1[:0], u1, 10)) +} + +func stringToBytes(s string) []byte { + return *(*[]byte)(unsafe.Pointer(&sliceHeader{ + Data: *(*unsafe.Pointer)(unsafe.Pointer(&s)), + Len: len(s), + Cap: len(s), + })) +} + +type sliceHeader struct { + Data unsafe.Pointer + Len int + Cap int +} + +var ( + nullType = reflect.TypeOf(nil) + boolType = reflect.TypeOf(false) + + intType = reflect.TypeOf(int(0)) + int8Type = reflect.TypeOf(int8(0)) + int16Type = reflect.TypeOf(int16(0)) + int32Type = reflect.TypeOf(int32(0)) + int64Type = reflect.TypeOf(int64(0)) + + uintType = reflect.TypeOf(uint(0)) + uint8Type = reflect.TypeOf(uint8(0)) + uint16Type = reflect.TypeOf(uint16(0)) + uint32Type = reflect.TypeOf(uint32(0)) + uint64Type = reflect.TypeOf(uint64(0)) + uintptrType = reflect.TypeOf(uintptr(0)) + + float32Type = reflect.TypeOf(float32(0)) + float64Type = reflect.TypeOf(float64(0)) + + bigIntType = reflect.TypeOf(new(big.Int)) + numberType = reflect.TypeOf(json.Number("")) + stringType = reflect.TypeOf("") + stringsType = reflect.TypeOf([]string(nil)) + bytesType = reflect.TypeOf(([]byte)(nil)) + durationType = reflect.TypeOf(time.Duration(0)) + timeType = reflect.TypeOf(time.Time{}) + rawMessageType = reflect.TypeOf(RawMessage(nil)) + + numberPtrType = reflect.PointerTo(numberType) + durationPtrType = reflect.PointerTo(durationType) + timePtrType = reflect.PointerTo(timeType) + rawMessagePtrType = reflect.PointerTo(rawMessageType) + + sliceInterfaceType = reflect.TypeOf(([]any)(nil)) + sliceStringType = reflect.TypeOf(([]any)(nil)) + mapStringInterfaceType = reflect.TypeOf((map[string]any)(nil)) + mapStringRawMessageType = reflect.TypeOf((map[string]RawMessage)(nil)) + mapStringStringType = reflect.TypeOf((map[string]string)(nil)) + mapStringStringSliceType = reflect.TypeOf((map[string][]string)(nil)) + mapStringBoolType = reflect.TypeOf((map[string]bool)(nil)) + + interfaceType = reflect.TypeOf((*any)(nil)).Elem() + jsonMarshalerType = reflect.TypeOf((*Marshaler)(nil)).Elem() + jsonUnmarshalerType = reflect.TypeOf((*Unmarshaler)(nil)).Elem() + textMarshalerType = reflect.TypeOf((*encoding.TextMarshaler)(nil)).Elem() + textUnmarshalerType = reflect.TypeOf((*encoding.TextUnmarshaler)(nil)).Elem() + + bigIntDecoder = constructJSONUnmarshalerDecodeFunc(bigIntType, false) +) + +// ============================================================================= +// Copyright 2009 The Go Authors. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +// appendDuration appends a human-readable representation of d to b. +// +// The function copies the implementation of time.Duration.String but prevents +// Go from making a dynamic memory allocation on the returned value. +func appendDuration(b []byte, d time.Duration) []byte { + // Largest time is 2540400h10m10.000000000s + var buf [32]byte + w := len(buf) + + u := uint64(d) + neg := d < 0 + if neg { + u = -u + } + + if u < uint64(time.Second) { + // Special case: if duration is smaller than a second, + // use smaller units, like 1.2ms + var prec int + w-- + buf[w] = 's' + w-- + switch { + case u == 0: + return append(b, '0', 's') + case u < uint64(time.Microsecond): + // print nanoseconds + prec = 0 + buf[w] = 'n' + case u < uint64(time.Millisecond): + // print microseconds + prec = 3 + // U+00B5 'µ' micro sign == 0xC2 0xB5 + w-- // Need room for two bytes. + copy(buf[w:], "µ") + default: + // print milliseconds + prec = 6 + buf[w] = 'm' + } + w, u = fmtFrac(buf[:w], u, prec) + w = fmtInt(buf[:w], u) + } else { + w-- + buf[w] = 's' + + w, u = fmtFrac(buf[:w], u, 9) + + // u is now integer seconds + w = fmtInt(buf[:w], u%60) + u /= 60 + + // u is now integer minutes + if u > 0 { + w-- + buf[w] = 'm' + w = fmtInt(buf[:w], u%60) + u /= 60 + + // u is now integer hours + // Stop at hours because days can be different lengths. + if u > 0 { + w-- + buf[w] = 'h' + w = fmtInt(buf[:w], u) + } + } + } + + if neg { + w-- + buf[w] = '-' + } + + return append(b, buf[w:]...) +} + +// fmtFrac formats the fraction of v/10**prec (e.g., ".12345") into the +// tail of buf, omitting trailing zeros. it omits the decimal +// point too when the fraction is 0. It returns the index where the +// output bytes begin and the value v/10**prec. +func fmtFrac(buf []byte, v uint64, prec int) (nw int, nv uint64) { + // Omit trailing zeros up to and including decimal point. + w := len(buf) + print := false + for range prec { + digit := v % 10 + print = print || digit != 0 + if print { + w-- + buf[w] = byte(digit) + '0' + } + v /= 10 + } + if print { + w-- + buf[w] = '.' + } + return w, v +} + +// fmtInt formats v into the tail of buf. +// It returns the index where the output begins. +func fmtInt(buf []byte, v uint64) int { + w := len(buf) + if v == 0 { + w-- + buf[w] = '0' + } else { + for v > 0 { + w-- + buf[w] = byte(v%10) + '0' + v /= 10 + } + } + return w +} + +// ============================================================================= diff --git a/mcp/dsx-exchange-mcp/vendor/github.com/segmentio/encoding/json/decode.go b/mcp/dsx-exchange-mcp/vendor/github.com/segmentio/encoding/json/decode.go new file mode 100644 index 0000000..c87f01e --- /dev/null +++ b/mcp/dsx-exchange-mcp/vendor/github.com/segmentio/encoding/json/decode.go @@ -0,0 +1,1532 @@ +package json + +import ( + "bytes" + "encoding" + "encoding/json" + "fmt" + "math" + "math/big" + "reflect" + "strconv" + "time" + "unsafe" + + "github.com/segmentio/asm/base64" + "github.com/segmentio/asm/keyset" + "github.com/segmentio/encoding/iso8601" +) + +func (d decoder) anyFlagsSet(flags ParseFlags) bool { + return d.flags&flags != 0 +} + +func (d decoder) decodeNull(b []byte, p unsafe.Pointer) ([]byte, error) { + if hasNullPrefix(b) { + return b[4:], nil + } + return d.inputError(b, nullType) +} + +func (d decoder) decodeBool(b []byte, p unsafe.Pointer) ([]byte, error) { + switch { + case hasTruePrefix(b): + *(*bool)(p) = true + return b[4:], nil + + case hasFalsePrefix(b): + *(*bool)(p) = false + return b[5:], nil + + case hasNullPrefix(b): + return b[4:], nil + + default: + return d.inputError(b, boolType) + } +} + +func (d decoder) decodeInt(b []byte, p unsafe.Pointer) ([]byte, error) { + if hasNullPrefix(b) { + return b[4:], nil + } + + v, r, err := d.parseInt(b, intType) + if err != nil { + return r, err + } + + *(*int)(p) = int(v) + return r, nil +} + +func (d decoder) decodeInt8(b []byte, p unsafe.Pointer) ([]byte, error) { + if hasNullPrefix(b) { + return b[4:], nil + } + + v, r, err := d.parseInt(b, int8Type) + if err != nil { + return r, err + } + + if v < math.MinInt8 || v > math.MaxInt8 { + return r, unmarshalOverflow(b[:len(b)-len(r)], int8Type) + } + + *(*int8)(p) = int8(v) + return r, nil +} + +func (d decoder) decodeInt16(b []byte, p unsafe.Pointer) ([]byte, error) { + if hasNullPrefix(b) { + return b[4:], nil + } + + v, r, err := d.parseInt(b, int16Type) + if err != nil { + return r, err + } + + if v < math.MinInt16 || v > math.MaxInt16 { + return r, unmarshalOverflow(b[:len(b)-len(r)], int16Type) + } + + *(*int16)(p) = int16(v) + return r, nil +} + +func (d decoder) decodeInt32(b []byte, p unsafe.Pointer) ([]byte, error) { + if hasNullPrefix(b) { + return b[4:], nil + } + + v, r, err := d.parseInt(b, int32Type) + if err != nil { + return r, err + } + + if v < math.MinInt32 || v > math.MaxInt32 { + return r, unmarshalOverflow(b[:len(b)-len(r)], int32Type) + } + + *(*int32)(p) = int32(v) + return r, nil +} + +func (d decoder) decodeInt64(b []byte, p unsafe.Pointer) ([]byte, error) { + if hasNullPrefix(b) { + return b[4:], nil + } + + v, r, err := d.parseInt(b, int64Type) + if err != nil { + return r, err + } + + *(*int64)(p) = v + return r, nil +} + +func (d decoder) decodeUint(b []byte, p unsafe.Pointer) ([]byte, error) { + if hasNullPrefix(b) { + return b[4:], nil + } + + v, r, err := d.parseUint(b, uintType) + if err != nil { + return r, err + } + + *(*uint)(p) = uint(v) + return r, nil +} + +func (d decoder) decodeUintptr(b []byte, p unsafe.Pointer) ([]byte, error) { + if hasNullPrefix(b) { + return b[4:], nil + } + + v, r, err := d.parseUint(b, uintptrType) + if err != nil { + return r, err + } + + *(*uintptr)(p) = uintptr(v) + return r, nil +} + +func (d decoder) decodeUint8(b []byte, p unsafe.Pointer) ([]byte, error) { + if hasNullPrefix(b) { + return b[4:], nil + } + + v, r, err := d.parseUint(b, uint8Type) + if err != nil { + return r, err + } + + if v > math.MaxUint8 { + return r, unmarshalOverflow(b[:len(b)-len(r)], uint8Type) + } + + *(*uint8)(p) = uint8(v) + return r, nil +} + +func (d decoder) decodeUint16(b []byte, p unsafe.Pointer) ([]byte, error) { + if hasNullPrefix(b) { + return b[4:], nil + } + + v, r, err := d.parseUint(b, uint16Type) + if err != nil { + return r, err + } + + if v > math.MaxUint16 { + return r, unmarshalOverflow(b[:len(b)-len(r)], uint16Type) + } + + *(*uint16)(p) = uint16(v) + return r, nil +} + +func (d decoder) decodeUint32(b []byte, p unsafe.Pointer) ([]byte, error) { + if hasNullPrefix(b) { + return b[4:], nil + } + + v, r, err := d.parseUint(b, uint32Type) + if err != nil { + return r, err + } + + if v > math.MaxUint32 { + return r, unmarshalOverflow(b[:len(b)-len(r)], uint32Type) + } + + *(*uint32)(p) = uint32(v) + return r, nil +} + +func (d decoder) decodeUint64(b []byte, p unsafe.Pointer) ([]byte, error) { + if hasNullPrefix(b) { + return b[4:], nil + } + + v, r, err := d.parseUint(b, uint64Type) + if err != nil { + return r, err + } + + *(*uint64)(p) = v + return r, nil +} + +func (d decoder) decodeFloat32(b []byte, p unsafe.Pointer) ([]byte, error) { + if hasNullPrefix(b) { + return b[4:], nil + } + + v, r, _, err := d.parseNumber(b) + if err != nil { + return d.inputError(b, float32Type) + } + + f, err := strconv.ParseFloat(*(*string)(unsafe.Pointer(&v)), 32) + if err != nil { + return d.inputError(b, float32Type) + } + + *(*float32)(p) = float32(f) + return r, nil +} + +func (d decoder) decodeFloat64(b []byte, p unsafe.Pointer) ([]byte, error) { + if hasNullPrefix(b) { + return b[4:], nil + } + + v, r, _, err := d.parseNumber(b) + if err != nil { + return d.inputError(b, float64Type) + } + + f, err := strconv.ParseFloat(*(*string)(unsafe.Pointer(&v)), 64) + if err != nil { + return d.inputError(b, float64Type) + } + + *(*float64)(p) = f + return r, nil +} + +func (d decoder) decodeNumber(b []byte, p unsafe.Pointer) ([]byte, error) { + if hasNullPrefix(b) { + return b[4:], nil + } + + v, r, _, err := d.parseNumber(b) + if err != nil { + return d.inputError(b, numberType) + } + + if (d.flags & DontCopyNumber) != 0 { + *(*Number)(p) = *(*Number)(unsafe.Pointer(&v)) + } else { + *(*Number)(p) = Number(v) + } + + return r, nil +} + +func (d decoder) decodeString(b []byte, p unsafe.Pointer) ([]byte, error) { + if hasNullPrefix(b) { + return b[4:], nil + } + + s, r, new, err := d.parseStringUnquote(b, nil) + if err != nil { + if len(b) == 0 || b[0] != '"' { + return d.inputError(b, stringType) + } + return r, err + } + + if new || (d.flags&DontCopyString) != 0 { + *(*string)(p) = *(*string)(unsafe.Pointer(&s)) + } else { + *(*string)(p) = string(s) + } + + return r, nil +} + +func (d decoder) decodeFromString(b []byte, p unsafe.Pointer, decode decodeFunc) ([]byte, error) { + if hasNullPrefix(b) { + return decode(d, b, p) + } + + v, b, _, err := d.parseStringUnquote(b, nil) + if err != nil { + return d.inputError(v, stringType) + } + + if v, err = decode(d, v, p); err != nil { + return b, err + } + + if v = skipSpaces(v); len(v) != 0 { + return b, syntaxError(v, "unexpected trailing tokens after string value") + } + + return b, nil +} + +func (d decoder) decodeFromStringToInt(b []byte, p unsafe.Pointer, t reflect.Type, decode decodeFunc) ([]byte, error) { + if hasNullPrefix(b) { + return decode(d, b, p) + } + + if len(b) > 0 && b[0] != '"' { + v, r, k, err := d.parseNumber(b) + if err == nil { + // The encoding/json package will return a *json.UnmarshalTypeError if + // the input was a floating point number representation, even tho a + // string is expected here. + if k == Float { + _, err := strconv.ParseFloat(*(*string)(unsafe.Pointer(&v)), 64) + if err != nil { + return r, unmarshalTypeError(v, t) + } + } + } + return r, fmt.Errorf("json: invalid use of ,string struct tag, trying to unmarshal unquoted value into int") + } + + if len(b) > 1 && b[0] == '"' && b[1] == '"' { + return b, fmt.Errorf("json: invalid use of ,string struct tag, trying to unmarshal \"\" into int") + } + + v, b, _, err := d.parseStringUnquote(b, nil) + if err != nil { + return d.inputError(v, t) + } + + if hasLeadingZeroes(v) { + // In this context the encoding/json package accepts leading zeroes because + // it is not constrained by the JSON syntax, remove them so the parsing + // functions don't return syntax errors. + u := make([]byte, 0, len(v)) + i := 0 + + if i < len(v) && v[i] == '-' || v[i] == '+' { + u = append(u, v[i]) + i++ + } + + for (i+1) < len(v) && v[i] == '0' && '0' <= v[i+1] && v[i+1] <= '9' { + i++ + } + + v = append(u, v[i:]...) + } + + if r, err := decode(d, v, p); err != nil { + if _, isSyntaxError := err.(*SyntaxError); isSyntaxError { + if hasPrefix(v, "-") { + // The standard library interprets sequences of '-' characters + // as numbers but still returns type errors in this case... + return b, unmarshalTypeError(v, t) + } + return b, fmt.Errorf("json: invalid use of ,string struct tag, trying to unmarshal %q into int", prefix(v)) + } + // When the input value was a valid number representation we retain the + // error returned by the decoder. + if _, _, _, err := d.parseNumber(v); err != nil { + // When the input value valid JSON we mirror the behavior of the + // encoding/json package and return a generic error. + if _, _, _, err := d.parseValue(v); err == nil { + return b, fmt.Errorf("json: invalid use of ,string struct tag, trying to unmarshal %q into int", prefix(v)) + } + } + return b, err + } else if len(r) != 0 { + return r, unmarshalTypeError(v, t) + } + + return b, nil +} + +func (d decoder) decodeBytes(b []byte, p unsafe.Pointer) ([]byte, error) { + if hasNullPrefix(b) { + *(*[]byte)(p) = nil + return b[4:], nil + } + + if len(b) < 2 { + return d.inputError(b, bytesType) + } + + if b[0] != '"' { + // Go 1.7- behavior: bytes slices may be decoded from array of integers. + if len(b) > 0 && b[0] == '[' { + return d.decodeSlice(b, p, 1, bytesType, decoder.decodeUint8) + } + return d.inputError(b, bytesType) + } + + // The input string contains escaped sequences, we need to parse it before + // decoding it to match the encoding/json package behvaior. + src, r, _, err := d.parseStringUnquote(b, nil) + if err != nil { + return d.inputError(b, bytesType) + } + + dst := make([]byte, base64.StdEncoding.DecodedLen(len(src))) + + n, err := base64.StdEncoding.Decode(dst, src) + if err != nil { + return r, err + } + + *(*[]byte)(p) = dst[:n] + return r, nil +} + +func (d decoder) decodeDuration(b []byte, p unsafe.Pointer) ([]byte, error) { + if hasNullPrefix(b) { + return b[4:], nil + } + + // in order to inter-operate with the stdlib, we must be able to interpret + // durations passed as integer values. there's some discussion about being + // flexible on how durations are formatted, but for the time being, it's + // been punted to go2 at the earliest: https://github.com/golang/go/issues/4712 + if len(b) > 0 && b[0] != '"' { + v, r, err := d.parseInt(b, durationType) + if err != nil { + return d.inputError(b, int32Type) + } + + if v < math.MinInt64 || v > math.MaxInt64 { + return r, unmarshalOverflow(b[:len(b)-len(r)], int32Type) + } + + *(*time.Duration)(p) = time.Duration(v) + return r, nil + } + + if len(b) < 2 || b[0] != '"' { + return d.inputError(b, durationType) + } + + i := bytes.IndexByte(b[1:], '"') + 1 + if i <= 0 { + return d.inputError(b, durationType) + } + + s := b[1:i] // trim quotes + + v, err := time.ParseDuration(*(*string)(unsafe.Pointer(&s))) + if err != nil { + return d.inputError(b, durationType) + } + + *(*time.Duration)(p) = v + return b[i+1:], nil +} + +func (d decoder) decodeTime(b []byte, p unsafe.Pointer) ([]byte, error) { + if hasNullPrefix(b) { + return b[4:], nil + } + + if len(b) < 2 || b[0] != '"' { + return d.inputError(b, timeType) + } + + i := bytes.IndexByte(b[1:], '"') + 1 + if i <= 0 { + return d.inputError(b, timeType) + } + + s := b[1:i] // trim quotes + + v, err := iso8601.Parse(*(*string)(unsafe.Pointer(&s))) + if err != nil { + return d.inputError(b, timeType) + } + + *(*time.Time)(p) = v + return b[i+1:], nil +} + +func (d decoder) decodeArray(b []byte, p unsafe.Pointer, n int, size uintptr, t reflect.Type, decode decodeFunc) ([]byte, error) { + if hasNullPrefix(b) { + return b[4:], nil + } + + if len(b) < 2 || b[0] != '[' { + return d.inputError(b, t) + } + b = b[1:] + + var err error + for i := range n { + b = skipSpaces(b) + + if i != 0 { + if len(b) == 0 { + return b, syntaxError(b, "unexpected EOF after array element") + } + switch b[0] { + case ',': + b = skipSpaces(b[1:]) + case ']': + return b[1:], nil + default: + return b, syntaxError(b, "expected ',' after array element but found '%c'", b[0]) + } + } + + b, err = decode(d, b, unsafe.Pointer(uintptr(p)+(uintptr(i)*size))) + if err != nil { + if e, ok := err.(*UnmarshalTypeError); ok { + e.Struct = t.String() + e.Struct + e.Field = d.prependField(strconv.Itoa(i), e.Field) + } + return b, err + } + } + + // The encoding/json package ignores extra elements found when decoding into + // array types (which have a fixed size). + for { + b = skipSpaces(b) + + if len(b) == 0 { + return b, syntaxError(b, "missing closing ']' in array value") + } + + switch b[0] { + case ',': + b = skipSpaces(b[1:]) + case ']': + return b[1:], nil + } + + _, b, _, err = d.parseValue(b) + if err != nil { + return b, err + } + } +} + +// This is a placeholder used to consturct non-nil empty slices. +var empty struct{} + +func (d decoder) decodeSlice(b []byte, p unsafe.Pointer, size uintptr, t reflect.Type, decode decodeFunc) ([]byte, error) { + if hasNullPrefix(b) { + *(*slice)(p) = slice{} + return b[4:], nil + } + + if len(b) < 2 { + return d.inputError(b, t) + } + + if b[0] != '[' { + // Go 1.7- behavior: fallback to decoding as a []byte if the element + // type is byte; allow conversions from JSON strings even tho the + // underlying type implemented unmarshaler interfaces. + if t.Elem().Kind() == reflect.Uint8 { + return d.decodeBytes(b, p) + } + return d.inputError(b, t) + } + + input := b + b = b[1:] + + s := (*slice)(p) + s.len = 0 + + var err error + for { + b = skipSpaces(b) + + if len(b) != 0 && b[0] == ']' { + if s.data == nil { + s.data = unsafe.Pointer(&empty) + } + return b[1:], nil + } + + if s.len != 0 { + if len(b) == 0 { + return b, syntaxError(b, "unexpected EOF after array element") + } + if b[0] != ',' { + return b, syntaxError(b, "expected ',' after array element but found '%c'", b[0]) + } + b = skipSpaces(b[1:]) + } + + if s.len == s.cap { + c := s.cap + + if c == 0 { + c = 10 + } else { + c *= 2 + } + + *s = extendSlice(t, s, c) + } + + b, err = decode(d, b, unsafe.Pointer(uintptr(s.data)+(uintptr(s.len)*size))) + if err != nil { + if _, r, _, err := d.parseValue(input); err != nil { + return r, err + } else { + b = r + } + if e, ok := err.(*UnmarshalTypeError); ok { + e.Struct = t.String() + e.Struct + e.Field = d.prependField(strconv.Itoa(s.len), e.Field) + } + return b, err + } + + s.len++ + } +} + +func (d decoder) decodeMap(b []byte, p unsafe.Pointer, t, kt, vt reflect.Type, kz, vz reflect.Value, decodeKey, decodeValue decodeFunc) ([]byte, error) { + if hasNullPrefix(b) { + *(*unsafe.Pointer)(p) = nil + return b[4:], nil + } + + if len(b) < 2 || b[0] != '{' { + return d.inputError(b, t) + } + i := 0 + m := reflect.NewAt(t, p).Elem() + + k := reflect.New(kt).Elem() + v := reflect.New(vt).Elem() + + kptr := (*iface)(unsafe.Pointer(&k)).ptr + vptr := (*iface)(unsafe.Pointer(&v)).ptr + input := b + + if m.IsNil() { + m = reflect.MakeMap(t) + } + + var err error + b = b[1:] + for { + k.Set(kz) + v.Set(vz) + b = skipSpaces(b) + + if len(b) != 0 && b[0] == '}' { + *(*unsafe.Pointer)(p) = unsafe.Pointer(m.Pointer()) + return b[1:], nil + } + + if i != 0 { + if len(b) == 0 { + return b, syntaxError(b, "unexpected end of JSON input after object field value") + } + if b[0] != ',' { + return b, syntaxError(b, "expected ',' after object field value but found '%c'", b[0]) + } + b = skipSpaces(b[1:]) + } + + if hasNullPrefix(b) { + return b, syntaxError(b, "cannot decode object key string from 'null' value") + } + + if b, err = decodeKey(d, b, kptr); err != nil { + return objectKeyError(b, err) + } + b = skipSpaces(b) + + if len(b) == 0 { + return b, syntaxError(b, "unexpected end of JSON input after object field key") + } + if b[0] != ':' { + return b, syntaxError(b, "expected ':' after object field key but found '%c'", b[0]) + } + b = skipSpaces(b[1:]) + + if b, err = decodeValue(d, b, vptr); err != nil { + if _, r, _, err := d.parseValue(input); err != nil { + return r, err + } else { + b = r + } + if e, ok := err.(*UnmarshalTypeError); ok { + e.Struct = "map[" + kt.String() + "]" + vt.String() + "{" + e.Struct + "}" + e.Field = d.prependField(fmt.Sprint(k.Interface()), e.Field) + } + return b, err + } + + m.SetMapIndex(k, v) + i++ + } +} + +func (d decoder) decodeMapStringInterface(b []byte, p unsafe.Pointer) ([]byte, error) { + if hasNullPrefix(b) { + *(*unsafe.Pointer)(p) = nil + return b[4:], nil + } + + if len(b) < 2 || b[0] != '{' { + return d.inputError(b, mapStringInterfaceType) + } + + i := 0 + m := *(*map[string]any)(p) + + if m == nil { + m = make(map[string]any, 64) + } + + var ( + input = b + key string + val any + err error + ) + + b = b[1:] + for { + key = "" + val = nil + + b = skipSpaces(b) + + if len(b) != 0 && b[0] == '}' { + *(*unsafe.Pointer)(p) = *(*unsafe.Pointer)(unsafe.Pointer(&m)) + return b[1:], nil + } + + if i != 0 { + if len(b) == 0 { + return b, syntaxError(b, "unexpected end of JSON input after object field value") + } + if b[0] != ',' { + return b, syntaxError(b, "expected ',' after object field value but found '%c'", b[0]) + } + b = skipSpaces(b[1:]) + } + + if hasNullPrefix(b) { + return b, syntaxError(b, "cannot decode object key string from 'null' value") + } + + b, err = d.decodeString(b, unsafe.Pointer(&key)) + if err != nil { + return objectKeyError(b, err) + } + b = skipSpaces(b) + + if len(b) == 0 { + return b, syntaxError(b, "unexpected end of JSON input after object field key") + } + if b[0] != ':' { + return b, syntaxError(b, "expected ':' after object field key but found '%c'", b[0]) + } + b = skipSpaces(b[1:]) + + b, err = d.decodeInterface(b, unsafe.Pointer(&val)) + if err != nil { + if _, r, _, err := d.parseValue(input); err != nil { + return r, err + } else { + b = r + } + if e, ok := err.(*UnmarshalTypeError); ok { + e.Struct = mapStringInterfaceType.String() + e.Struct + e.Field = d.prependField(key, e.Field) + } + return b, err + } + + m[key] = val + i++ + } +} + +func (d decoder) decodeMapStringRawMessage(b []byte, p unsafe.Pointer) ([]byte, error) { + if hasNullPrefix(b) { + *(*unsafe.Pointer)(p) = nil + return b[4:], nil + } + + if len(b) < 2 || b[0] != '{' { + return d.inputError(b, mapStringRawMessageType) + } + + i := 0 + m := *(*map[string]RawMessage)(p) + + if m == nil { + m = make(map[string]RawMessage, 64) + } + + var err error + var key string + var val RawMessage + input := b + + b = b[1:] + for { + key = "" + val = nil + + b = skipSpaces(b) + + if len(b) != 0 && b[0] == '}' { + *(*unsafe.Pointer)(p) = *(*unsafe.Pointer)(unsafe.Pointer(&m)) + return b[1:], nil + } + + if i != 0 { + if len(b) == 0 { + return b, syntaxError(b, "unexpected end of JSON input after object field value") + } + if b[0] != ',' { + return b, syntaxError(b, "expected ',' after object field value but found '%c'", b[0]) + } + b = skipSpaces(b[1:]) + } + + if hasNullPrefix(b) { + return b, syntaxError(b, "cannot decode object key string from 'null' value") + } + + b, err = d.decodeString(b, unsafe.Pointer(&key)) + if err != nil { + return objectKeyError(b, err) + } + b = skipSpaces(b) + + if len(b) == 0 { + return b, syntaxError(b, "unexpected end of JSON input after object field key") + } + if b[0] != ':' { + return b, syntaxError(b, "expected ':' after object field key but found '%c'", b[0]) + } + b = skipSpaces(b[1:]) + + b, err = d.decodeRawMessage(b, unsafe.Pointer(&val)) + if err != nil { + if _, r, _, err := d.parseValue(input); err != nil { + return r, err + } else { + b = r + } + if e, ok := err.(*UnmarshalTypeError); ok { + e.Struct = mapStringRawMessageType.String() + e.Struct + e.Field = d.prependField(key, e.Field) + } + return b, err + } + + m[key] = val + i++ + } +} + +func (d decoder) decodeMapStringString(b []byte, p unsafe.Pointer) ([]byte, error) { + if hasNullPrefix(b) { + *(*unsafe.Pointer)(p) = nil + return b[4:], nil + } + + if len(b) < 2 || b[0] != '{' { + return d.inputError(b, mapStringStringType) + } + + i := 0 + m := *(*map[string]string)(p) + + if m == nil { + m = make(map[string]string, 64) + } + + var err error + var key string + var val string + input := b + + b = b[1:] + for { + key = "" + val = "" + + b = skipSpaces(b) + + if len(b) != 0 && b[0] == '}' { + *(*unsafe.Pointer)(p) = *(*unsafe.Pointer)(unsafe.Pointer(&m)) + return b[1:], nil + } + + if i != 0 { + if len(b) == 0 { + return b, syntaxError(b, "unexpected end of JSON input after object field value") + } + if b[0] != ',' { + return b, syntaxError(b, "expected ',' after object field value but found '%c'", b[0]) + } + b = skipSpaces(b[1:]) + } + + if hasNullPrefix(b) { + return b, syntaxError(b, "cannot decode object key string from 'null' value") + } + + b, err = d.decodeString(b, unsafe.Pointer(&key)) + if err != nil { + return objectKeyError(b, err) + } + b = skipSpaces(b) + + if len(b) == 0 { + return b, syntaxError(b, "unexpected end of JSON input after object field key") + } + if b[0] != ':' { + return b, syntaxError(b, "expected ':' after object field key but found '%c'", b[0]) + } + b = skipSpaces(b[1:]) + + b, err = d.decodeString(b, unsafe.Pointer(&val)) + if err != nil { + if _, r, _, err := d.parseValue(input); err != nil { + return r, err + } else { + b = r + } + if e, ok := err.(*UnmarshalTypeError); ok { + e.Struct = mapStringStringType.String() + e.Struct + e.Field = d.prependField(key, e.Field) + } + return b, err + } + + m[key] = val + i++ + } +} + +func (d decoder) decodeMapStringStringSlice(b []byte, p unsafe.Pointer) ([]byte, error) { + if hasNullPrefix(b) { + *(*unsafe.Pointer)(p) = nil + return b[4:], nil + } + + if len(b) < 2 || b[0] != '{' { + return d.inputError(b, mapStringStringSliceType) + } + + i := 0 + m := *(*map[string][]string)(p) + + if m == nil { + m = make(map[string][]string, 64) + } + + var err error + var key string + var buf []string + input := b + stringSize := unsafe.Sizeof("") + + b = b[1:] + for { + key = "" + buf = buf[:0] + + b = skipSpaces(b) + + if len(b) != 0 && b[0] == '}' { + *(*unsafe.Pointer)(p) = *(*unsafe.Pointer)(unsafe.Pointer(&m)) + return b[1:], nil + } + + if i != 0 { + if len(b) == 0 { + return b, syntaxError(b, "unexpected end of JSON input after object field value") + } + if b[0] != ',' { + return b, syntaxError(b, "expected ',' after object field value but found '%c'", b[0]) + } + b = skipSpaces(b[1:]) + } + + if hasNullPrefix(b) { + return b, syntaxError(b, "cannot decode object key string from 'null' value") + } + + b, err = d.decodeString(b, unsafe.Pointer(&key)) + if err != nil { + return objectKeyError(b, err) + } + b = skipSpaces(b) + + if len(b) == 0 { + return b, syntaxError(b, "unexpected end of JSON input after object field key") + } + if b[0] != ':' { + return b, syntaxError(b, "expected ':' after object field key but found '%c'", b[0]) + } + b = skipSpaces(b[1:]) + + b, err = d.decodeSlice(b, unsafe.Pointer(&buf), stringSize, sliceStringType, decoder.decodeString) + if err != nil { + if _, r, _, err := d.parseValue(input); err != nil { + return r, err + } else { + b = r + } + if e, ok := err.(*UnmarshalTypeError); ok { + e.Struct = mapStringStringType.String() + e.Struct + e.Field = d.prependField(key, e.Field) + } + return b, err + } + + val := make([]string, len(buf)) + copy(val, buf) + + m[key] = val + i++ + } +} + +func (d decoder) decodeMapStringBool(b []byte, p unsafe.Pointer) ([]byte, error) { + if hasNullPrefix(b) { + *(*unsafe.Pointer)(p) = nil + return b[4:], nil + } + + if len(b) < 2 || b[0] != '{' { + return d.inputError(b, mapStringBoolType) + } + + i := 0 + m := *(*map[string]bool)(p) + + if m == nil { + m = make(map[string]bool, 64) + } + + var err error + var key string + var val bool + input := b + + b = b[1:] + for { + key = "" + val = false + + b = skipSpaces(b) + + if len(b) != 0 && b[0] == '}' { + *(*unsafe.Pointer)(p) = *(*unsafe.Pointer)(unsafe.Pointer(&m)) + return b[1:], nil + } + + if i != 0 { + if len(b) == 0 { + return b, syntaxError(b, "unexpected end of JSON input after object field value") + } + if b[0] != ',' { + return b, syntaxError(b, "expected ',' after object field value but found '%c'", b[0]) + } + b = skipSpaces(b[1:]) + } + + if hasNullPrefix(b) { + return b, syntaxError(b, "cannot decode object key string from 'null' value") + } + + b, err = d.decodeString(b, unsafe.Pointer(&key)) + if err != nil { + return objectKeyError(b, err) + } + b = skipSpaces(b) + + if len(b) == 0 { + return b, syntaxError(b, "unexpected end of JSON input after object field key") + } + if b[0] != ':' { + return b, syntaxError(b, "expected ':' after object field key but found '%c'", b[0]) + } + b = skipSpaces(b[1:]) + + b, err = d.decodeBool(b, unsafe.Pointer(&val)) + if err != nil { + if _, r, _, err := d.parseValue(input); err != nil { + return r, err + } else { + b = r + } + if e, ok := err.(*UnmarshalTypeError); ok { + e.Struct = mapStringStringType.String() + e.Struct + e.Field = d.prependField(key, e.Field) + } + return b, err + } + + m[key] = val + i++ + } +} + +func (d decoder) decodeStruct(b []byte, p unsafe.Pointer, st *structType) ([]byte, error) { + if hasNullPrefix(b) { + return b[4:], nil + } + + if len(b) < 2 || b[0] != '{' { + return d.inputError(b, st.typ) + } + + var err error + var k []byte + var i int + + // memory buffer used to convert short field names to lowercase + var buf [64]byte + var key []byte + input := b + + b = b[1:] + for { + b = skipSpaces(b) + + if len(b) != 0 && b[0] == '}' { + return b[1:], nil + } + + if i != 0 { + if len(b) == 0 { + return b, syntaxError(b, "unexpected end of JSON input after object field value") + } + if b[0] != ',' { + return b, syntaxError(b, "expected ',' after object field value but found '%c'", b[0]) + } + b = skipSpaces(b[1:]) + } + i++ + + if hasNullPrefix(b) { + return b, syntaxError(b, "cannot decode object key string from 'null' value") + } + + k, b, _, err = d.parseStringUnquote(b, nil) + if err != nil { + return objectKeyError(b, err) + } + b = skipSpaces(b) + + if len(b) == 0 { + return b, syntaxError(b, "unexpected end of JSON input after object field key") + } + if b[0] != ':' { + return b, syntaxError(b, "expected ':' after object field key but found '%c'", b[0]) + } + b = skipSpaces(b[1:]) + + var f *structField + if len(st.keyset) != 0 { + if n := keyset.Lookup(st.keyset, k); n < len(st.fields) { + f = &st.fields[n] + } + } else { + f = st.fieldsIndex[string(k)] + } + + if f == nil && (d.flags&DontMatchCaseInsensitiveStructFields) == 0 { + key = appendToLower(buf[:0], k) + f = st.ficaseIndex[string(key)] + } + + if f == nil { + if (d.flags & DisallowUnknownFields) != 0 { + return b, fmt.Errorf("json: unknown field %q", k) + } + if _, b, _, err = d.parseValue(b); err != nil { + return b, err + } + continue + } + + if b, err = f.codec.decode(d, b, unsafe.Pointer(uintptr(p)+f.offset)); err != nil { + if _, r, _, err := d.parseValue(input); err != nil { + return r, err + } else { + b = r + } + if e, ok := err.(*UnmarshalTypeError); ok { + e.Struct = st.typ.String() + e.Struct + e.Field = d.prependField(string(k), e.Field) + } + return b, err + } + } +} + +func (d decoder) decodeEmbeddedStructPointer(b []byte, p unsafe.Pointer, t reflect.Type, unexported bool, offset uintptr, decode decodeFunc) ([]byte, error) { + v := *(*unsafe.Pointer)(p) + + if v == nil { + if unexported { + return nil, fmt.Errorf("json: cannot set embedded pointer to unexported struct: %s", t) + } + v = unsafe.Pointer(reflect.New(t).Pointer()) + *(*unsafe.Pointer)(p) = v + } + + return decode(d, b, unsafe.Pointer(uintptr(v)+offset)) +} + +func (d decoder) decodePointer(b []byte, p unsafe.Pointer, t reflect.Type, decode decodeFunc) ([]byte, error) { + if hasNullPrefix(b) { + pp := *(*unsafe.Pointer)(p) + if pp != nil && t.Kind() == reflect.Ptr { + return decode(d, b, pp) + } + *(*unsafe.Pointer)(p) = nil + return b[4:], nil + } + + v := *(*unsafe.Pointer)(p) + if v == nil { + v = unsafe.Pointer(reflect.New(t).Pointer()) + *(*unsafe.Pointer)(p) = v + } + + return decode(d, b, v) +} + +func (d decoder) decodeInterface(b []byte, p unsafe.Pointer) ([]byte, error) { + val := *(*any)(p) + *(*any)(p) = nil + + if t := reflect.TypeOf(val); t != nil && t.Kind() == reflect.Ptr { + if v := reflect.ValueOf(val); v.IsNil() || t.Elem().Kind() != reflect.Ptr { + // If the destination is nil the only value that is OK to decode is + // `null`, and the encoding/json package always nils the destination + // interface value in this case. + if hasNullPrefix(b) { + *(*any)(p) = nil + return b[4:], nil + } + } + + b, err := Parse(b, val, d.flags) + if err == nil { + *(*any)(p) = val + } + + return b, err + } + + v, b, k, err := d.parseValue(b) + if err != nil { + return b, err + } + + switch k.Class() { + case Object: + m := make(map[string]interface{}) + v, err = d.decodeMapStringInterface(v, unsafe.Pointer(&m)) + val = m + + case Array: + a := make([]interface{}, 0, 10) + v, err = d.decodeSlice(v, unsafe.Pointer(&a), unsafe.Sizeof(a[0]), sliceInterfaceType, decoder.decodeInterface) + val = a + + case String: + s := "" + v, err = d.decodeString(v, unsafe.Pointer(&s)) + val = s + + case Null: + v, val = nil, nil + + case Bool: + v, val = nil, k == True + + case Num: + v, err = d.decodeDynamicNumber(v, unsafe.Pointer(&val)) + + default: + return b, syntaxError(v, "expected token but found '%c'", v[0]) + } + + if err != nil { + return b, err + } + + if v = skipSpaces(v); len(v) != 0 { + return b, syntaxError(v, "unexpected trailing trailing tokens after json value") + } + + *(*any)(p) = val + return b, nil +} + +func (d decoder) decodeDynamicNumber(b []byte, p unsafe.Pointer) ([]byte, error) { + kind := Float + var err error + + // Only pre-parse for numeric kind if a conditional decode + // has been requested. + if d.anyFlagsSet(UseBigInt | UseInt64 | UseUint64) { + _, _, kind, err = d.parseNumber(b) + if err != nil { + return b, err + } + } + + var rem []byte + anyPtr := (*any)(p) + + // Mutually exclusive integer handling cases. + switch { + // If requested, attempt decode of positive integers as uint64. + case kind == Uint && d.anyFlagsSet(UseUint64): + rem, err = decodeInto[uint64](anyPtr, b, d, decoder.decodeUint64) + if err == nil { + return rem, err + } + + // If uint64 decode was not requested but int64 decode was requested, + // then attempt decode of positive integers as int64. + case kind == Uint && d.anyFlagsSet(UseInt64): + fallthrough + + // If int64 decode was requested, + // attempt decode of negative integers as int64. + case kind == Int && d.anyFlagsSet(UseInt64): + rem, err = decodeInto[int64](anyPtr, b, d, decoder.decodeInt64) + if err == nil { + return rem, err + } + } + + // Fallback numeric handling cases: + // these cannot be combined into the above switch, + // since these cases also handle overflow + // from the above cases, if decode was already attempted. + switch { + // If *big.Int decode was requested, handle that case for any integer. + case kind == Uint && d.anyFlagsSet(UseBigInt): + fallthrough + case kind == Int && d.anyFlagsSet(UseBigInt): + rem, err = decodeInto[*big.Int](anyPtr, b, d, bigIntDecoder) + + // If json.Number decode was requested, handle that for any number. + case d.anyFlagsSet(UseNumber): + rem, err = decodeInto[Number](anyPtr, b, d, decoder.decodeNumber) + + // Fall back to float64 decode when no special decoding has been requested. + default: + rem, err = decodeInto[float64](anyPtr, b, d, decoder.decodeFloat64) + } + + return rem, err +} + +func (d decoder) decodeMaybeEmptyInterface(b []byte, p unsafe.Pointer, t reflect.Type) ([]byte, error) { + if hasNullPrefix(b) { + *(*any)(p) = nil + return b[4:], nil + } + + if x := reflect.NewAt(t, p).Elem(); !x.IsNil() { + if e := x.Elem(); e.Kind() == reflect.Ptr { + return Parse(b, e.Interface(), d.flags) + } + } else if t.NumMethod() == 0 { // empty interface + return Parse(b, (*any)(p), d.flags) + } + + return d.decodeUnmarshalTypeError(b, p, t) +} + +func (d decoder) decodeUnmarshalTypeError(b []byte, _ unsafe.Pointer, t reflect.Type) ([]byte, error) { + v, b, _, err := d.parseValue(b) + if err != nil { + return b, err + } + return b, &UnmarshalTypeError{ + Value: string(v), + Type: t, + } +} + +func (d decoder) decodeRawMessage(b []byte, p unsafe.Pointer) ([]byte, error) { + v, r, _, err := d.parseValue(b) + if err != nil { + return d.inputError(b, rawMessageType) + } + + if (d.flags & DontCopyRawMessage) == 0 { + v = append(make([]byte, 0, len(v)), v...) + } + + *(*RawMessage)(p) = json.RawMessage(v) + return r, err +} + +func (d decoder) decodeJSONUnmarshaler(b []byte, p unsafe.Pointer, t reflect.Type, pointer bool) ([]byte, error) { + v, b, _, err := d.parseValue(b) + if err != nil { + return b, err + } + + u := reflect.NewAt(t, p) + if !pointer { + u = u.Elem() + t = t.Elem() + } + if u.IsNil() { + u.Set(reflect.New(t)) + } + + return b, u.Interface().(Unmarshaler).UnmarshalJSON(v) +} + +func (d decoder) decodeTextUnmarshaler(b []byte, p unsafe.Pointer, t reflect.Type, pointer bool) ([]byte, error) { + var value string + + v, b, k, err := d.parseValue(b) + if err != nil { + return b, err + } + if len(v) == 0 { + return d.inputError(v, t) + } + + switch k.Class() { + case Null: + return b, err + + case String: + s, _, _, err := d.parseStringUnquote(v, nil) + if err != nil { + return b, err + } + u := reflect.NewAt(t, p) + if !pointer { + u = u.Elem() + t = t.Elem() + } + if u.IsNil() { + u.Set(reflect.New(t)) + } + return b, u.Interface().(encoding.TextUnmarshaler).UnmarshalText(s) + + case Bool: + if k == True { + value = "true" + } else { + value = "false" + } + + case Num: + value = "number" + + case Object: + value = "object" + + case Array: + value = "array" + } + + return b, &UnmarshalTypeError{Value: value, Type: reflect.PointerTo(t)} +} + +func (d decoder) prependField(key, field string) string { + if field != "" { + return key + "." + field + } + return key +} + +func (d decoder) inputError(b []byte, t reflect.Type) ([]byte, error) { + if len(b) == 0 { + return nil, unexpectedEOF(b) + } + _, r, _, err := d.parseValue(b) + if err != nil { + return r, err + } + return skipSpaces(r), unmarshalTypeError(b, t) +} + +func decodeInto[T any](dest *any, b []byte, d decoder, fn decodeFunc) ([]byte, error) { + var v T + rem, err := fn(d, b, unsafe.Pointer(&v)) + if err == nil { + *dest = v + } + + return rem, err +} diff --git a/mcp/dsx-exchange-mcp/vendor/github.com/segmentio/encoding/json/encode.go b/mcp/dsx-exchange-mcp/vendor/github.com/segmentio/encoding/json/encode.go new file mode 100644 index 0000000..2a6da07 --- /dev/null +++ b/mcp/dsx-exchange-mcp/vendor/github.com/segmentio/encoding/json/encode.go @@ -0,0 +1,970 @@ +package json + +import ( + "encoding" + "fmt" + "math" + "reflect" + "sort" + "strconv" + "sync" + "time" + "unicode/utf8" + "unsafe" + + "github.com/segmentio/asm/base64" +) + +const hex = "0123456789abcdef" + +func (e encoder) encodeNull(b []byte, p unsafe.Pointer) ([]byte, error) { + return append(b, "null"...), nil +} + +func (e encoder) encodeBool(b []byte, p unsafe.Pointer) ([]byte, error) { + if *(*bool)(p) { + return append(b, "true"...), nil + } + return append(b, "false"...), nil +} + +func (e encoder) encodeInt(b []byte, p unsafe.Pointer) ([]byte, error) { + return appendInt(b, int64(*(*int)(p))), nil +} + +func (e encoder) encodeInt8(b []byte, p unsafe.Pointer) ([]byte, error) { + return appendInt(b, int64(*(*int8)(p))), nil +} + +func (e encoder) encodeInt16(b []byte, p unsafe.Pointer) ([]byte, error) { + return appendInt(b, int64(*(*int16)(p))), nil +} + +func (e encoder) encodeInt32(b []byte, p unsafe.Pointer) ([]byte, error) { + return appendInt(b, int64(*(*int32)(p))), nil +} + +func (e encoder) encodeInt64(b []byte, p unsafe.Pointer) ([]byte, error) { + return appendInt(b, *(*int64)(p)), nil +} + +func (e encoder) encodeUint(b []byte, p unsafe.Pointer) ([]byte, error) { + return appendUint(b, uint64(*(*uint)(p))), nil +} + +func (e encoder) encodeUintptr(b []byte, p unsafe.Pointer) ([]byte, error) { + return appendUint(b, uint64(*(*uintptr)(p))), nil +} + +func (e encoder) encodeUint8(b []byte, p unsafe.Pointer) ([]byte, error) { + return appendUint(b, uint64(*(*uint8)(p))), nil +} + +func (e encoder) encodeUint16(b []byte, p unsafe.Pointer) ([]byte, error) { + return appendUint(b, uint64(*(*uint16)(p))), nil +} + +func (e encoder) encodeUint32(b []byte, p unsafe.Pointer) ([]byte, error) { + return appendUint(b, uint64(*(*uint32)(p))), nil +} + +func (e encoder) encodeUint64(b []byte, p unsafe.Pointer) ([]byte, error) { + return appendUint(b, *(*uint64)(p)), nil +} + +func (e encoder) encodeFloat32(b []byte, p unsafe.Pointer) ([]byte, error) { + return e.encodeFloat(b, float64(*(*float32)(p)), 32) +} + +func (e encoder) encodeFloat64(b []byte, p unsafe.Pointer) ([]byte, error) { + return e.encodeFloat(b, *(*float64)(p), 64) +} + +func (e encoder) encodeFloat(b []byte, f float64, bits int) ([]byte, error) { + switch { + case math.IsNaN(f): + return b, &UnsupportedValueError{Value: reflect.ValueOf(f), Str: "NaN"} + case math.IsInf(f, 0): + return b, &UnsupportedValueError{Value: reflect.ValueOf(f), Str: "inf"} + } + + // Convert as if by ES6 number to string conversion. + // This matches most other JSON generators. + // See golang.org/issue/6384 and golang.org/issue/14135. + // Like fmt %g, but the exponent cutoffs are different + // and exponents themselves are not padded to two digits. + abs := math.Abs(f) + fmt := byte('f') + // Note: Must use float32 comparisons for underlying float32 value to get precise cutoffs right. + if abs != 0 { + if bits == 64 && (abs < 1e-6 || abs >= 1e21) || bits == 32 && (float32(abs) < 1e-6 || float32(abs) >= 1e21) { + fmt = 'e' + } + } + + b = strconv.AppendFloat(b, f, fmt, -1, int(bits)) + + if fmt == 'e' { + // clean up e-09 to e-9 + n := len(b) + if n >= 4 && b[n-4] == 'e' && b[n-3] == '-' && b[n-2] == '0' { + b[n-2] = b[n-1] + b = b[:n-1] + } + } + + return b, nil +} + +func (e encoder) encodeNumber(b []byte, p unsafe.Pointer) ([]byte, error) { + n := *(*Number)(p) + if n == "" { + n = "0" + } + + d := decoder{} + _, _, _, err := d.parseNumber(stringToBytes(string(n))) + if err != nil { + return b, err + } + + return append(b, n...), nil +} + +func (e encoder) encodeString(b []byte, p unsafe.Pointer) ([]byte, error) { + s := *(*string)(p) + if len(s) == 0 { + return append(b, `""`...), nil + } + i := 0 + j := 0 + escapeHTML := (e.flags & EscapeHTML) != 0 + + b = append(b, '"') + + if len(s) >= 8 { + if j = escapeIndex(s, escapeHTML); j < 0 { + return append(append(b, s...), '"'), nil + } + } + + for j < len(s) { + c := s[j] + + if c >= 0x20 && c <= 0x7f && c != '\\' && c != '"' && (!escapeHTML || (c != '<' && c != '>' && c != '&')) { + // fast path: most of the time, printable ascii characters are used + j++ + continue + } + + switch c { + case '\\', '"', '\b', '\f', '\n', '\r', '\t': + b = append(b, s[i:j]...) + b = append(b, '\\', escapeByteRepr(c)) + i = j + 1 + j = j + 1 + continue + + case '<', '>', '&': + b = append(b, s[i:j]...) + b = append(b, `\u00`...) + b = append(b, hex[c>>4], hex[c&0xF]) + i = j + 1 + j = j + 1 + continue + } + + // This encodes bytes < 0x20 except for \t, \n and \r. + if c < 0x20 { + b = append(b, s[i:j]...) + b = append(b, `\u00`...) + b = append(b, hex[c>>4], hex[c&0xF]) + i = j + 1 + j = j + 1 + continue + } + + r, size := utf8.DecodeRuneInString(s[j:]) + + if r == utf8.RuneError && size == 1 { + b = append(b, s[i:j]...) + b = append(b, `\ufffd`...) + i = j + size + j = j + size + continue + } + + switch r { + case '\u2028', '\u2029': + // U+2028 is LINE SEPARATOR. + // U+2029 is PARAGRAPH SEPARATOR. + // They are both technically valid characters in JSON strings, + // but don't work in JSONP, which has to be evaluated as JavaScript, + // and can lead to security holes there. It is valid JSON to + // escape them, so we do so unconditionally. + // See http://timelessrepo.com/json-isnt-a-javascript-subset for discussion. + b = append(b, s[i:j]...) + b = append(b, `\u202`...) + b = append(b, hex[r&0xF]) + i = j + size + j = j + size + continue + } + + j += size + } + + b = append(b, s[i:]...) + b = append(b, '"') + return b, nil +} + +func (e encoder) encodeToString(b []byte, p unsafe.Pointer, encode encodeFunc) ([]byte, error) { + i := len(b) + + b, err := encode(e, b, p) + if err != nil { + return b, err + } + + j := len(b) + s := b[i:] + + if b, err = e.encodeString(b, unsafe.Pointer(&s)); err != nil { + return b, err + } + + n := copy(b[i:], b[j:]) + return b[:i+n], nil +} + +func (e encoder) encodeBytes(b []byte, p unsafe.Pointer) ([]byte, error) { + v := *(*[]byte)(p) + if v == nil { + return append(b, "null"...), nil + } + + n := base64.StdEncoding.EncodedLen(len(v)) + 2 + + if avail := cap(b) - len(b); avail < n { + newB := make([]byte, cap(b)+(n-avail)) + copy(newB, b) + b = newB[:len(b)] + } + + i := len(b) + j := len(b) + n + + b = b[:j] + b[i] = '"' + base64.StdEncoding.Encode(b[i+1:j-1], v) + b[j-1] = '"' + return b, nil +} + +func (e encoder) encodeDuration(b []byte, p unsafe.Pointer) ([]byte, error) { + b = append(b, '"') + b = appendDuration(b, *(*time.Duration)(p)) + b = append(b, '"') + return b, nil +} + +func (e encoder) encodeTime(b []byte, p unsafe.Pointer) ([]byte, error) { + t := *(*time.Time)(p) + b = append(b, '"') + b = t.AppendFormat(b, time.RFC3339Nano) + b = append(b, '"') + return b, nil +} + +func (e encoder) encodeArray(b []byte, p unsafe.Pointer, n int, size uintptr, t reflect.Type, encode encodeFunc) ([]byte, error) { + start := len(b) + var err error + b = append(b, '[') + + for i := range n { + if i != 0 { + b = append(b, ',') + } + if b, err = encode(e, b, unsafe.Pointer(uintptr(p)+(uintptr(i)*size))); err != nil { + return b[:start], err + } + } + + b = append(b, ']') + return b, nil +} + +func (e encoder) encodeSlice(b []byte, p unsafe.Pointer, size uintptr, t reflect.Type, encode encodeFunc) ([]byte, error) { + s := (*slice)(p) + + if s.data == nil && s.len == 0 && s.cap == 0 { + return append(b, "null"...), nil + } + + return e.encodeArray(b, s.data, s.len, size, t, encode) +} + +func (e encoder) encodeMap(b []byte, p unsafe.Pointer, t reflect.Type, encodeKey, encodeValue encodeFunc, sortKeys sortFunc) ([]byte, error) { + m := reflect.NewAt(t, p).Elem() + if m.IsNil() { + return append(b, "null"...), nil + } + + keys := m.MapKeys() + if sortKeys != nil && (e.flags&SortMapKeys) != 0 { + sortKeys(keys) + } + + start := len(b) + var err error + b = append(b, '{') + + for i, k := range keys { + v := m.MapIndex(k) + + if i != 0 { + b = append(b, ',') + } + + if b, err = encodeKey(e, b, (*iface)(unsafe.Pointer(&k)).ptr); err != nil { + return b[:start], err + } + + b = append(b, ':') + + if b, err = encodeValue(e, b, (*iface)(unsafe.Pointer(&v)).ptr); err != nil { + return b[:start], err + } + } + + b = append(b, '}') + return b, nil +} + +type element struct { + key string + val any + raw RawMessage +} + +type mapslice struct { + elements []element +} + +func (m *mapslice) Len() int { return len(m.elements) } +func (m *mapslice) Less(i, j int) bool { return m.elements[i].key < m.elements[j].key } +func (m *mapslice) Swap(i, j int) { m.elements[i], m.elements[j] = m.elements[j], m.elements[i] } + +var mapslicePool = sync.Pool{ + New: func() any { return new(mapslice) }, +} + +func (e encoder) encodeMapStringInterface(b []byte, p unsafe.Pointer) ([]byte, error) { + m := *(*map[string]any)(p) + if m == nil { + return append(b, "null"...), nil + } + + if (e.flags & SortMapKeys) == 0 { + // Optimized code path when the program does not need the map keys to be + // sorted. + b = append(b, '{') + + if len(m) != 0 { + var err error + i := 0 + + for k, v := range m { + if i != 0 { + b = append(b, ',') + } + + b, _ = e.encodeString(b, unsafe.Pointer(&k)) + b = append(b, ':') + + b, err = Append(b, v, e.flags) + if err != nil { + return b, err + } + + i++ + } + } + + b = append(b, '}') + return b, nil + } + + s := mapslicePool.Get().(*mapslice) + if cap(s.elements) < len(m) { + s.elements = make([]element, 0, align(10, uintptr(len(m)))) + } + for key, val := range m { + s.elements = append(s.elements, element{key: key, val: val}) + } + sort.Sort(s) + + start := len(b) + var err error + b = append(b, '{') + + for i, elem := range s.elements { + if i != 0 { + b = append(b, ',') + } + + b, _ = e.encodeString(b, unsafe.Pointer(&elem.key)) + b = append(b, ':') + + b, err = Append(b, elem.val, e.flags) + if err != nil { + break + } + } + + for i := range s.elements { + s.elements[i] = element{} + } + + s.elements = s.elements[:0] + mapslicePool.Put(s) + + if err != nil { + return b[:start], err + } + + b = append(b, '}') + return b, nil +} + +func (e encoder) encodeMapStringRawMessage(b []byte, p unsafe.Pointer) ([]byte, error) { + m := *(*map[string]RawMessage)(p) + if m == nil { + return append(b, "null"...), nil + } + + if (e.flags & SortMapKeys) == 0 { + // Optimized code path when the program does not need the map keys to be + // sorted. + b = append(b, '{') + + if len(m) != 0 { + var err error + i := 0 + + for k, v := range m { + if i != 0 { + b = append(b, ',') + } + + // encodeString doesn't return errors so we ignore it here + b, _ = e.encodeString(b, unsafe.Pointer(&k)) + b = append(b, ':') + + b, err = e.encodeRawMessage(b, unsafe.Pointer(&v)) + if err != nil { + break + } + + i++ + } + } + + b = append(b, '}') + return b, nil + } + + s := mapslicePool.Get().(*mapslice) + if cap(s.elements) < len(m) { + s.elements = make([]element, 0, align(10, uintptr(len(m)))) + } + for key, raw := range m { + s.elements = append(s.elements, element{key: key, raw: raw}) + } + sort.Sort(s) + + start := len(b) + var err error + b = append(b, '{') + + for i, elem := range s.elements { + if i != 0 { + b = append(b, ',') + } + + b, _ = e.encodeString(b, unsafe.Pointer(&elem.key)) + b = append(b, ':') + + b, err = e.encodeRawMessage(b, unsafe.Pointer(&elem.raw)) + if err != nil { + break + } + } + + for i := range s.elements { + s.elements[i] = element{} + } + + s.elements = s.elements[:0] + mapslicePool.Put(s) + + if err != nil { + return b[:start], err + } + + b = append(b, '}') + return b, nil +} + +func (e encoder) encodeMapStringString(b []byte, p unsafe.Pointer) ([]byte, error) { + m := *(*map[string]string)(p) + if m == nil { + return append(b, "null"...), nil + } + + if (e.flags & SortMapKeys) == 0 { + // Optimized code path when the program does not need the map keys to be + // sorted. + b = append(b, '{') + + if len(m) != 0 { + i := 0 + + for k, v := range m { + if i != 0 { + b = append(b, ',') + } + + // encodeString never returns an error so we ignore it here + b, _ = e.encodeString(b, unsafe.Pointer(&k)) + b = append(b, ':') + b, _ = e.encodeString(b, unsafe.Pointer(&v)) + + i++ + } + } + + b = append(b, '}') + return b, nil + } + + s := mapslicePool.Get().(*mapslice) + if cap(s.elements) < len(m) { + s.elements = make([]element, 0, align(10, uintptr(len(m)))) + } + for key, val := range m { + v := val + s.elements = append(s.elements, element{key: key, val: &v}) + } + sort.Sort(s) + + b = append(b, '{') + + for i, elem := range s.elements { + if i != 0 { + b = append(b, ',') + } + + // encodeString never returns an error so we ignore it here + b, _ = e.encodeString(b, unsafe.Pointer(&elem.key)) + b = append(b, ':') + b, _ = e.encodeString(b, unsafe.Pointer(elem.val.(*string))) + } + + for i := range s.elements { + s.elements[i] = element{} + } + + s.elements = s.elements[:0] + mapslicePool.Put(s) + + b = append(b, '}') + return b, nil +} + +func (e encoder) encodeMapStringStringSlice(b []byte, p unsafe.Pointer) ([]byte, error) { + m := *(*map[string][]string)(p) + if m == nil { + return append(b, "null"...), nil + } + + stringSize := unsafe.Sizeof("") + + if (e.flags & SortMapKeys) == 0 { + // Optimized code path when the program does not need the map keys to be + // sorted. + b = append(b, '{') + + if len(m) != 0 { + var err error + i := 0 + + for k, v := range m { + if i != 0 { + b = append(b, ',') + } + + b, _ = e.encodeString(b, unsafe.Pointer(&k)) + b = append(b, ':') + + b, err = e.encodeSlice(b, unsafe.Pointer(&v), stringSize, sliceStringType, encoder.encodeString) + if err != nil { + return b, err + } + + i++ + } + } + + b = append(b, '}') + return b, nil + } + + s := mapslicePool.Get().(*mapslice) + if cap(s.elements) < len(m) { + s.elements = make([]element, 0, align(10, uintptr(len(m)))) + } + for key, val := range m { + v := val + s.elements = append(s.elements, element{key: key, val: &v}) + } + sort.Sort(s) + + start := len(b) + var err error + b = append(b, '{') + + for i, elem := range s.elements { + if i != 0 { + b = append(b, ',') + } + + b, _ = e.encodeString(b, unsafe.Pointer(&elem.key)) + b = append(b, ':') + + b, err = e.encodeSlice(b, unsafe.Pointer(elem.val.(*[]string)), stringSize, sliceStringType, encoder.encodeString) + if err != nil { + break + } + } + + for i := range s.elements { + s.elements[i] = element{} + } + + s.elements = s.elements[:0] + mapslicePool.Put(s) + + if err != nil { + return b[:start], err + } + + b = append(b, '}') + return b, nil +} + +func (e encoder) encodeMapStringBool(b []byte, p unsafe.Pointer) ([]byte, error) { + m := *(*map[string]bool)(p) + if m == nil { + return append(b, "null"...), nil + } + + if (e.flags & SortMapKeys) == 0 { + // Optimized code path when the program does not need the map keys to be + // sorted. + b = append(b, '{') + + if len(m) != 0 { + i := 0 + + for k, v := range m { + if i != 0 { + b = append(b, ',') + } + + // encodeString never returns an error so we ignore it here + b, _ = e.encodeString(b, unsafe.Pointer(&k)) + if v { + b = append(b, ":true"...) + } else { + b = append(b, ":false"...) + } + + i++ + } + } + + b = append(b, '}') + return b, nil + } + + s := mapslicePool.Get().(*mapslice) + if cap(s.elements) < len(m) { + s.elements = make([]element, 0, align(10, uintptr(len(m)))) + } + for key, val := range m { + s.elements = append(s.elements, element{key: key, val: val}) + } + sort.Sort(s) + + b = append(b, '{') + + for i, elem := range s.elements { + if i != 0 { + b = append(b, ',') + } + + // encodeString never returns an error so we ignore it here + b, _ = e.encodeString(b, unsafe.Pointer(&elem.key)) + if elem.val.(bool) { + b = append(b, ":true"...) + } else { + b = append(b, ":false"...) + } + } + + for i := range s.elements { + s.elements[i] = element{} + } + + s.elements = s.elements[:0] + mapslicePool.Put(s) + + b = append(b, '}') + return b, nil +} + +func (e encoder) encodeStruct(b []byte, p unsafe.Pointer, st *structType) ([]byte, error) { + start := len(b) + var err error + var k string + var n int + b = append(b, '{') + + escapeHTML := (e.flags & EscapeHTML) != 0 + + for i := range st.fields { + f := &st.fields[i] + v := unsafe.Pointer(uintptr(p) + f.offset) + + if f.omitempty && f.empty(v) { + continue + } + + if escapeHTML { + k = f.html + } else { + k = f.json + } + + lengthBeforeKey := len(b) + + if n != 0 { + b = append(b, k...) + } else { + b = append(b, k[1:]...) + } + + if b, err = f.codec.encode(e, b, v); err != nil { + if err == (rollback{}) { + b = b[:lengthBeforeKey] + continue + } + return b[:start], err + } + + n++ + } + + b = append(b, '}') + return b, nil +} + +type rollback struct{} + +func (rollback) Error() string { return "rollback" } + +func (e encoder) encodeEmbeddedStructPointer(b []byte, p unsafe.Pointer, t reflect.Type, unexported bool, offset uintptr, encode encodeFunc) ([]byte, error) { + p = *(*unsafe.Pointer)(p) + if p == nil { + return b, rollback{} + } + return encode(e, b, unsafe.Pointer(uintptr(p)+offset)) +} + +func (e encoder) encodePointer(b []byte, p unsafe.Pointer, t reflect.Type, encode encodeFunc) ([]byte, error) { + if p = *(*unsafe.Pointer)(p); p != nil { + if e.ptrDepth++; e.ptrDepth >= startDetectingCyclesAfter { + if _, seen := e.ptrSeen[p]; seen { + // TODO: reconstruct the reflect.Value from p + t so we can set + // the erorr's Value field? + return b, &UnsupportedValueError{Str: fmt.Sprintf("encountered a cycle via %s", t)} + } + if e.ptrSeen == nil { + e.ptrSeen = make(map[unsafe.Pointer]struct{}) + } + e.ptrSeen[p] = struct{}{} + defer delete(e.ptrSeen, p) + } + return encode(e, b, p) + } + return e.encodeNull(b, nil) +} + +func (e encoder) encodeInterface(b []byte, p unsafe.Pointer) ([]byte, error) { + return Append(b, *(*any)(p), e.flags) +} + +func (e encoder) encodeMaybeEmptyInterface(b []byte, p unsafe.Pointer, t reflect.Type) ([]byte, error) { + return Append(b, reflect.NewAt(t, p).Elem().Interface(), e.flags) +} + +func (e encoder) encodeUnsupportedTypeError(b []byte, p unsafe.Pointer, t reflect.Type) ([]byte, error) { + return b, &UnsupportedTypeError{Type: t} +} + +func (e encoder) encodeRawMessage(b []byte, p unsafe.Pointer) ([]byte, error) { + v := *(*RawMessage)(p) + + if v == nil { + return append(b, "null"...), nil + } + + var s []byte + + if (e.flags & TrustRawMessage) != 0 { + s = v + } else { + var err error + v = skipSpaces(v) // don't assume that a RawMessage starts with a token. + d := decoder{} + s, _, _, err = d.parseValue(v) + if err != nil { + return b, &UnsupportedValueError{Value: reflect.ValueOf(v), Str: err.Error()} + } + } + + if (e.flags & EscapeHTML) != 0 { + return appendCompactEscapeHTML(b, s), nil + } + + return append(b, s...), nil +} + +func (e encoder) encodeJSONMarshaler(b []byte, p unsafe.Pointer, t reflect.Type, pointer bool) ([]byte, error) { + v := reflect.NewAt(t, p) + + if !pointer { + v = v.Elem() + } + + switch v.Kind() { + case reflect.Ptr, reflect.Interface: + if v.IsNil() { + return append(b, "null"...), nil + } + } + + j, err := v.Interface().(Marshaler).MarshalJSON() + if err != nil { + return b, err + } + + d := decoder{} + s, _, _, err := d.parseValue(j) + if err != nil { + return b, &MarshalerError{Type: t, Err: err} + } + + if (e.flags & EscapeHTML) != 0 { + return appendCompactEscapeHTML(b, s), nil + } + + return append(b, s...), nil +} + +func (e encoder) encodeTextMarshaler(b []byte, p unsafe.Pointer, t reflect.Type, pointer bool) ([]byte, error) { + v := reflect.NewAt(t, p) + + if !pointer { + v = v.Elem() + } + + switch v.Kind() { + case reflect.Ptr, reflect.Interface: + if v.IsNil() { + return append(b, `null`...), nil + } + } + + s, err := v.Interface().(encoding.TextMarshaler).MarshalText() + if err != nil { + return b, err + } + + return e.encodeString(b, unsafe.Pointer(&s)) +} + +func appendCompactEscapeHTML(dst []byte, src []byte) []byte { + start := 0 + escape := false + inString := false + + for i, c := range src { + if !inString { + switch c { + case '"': // enter string + inString = true + case ' ', '\n', '\r', '\t': // skip space + if start < i { + dst = append(dst, src[start:i]...) + } + start = i + 1 + } + continue + } + + if escape { + escape = false + continue + } + + if c == '\\' { + escape = true + continue + } + + if c == '"' { + inString = false + continue + } + + if c == '<' || c == '>' || c == '&' { + if start < i { + dst = append(dst, src[start:i]...) + } + dst = append(dst, `\u00`...) + dst = append(dst, hex[c>>4], hex[c&0xF]) + start = i + 1 + continue + } + + // Convert U+2028 and U+2029 (E2 80 A8 and E2 80 A9). + if c == 0xE2 && i+2 < len(src) && src[i+1] == 0x80 && src[i+2]&^1 == 0xA8 { + if start < i { + dst = append(dst, src[start:i]...) + } + dst = append(dst, `\u202`...) + dst = append(dst, hex[src[i+2]&0xF]) + start = i + 3 + continue + } + } + + if start < len(src) { + dst = append(dst, src[start:]...) + } + + return dst +} diff --git a/mcp/dsx-exchange-mcp/vendor/github.com/segmentio/encoding/json/int.go b/mcp/dsx-exchange-mcp/vendor/github.com/segmentio/encoding/json/int.go new file mode 100644 index 0000000..b53149c --- /dev/null +++ b/mcp/dsx-exchange-mcp/vendor/github.com/segmentio/encoding/json/int.go @@ -0,0 +1,98 @@ +package json + +import ( + "unsafe" +) + +var endianness int + +func init() { + var b [2]byte + *(*uint16)(unsafe.Pointer(&b)) = uint16(0xABCD) + + switch b[0] { + case 0xCD: + endianness = 0 // LE + case 0xAB: + endianness = 1 // BE + default: + panic("could not determine endianness") + } +} + +// "00010203...96979899" cast to []uint16 +var intLELookup = [100]uint16{ + 0x3030, 0x3130, 0x3230, 0x3330, 0x3430, 0x3530, 0x3630, 0x3730, 0x3830, 0x3930, + 0x3031, 0x3131, 0x3231, 0x3331, 0x3431, 0x3531, 0x3631, 0x3731, 0x3831, 0x3931, + 0x3032, 0x3132, 0x3232, 0x3332, 0x3432, 0x3532, 0x3632, 0x3732, 0x3832, 0x3932, + 0x3033, 0x3133, 0x3233, 0x3333, 0x3433, 0x3533, 0x3633, 0x3733, 0x3833, 0x3933, + 0x3034, 0x3134, 0x3234, 0x3334, 0x3434, 0x3534, 0x3634, 0x3734, 0x3834, 0x3934, + 0x3035, 0x3135, 0x3235, 0x3335, 0x3435, 0x3535, 0x3635, 0x3735, 0x3835, 0x3935, + 0x3036, 0x3136, 0x3236, 0x3336, 0x3436, 0x3536, 0x3636, 0x3736, 0x3836, 0x3936, + 0x3037, 0x3137, 0x3237, 0x3337, 0x3437, 0x3537, 0x3637, 0x3737, 0x3837, 0x3937, + 0x3038, 0x3138, 0x3238, 0x3338, 0x3438, 0x3538, 0x3638, 0x3738, 0x3838, 0x3938, + 0x3039, 0x3139, 0x3239, 0x3339, 0x3439, 0x3539, 0x3639, 0x3739, 0x3839, 0x3939, +} + +var intBELookup = [100]uint16{ + 0x3030, 0x3031, 0x3032, 0x3033, 0x3034, 0x3035, 0x3036, 0x3037, 0x3038, 0x3039, + 0x3130, 0x3131, 0x3132, 0x3133, 0x3134, 0x3135, 0x3136, 0x3137, 0x3138, 0x3139, + 0x3230, 0x3231, 0x3232, 0x3233, 0x3234, 0x3235, 0x3236, 0x3237, 0x3238, 0x3239, + 0x3330, 0x3331, 0x3332, 0x3333, 0x3334, 0x3335, 0x3336, 0x3337, 0x3338, 0x3339, + 0x3430, 0x3431, 0x3432, 0x3433, 0x3434, 0x3435, 0x3436, 0x3437, 0x3438, 0x3439, + 0x3530, 0x3531, 0x3532, 0x3533, 0x3534, 0x3535, 0x3536, 0x3537, 0x3538, 0x3539, + 0x3630, 0x3631, 0x3632, 0x3633, 0x3634, 0x3635, 0x3636, 0x3637, 0x3638, 0x3639, + 0x3730, 0x3731, 0x3732, 0x3733, 0x3734, 0x3735, 0x3736, 0x3737, 0x3738, 0x3739, + 0x3830, 0x3831, 0x3832, 0x3833, 0x3834, 0x3835, 0x3836, 0x3837, 0x3838, 0x3839, + 0x3930, 0x3931, 0x3932, 0x3933, 0x3934, 0x3935, 0x3936, 0x3937, 0x3938, 0x3939, +} + +var intLookup = [2]*[100]uint16{&intLELookup, &intBELookup} + +func appendInt(b []byte, n int64) []byte { + return formatInteger(b, uint64(n), n < 0) +} + +func appendUint(b []byte, n uint64) []byte { + return formatInteger(b, n, false) +} + +func formatInteger(out []byte, n uint64, negative bool) []byte { + if !negative { + if n < 10 { + return append(out, byte(n+'0')) + } else if n < 100 { + u := intLELookup[n] + return append(out, byte(u), byte(u>>8)) + } + } else { + n = -n + } + + lookup := intLookup[endianness] + + var b [22]byte + u := (*[11]uint16)(unsafe.Pointer(&b)) + i := 11 + + for n >= 100 { + j := n % 100 + n /= 100 + i-- + u[i] = lookup[j] + } + + i-- + u[i] = lookup[n] + + i *= 2 // convert to byte index + if n < 10 { + i++ // remove leading zero + } + if negative { + i-- + b[i] = '-' + } + + return append(out, b[i:]...) +} diff --git a/mcp/dsx-exchange-mcp/vendor/github.com/segmentio/encoding/json/json.go b/mcp/dsx-exchange-mcp/vendor/github.com/segmentio/encoding/json/json.go new file mode 100644 index 0000000..028fd1f --- /dev/null +++ b/mcp/dsx-exchange-mcp/vendor/github.com/segmentio/encoding/json/json.go @@ -0,0 +1,594 @@ +package json + +import ( + "bytes" + "encoding/json" + "io" + "math/bits" + "reflect" + "runtime" + "sync" + "unsafe" +) + +// Delim is documented at https://golang.org/pkg/encoding/json/#Delim +type Delim = json.Delim + +// InvalidUTF8Error is documented at https://golang.org/pkg/encoding/json/#InvalidUTF8Error +type InvalidUTF8Error = json.InvalidUTF8Error //nolint:staticcheck // compat. + +// InvalidUnmarshalError is documented at https://golang.org/pkg/encoding/json/#InvalidUnmarshalError +type InvalidUnmarshalError = json.InvalidUnmarshalError + +// Marshaler is documented at https://golang.org/pkg/encoding/json/#Marshaler +type Marshaler = json.Marshaler + +// MarshalerError is documented at https://golang.org/pkg/encoding/json/#MarshalerError +type MarshalerError = json.MarshalerError + +// Number is documented at https://golang.org/pkg/encoding/json/#Number +type Number = json.Number + +// RawMessage is documented at https://golang.org/pkg/encoding/json/#RawMessage +type RawMessage = json.RawMessage + +// A SyntaxError is a description of a JSON syntax error. +type SyntaxError = json.SyntaxError + +// Token is documented at https://golang.org/pkg/encoding/json/#Token +type Token = json.Token + +// UnmarshalFieldError is documented at https://golang.org/pkg/encoding/json/#UnmarshalFieldError +type UnmarshalFieldError = json.UnmarshalFieldError //nolint:staticcheck // compat. + +// UnmarshalTypeError is documented at https://golang.org/pkg/encoding/json/#UnmarshalTypeError +type UnmarshalTypeError = json.UnmarshalTypeError + +// Unmarshaler is documented at https://golang.org/pkg/encoding/json/#Unmarshaler +type Unmarshaler = json.Unmarshaler + +// UnsupportedTypeError is documented at https://golang.org/pkg/encoding/json/#UnsupportedTypeError +type UnsupportedTypeError = json.UnsupportedTypeError + +// UnsupportedValueError is documented at https://golang.org/pkg/encoding/json/#UnsupportedValueError +type UnsupportedValueError = json.UnsupportedValueError + +// AppendFlags is a type used to represent configuration options that can be +// applied when formatting json output. +type AppendFlags uint32 + +const ( + // EscapeHTML is a formatting flag used to to escape HTML in json strings. + EscapeHTML AppendFlags = 1 << iota + + // SortMapKeys is formatting flag used to enable sorting of map keys when + // encoding JSON (this matches the behavior of the standard encoding/json + // package). + SortMapKeys + + // TrustRawMessage is a performance optimization flag to skip value + // checking of raw messages. It should only be used if the values are + // known to be valid json (e.g., they were created by json.Unmarshal). + TrustRawMessage + + // appendNewline is a formatting flag to enable the addition of a newline + // in Encode (this matches the behavior of the standard encoding/json + // package). + appendNewline +) + +// ParseFlags is a type used to represent configuration options that can be +// applied when parsing json input. +type ParseFlags uint32 + +func (flags ParseFlags) has(f ParseFlags) bool { + return (flags & f) != 0 +} + +func (f ParseFlags) kind() Kind { + return Kind((f >> kindOffset) & 0xFF) +} + +func (f ParseFlags) withKind(kind Kind) ParseFlags { + return (f & ^(ParseFlags(0xFF) << kindOffset)) | (ParseFlags(kind) << kindOffset) +} + +const ( + // DisallowUnknownFields is a parsing flag used to prevent decoding of + // objects to Go struct values when a field of the input does not match + // with any of the struct fields. + DisallowUnknownFields ParseFlags = 1 << iota + + // UseNumber is a parsing flag used to load numeric values as Number + // instead of float64. + UseNumber + + // DontCopyString is a parsing flag used to provide zero-copy support when + // loading string values from a json payload. It is not always possible to + // avoid dynamic memory allocations, for example when a string is escaped in + // the json data a new buffer has to be allocated, but when the `wire` value + // can be used as content of a Go value the decoder will simply point into + // the input buffer. + DontCopyString + + // DontCopyNumber is a parsing flag used to provide zero-copy support when + // loading Number values (see DontCopyString and DontCopyRawMessage). + DontCopyNumber + + // DontCopyRawMessage is a parsing flag used to provide zero-copy support + // when loading RawMessage values from a json payload. When used, the + // RawMessage values will not be allocated into new memory buffers and + // will instead point directly to the area of the input buffer where the + // value was found. + DontCopyRawMessage + + // DontMatchCaseInsensitiveStructFields is a parsing flag used to prevent + // matching fields in a case-insensitive way. This can prevent degrading + // performance on case conversions, and can also act as a stricter decoding + // mode. + DontMatchCaseInsensitiveStructFields + + // Decode integers into *big.Int. + // Takes precedence over UseNumber for integers. + UseBigInt + + // Decode in-range integers to int64. + // Takes precedence over UseNumber and UseBigInt for in-range integers. + UseInt64 + + // Decode in-range positive integers to uint64. + // Takes precedence over UseNumber, UseBigInt, and UseInt64 + // for positive, in-range integers. + UseUint64 + + // ZeroCopy is a parsing flag that combines all the copy optimizations + // available in the package. + // + // The zero-copy optimizations are better used in request-handler style + // code where none of the values are retained after the handler returns. + ZeroCopy = DontCopyString | DontCopyNumber | DontCopyRawMessage + + // validAsciiPrint is an internal flag indicating that the input contains + // only valid ASCII print chars (0x20 <= c <= 0x7E). If the flag is unset, + // it's unknown whether the input is valid ASCII print. + validAsciiPrint ParseFlags = 1 << 28 + + // noBackslach is an internal flag indicating that the input does not + // contain a backslash. If the flag is unset, it's unknown whether the + // input contains a backslash. + noBackslash ParseFlags = 1 << 29 + + // Bit offset where the kind of the json value is stored. + // + // See Kind in token.go for the enum. + kindOffset ParseFlags = 16 +) + +// Kind represents the different kinds of value that exist in JSON. +type Kind uint + +const ( + Undefined Kind = 0 + + Null Kind = 1 // Null is not zero, so we keep zero for "undefined". + + Bool Kind = 2 // Bit two is set to 1, means it's a boolean. + False Kind = 2 // Bool + 0 + True Kind = 3 // Bool + 1 + + Num Kind = 4 // Bit three is set to 1, means it's a number. + Uint Kind = 5 // Num + 1 + Int Kind = 6 // Num + 2 + Float Kind = 7 // Num + 3 + + String Kind = 8 // Bit four is set to 1, means it's a string. + Unescaped Kind = 9 // String + 1 + + Array Kind = 16 // Equivalent to Delim == '[' + Object Kind = 32 // Equivalent to Delim == '{' +) + +// Class returns the class of k. +func (k Kind) Class() Kind { return Kind(1 << uint(bits.Len(uint(k))-1)) } + +// Append acts like Marshal but appends the json representation to b instead of +// always reallocating a new slice. +func Append(b []byte, x any, flags AppendFlags) ([]byte, error) { + if x == nil { + // Special case for nil values because it makes the rest of the code + // simpler to assume that it won't be seeing nil pointers. + return append(b, "null"...), nil + } + + t := reflect.TypeOf(x) + p := (*iface)(unsafe.Pointer(&x)).ptr + + cache := cacheLoad() + c, found := cache[typeid(t)] + + if !found { + c = constructCachedCodec(t, cache) + } + + b, err := c.encode(encoder{flags: flags}, b, p) + runtime.KeepAlive(x) + return b, err +} + +// Escape is a convenience helper to construct an escaped JSON string from s. +// The function escales HTML characters, for more control over the escape +// behavior and to write to a pre-allocated buffer, use AppendEscape. +func Escape(s string) []byte { + // +10 for extra escape characters, maybe not enough and the buffer will + // be reallocated. + b := make([]byte, 0, len(s)+10) + return AppendEscape(b, s, EscapeHTML) +} + +// AppendEscape appends s to b with the string escaped as a JSON value. +// This will include the starting and ending quote characters, and the +// appropriate characters will be escaped correctly for JSON encoding. +func AppendEscape(b []byte, s string, flags AppendFlags) []byte { + e := encoder{flags: flags} + b, _ = e.encodeString(b, unsafe.Pointer(&s)) + return b +} + +// Unescape is a convenience helper to unescape a JSON value. +// For more control over the unescape behavior and +// to write to a pre-allocated buffer, use AppendUnescape. +func Unescape(s []byte) []byte { + b := make([]byte, 0, len(s)) + return AppendUnescape(b, s, ParseFlags(0)) +} + +// AppendUnescape appends s to b with the string unescaped as a JSON value. +// This will remove starting and ending quote characters, and the +// appropriate characters will be escaped correctly as if JSON decoded. +// New space will be reallocated if more space is needed. +func AppendUnescape(b []byte, s []byte, flags ParseFlags) []byte { + d := decoder{flags: flags} + buf := new(string) + d.decodeString(s, unsafe.Pointer(buf)) + return append(b, *buf...) +} + +// Compact is documented at https://golang.org/pkg/encoding/json/#Compact +func Compact(dst *bytes.Buffer, src []byte) error { + return json.Compact(dst, src) +} + +// HTMLEscape is documented at https://golang.org/pkg/encoding/json/#HTMLEscape +func HTMLEscape(dst *bytes.Buffer, src []byte) { + json.HTMLEscape(dst, src) +} + +// Indent is documented at https://golang.org/pkg/encoding/json/#Indent +func Indent(dst *bytes.Buffer, src []byte, prefix, indent string) error { + return json.Indent(dst, src, prefix, indent) +} + +// Marshal is documented at https://golang.org/pkg/encoding/json/#Marshal +func Marshal(x any) ([]byte, error) { + var err error + buf := encoderBufferPool.Get().(*encoderBuffer) + + if buf.data, err = Append(buf.data[:0], x, EscapeHTML|SortMapKeys); err != nil { + return nil, err + } + + b := make([]byte, len(buf.data)) + copy(b, buf.data) + encoderBufferPool.Put(buf) + return b, nil +} + +// MarshalIndent is documented at https://golang.org/pkg/encoding/json/#MarshalIndent +func MarshalIndent(x any, prefix, indent string) ([]byte, error) { + b, err := Marshal(x) + + if err == nil { + tmp := &bytes.Buffer{} + tmp.Grow(2 * len(b)) + + Indent(tmp, b, prefix, indent) + b = tmp.Bytes() + } + + return b, err +} + +// Unmarshal is documented at https://golang.org/pkg/encoding/json/#Unmarshal +func Unmarshal(b []byte, x any) error { + r, err := Parse(b, x, 0) + if len(r) != 0 { + if _, ok := err.(*SyntaxError); !ok { + // The encoding/json package prioritizes reporting errors caused by + // unexpected trailing bytes over other issues; here we emulate this + // behavior by overriding the error. + err = syntaxError(r, "invalid character '%c' after top-level value", r[0]) + } + } + return err +} + +// Parse behaves like Unmarshal but the caller can pass a set of flags to +// configure the parsing behavior. +func Parse(b []byte, x any, flags ParseFlags) ([]byte, error) { + t := reflect.TypeOf(x) + p := (*iface)(unsafe.Pointer(&x)).ptr + + d := decoder{flags: flags | internalParseFlags(b)} + + b = skipSpaces(b) + + if t == nil || p == nil || t.Kind() != reflect.Ptr { + _, r, _, err := d.parseValue(b) + r = skipSpaces(r) + if err != nil { + return r, err + } + return r, &InvalidUnmarshalError{Type: t} + } + t = t.Elem() + + cache := cacheLoad() + c, found := cache[typeid(t)] + + if !found { + c = constructCachedCodec(t, cache) + } + + r, err := c.decode(d, b, p) + return skipSpaces(r), err +} + +// Valid is documented at https://golang.org/pkg/encoding/json/#Valid +func Valid(data []byte) bool { + data = skipSpaces(data) + d := decoder{flags: internalParseFlags(data)} + _, data, _, err := d.parseValue(data) + if err != nil { + return false + } + return len(skipSpaces(data)) == 0 +} + +// Decoder is documented at https://golang.org/pkg/encoding/json/#Decoder +type Decoder struct { + reader io.Reader + buffer []byte + remain []byte + inputOffset int64 + err error + flags ParseFlags +} + +// NewDecoder is documented at https://golang.org/pkg/encoding/json/#NewDecoder +func NewDecoder(r io.Reader) *Decoder { return &Decoder{reader: r} } + +// Buffered is documented at https://golang.org/pkg/encoding/json/#Decoder.Buffered +func (dec *Decoder) Buffered() io.Reader { + return bytes.NewReader(dec.remain) +} + +// Decode is documented at https://golang.org/pkg/encoding/json/#Decoder.Decode +func (dec *Decoder) Decode(v any) error { + raw, err := dec.readValue() + if err != nil { + return err + } + _, err = Parse(raw, v, dec.flags) + return err +} + +const ( + minBufferSize = 32768 + minReadSize = 4096 +) + +// readValue reads one JSON value from the buffer and returns its raw bytes. It +// is optimized for the "one JSON value per line" case. +func (dec *Decoder) readValue() (v []byte, err error) { + var n int + var r []byte + d := decoder{flags: dec.flags} + + for { + if len(dec.remain) != 0 { + v, r, _, err = d.parseValue(dec.remain) + if err == nil { + dec.remain, n = skipSpacesN(r) + dec.inputOffset += int64(len(v) + n) + return + } + if len(r) != 0 { + // Parsing of the next JSON value stopped at a position other + // than the end of the input buffer, which indicaates that a + // syntax error was encountered. + return + } + } + + if err = dec.err; err != nil { + if len(dec.remain) != 0 && err == io.EOF { + err = io.ErrUnexpectedEOF + } + return + } + + if dec.buffer == nil { + dec.buffer = make([]byte, 0, minBufferSize) + } else { + dec.buffer = dec.buffer[:copy(dec.buffer[:cap(dec.buffer)], dec.remain)] + dec.remain = nil + } + + if (cap(dec.buffer) - len(dec.buffer)) < minReadSize { + buf := make([]byte, len(dec.buffer), 2*cap(dec.buffer)) + copy(buf, dec.buffer) + dec.buffer = buf + } + + n, err = io.ReadFull(dec.reader, dec.buffer[len(dec.buffer):cap(dec.buffer)]) + if n > 0 { + dec.buffer = dec.buffer[:len(dec.buffer)+n] + if err != nil { + err = nil + } + } else if err == io.ErrUnexpectedEOF { + err = io.EOF + } + dec.remain, n = skipSpacesN(dec.buffer) + d.flags = dec.flags | internalParseFlags(dec.remain) + dec.inputOffset += int64(n) + dec.err = err + } +} + +// DisallowUnknownFields is documented at https://golang.org/pkg/encoding/json/#Decoder.DisallowUnknownFields +func (dec *Decoder) DisallowUnknownFields() { dec.flags |= DisallowUnknownFields } + +// UseNumber is documented at https://golang.org/pkg/encoding/json/#Decoder.UseNumber +func (dec *Decoder) UseNumber() { dec.flags |= UseNumber } + +// DontCopyString is an extension to the standard encoding/json package +// which instructs the decoder to not copy strings loaded from the json +// payloads when possible. +func (dec *Decoder) DontCopyString() { dec.flags |= DontCopyString } + +// DontCopyNumber is an extension to the standard encoding/json package +// which instructs the decoder to not copy numbers loaded from the json +// payloads. +func (dec *Decoder) DontCopyNumber() { dec.flags |= DontCopyNumber } + +// DontCopyRawMessage is an extension to the standard encoding/json package +// which instructs the decoder to not allocate RawMessage values in separate +// memory buffers (see the documentation of the DontcopyRawMessage flag for +// more detais). +func (dec *Decoder) DontCopyRawMessage() { dec.flags |= DontCopyRawMessage } + +// DontMatchCaseInsensitiveStructFields is an extension to the standard +// encoding/json package which instructs the decoder to not match object fields +// against struct fields in a case-insensitive way, the field names have to +// match exactly to be decoded into the struct field values. +func (dec *Decoder) DontMatchCaseInsensitiveStructFields() { + dec.flags |= DontMatchCaseInsensitiveStructFields +} + +// ZeroCopy is an extension to the standard encoding/json package which enables +// all the copy optimizations of the decoder. +func (dec *Decoder) ZeroCopy() { dec.flags |= ZeroCopy } + +// InputOffset returns the input stream byte offset of the current decoder position. +// The offset gives the location of the end of the most recently returned token +// and the beginning of the next token. +func (dec *Decoder) InputOffset() int64 { + return dec.inputOffset +} + +// Encoder is documented at https://golang.org/pkg/encoding/json/#Encoder +type Encoder struct { + writer io.Writer + prefix string + indent string + buffer *bytes.Buffer + err error + flags AppendFlags +} + +// NewEncoder is documented at https://golang.org/pkg/encoding/json/#NewEncoder +func NewEncoder(w io.Writer) *Encoder { + return &Encoder{writer: w, flags: EscapeHTML | SortMapKeys | appendNewline} +} + +// Encode is documented at https://golang.org/pkg/encoding/json/#Encoder.Encode +func (enc *Encoder) Encode(v any) error { + if enc.err != nil { + return enc.err + } + + var err error + buf := encoderBufferPool.Get().(*encoderBuffer) + + buf.data, err = Append(buf.data[:0], v, enc.flags) + if err != nil { + encoderBufferPool.Put(buf) + return err + } + + if (enc.flags & appendNewline) != 0 { + buf.data = append(buf.data, '\n') + } + b := buf.data + + if enc.prefix != "" || enc.indent != "" { + if enc.buffer == nil { + enc.buffer = new(bytes.Buffer) + enc.buffer.Grow(2 * len(buf.data)) + } else { + enc.buffer.Reset() + } + Indent(enc.buffer, buf.data, enc.prefix, enc.indent) + b = enc.buffer.Bytes() + } + + if _, err := enc.writer.Write(b); err != nil { + enc.err = err + } + + encoderBufferPool.Put(buf) + return err +} + +// SetEscapeHTML is documented at https://golang.org/pkg/encoding/json/#Encoder.SetEscapeHTML +func (enc *Encoder) SetEscapeHTML(on bool) { + if on { + enc.flags |= EscapeHTML + } else { + enc.flags &= ^EscapeHTML + } +} + +// SetIndent is documented at https://golang.org/pkg/encoding/json/#Encoder.SetIndent +func (enc *Encoder) SetIndent(prefix, indent string) { + enc.prefix = prefix + enc.indent = indent +} + +// SetSortMapKeys is an extension to the standard encoding/json package which +// allows the program to toggle sorting of map keys on and off. +func (enc *Encoder) SetSortMapKeys(on bool) { + if on { + enc.flags |= SortMapKeys + } else { + enc.flags &= ^SortMapKeys + } +} + +// SetTrustRawMessage skips value checking when encoding a raw json message. It should only +// be used if the values are known to be valid json, e.g. because they were originally created +// by json.Unmarshal. +func (enc *Encoder) SetTrustRawMessage(on bool) { + if on { + enc.flags |= TrustRawMessage + } else { + enc.flags &= ^TrustRawMessage + } +} + +// SetAppendNewline is an extension to the standard encoding/json package which +// allows the program to toggle the addition of a newline in Encode on or off. +func (enc *Encoder) SetAppendNewline(on bool) { + if on { + enc.flags |= appendNewline + } else { + enc.flags &= ^appendNewline + } +} + +var encoderBufferPool = sync.Pool{ + New: func() any { return &encoderBuffer{data: make([]byte, 0, 4096)} }, +} + +type encoderBuffer struct{ data []byte } diff --git a/mcp/dsx-exchange-mcp/vendor/github.com/segmentio/encoding/json/parse.go b/mcp/dsx-exchange-mcp/vendor/github.com/segmentio/encoding/json/parse.go new file mode 100644 index 0000000..d0ee221 --- /dev/null +++ b/mcp/dsx-exchange-mcp/vendor/github.com/segmentio/encoding/json/parse.go @@ -0,0 +1,781 @@ +package json + +import ( + "bytes" + "encoding/binary" + "math" + "math/bits" + "reflect" + "unicode" + "unicode/utf16" + "unicode/utf8" + + "github.com/segmentio/encoding/ascii" +) + +// All spaces characters defined in the json specification. +const ( + sp = ' ' + ht = '\t' + nl = '\n' + cr = '\r' +) + +func internalParseFlags(b []byte) (flags ParseFlags) { + // Don't consider surrounding whitespace + b = skipSpaces(b) + b = trimTrailingSpaces(b) + if ascii.ValidPrint(b) { + flags |= validAsciiPrint + } + if bytes.IndexByte(b, '\\') == -1 { + flags |= noBackslash + } + return +} + +func skipSpaces(b []byte) []byte { + if len(b) > 0 && b[0] <= 0x20 { + b, _ = skipSpacesN(b) + } + return b +} + +func skipSpacesN(b []byte) ([]byte, int) { + for i := range b { + switch b[i] { + case sp, ht, nl, cr: + default: + return b[i:], i + } + } + return nil, 0 +} + +func trimTrailingSpaces(b []byte) []byte { + if len(b) > 0 && b[len(b)-1] <= 0x20 { + b = trimTrailingSpacesN(b) + } + return b +} + +func trimTrailingSpacesN(b []byte) []byte { + i := len(b) - 1 +loop: + for ; i >= 0; i-- { + switch b[i] { + case sp, ht, nl, cr: + default: + break loop + } + } + return b[:i+1] +} + +// parseInt parses a decimal representation of an int64 from b. +// +// The function is equivalent to calling strconv.ParseInt(string(b), 10, 64) but +// it prevents Go from making a memory allocation for converting a byte slice to +// a string (escape analysis fails due to the error returned by strconv.ParseInt). +// +// Because it only works with base 10 the function is also significantly faster +// than strconv.ParseInt. +func (d decoder) parseInt(b []byte, t reflect.Type) (int64, []byte, error) { + var value int64 + var count int + + if len(b) == 0 { + return 0, b, syntaxError(b, "cannot decode integer from an empty input") + } + + if b[0] == '-' { + const max = math.MinInt64 + const lim = max / 10 + + if len(b) == 1 { + return 0, b, syntaxError(b, "cannot decode integer from '-'") + } + + if len(b) > 2 && b[1] == '0' && '0' <= b[2] && b[2] <= '9' { + return 0, b, syntaxError(b, "invalid leading character '0' in integer") + } + + for _, c := range b[1:] { + if c < '0' || c > '9' { + if count == 0 { + b, err := d.inputError(b, t) + return 0, b, err + } + break + } + + if value < lim { + return 0, b, unmarshalOverflow(b, t) + } + + value *= 10 + x := int64(c - '0') + + if value < (max + x) { + return 0, b, unmarshalOverflow(b, t) + } + + value -= x + count++ + } + + count++ + } else { + if len(b) > 1 && b[0] == '0' && '0' <= b[1] && b[1] <= '9' { + return 0, b, syntaxError(b, "invalid leading character '0' in integer") + } + + for ; count < len(b) && b[count] >= '0' && b[count] <= '9'; count++ { + x := int64(b[count] - '0') + next := value*10 + x + if next < value { + return 0, b, unmarshalOverflow(b, t) + } + value = next + } + + if count == 0 { + b, err := d.inputError(b, t) + return 0, b, err + } + } + + if count < len(b) { + switch b[count] { + case '.', 'e', 'E': // was this actually a float? + v, r, _, err := d.parseNumber(b) + if err != nil { + v, r = b[:count+1], b[count+1:] + } + return 0, r, unmarshalTypeError(v, t) + } + } + + return value, b[count:], nil +} + +// parseUint is like parseInt but for unsigned integers. +func (d decoder) parseUint(b []byte, t reflect.Type) (uint64, []byte, error) { + var value uint64 + var count int + + if len(b) == 0 { + return 0, b, syntaxError(b, "cannot decode integer value from an empty input") + } + + if len(b) > 1 && b[0] == '0' && '0' <= b[1] && b[1] <= '9' { + return 0, b, syntaxError(b, "invalid leading character '0' in integer") + } + + for ; count < len(b) && b[count] >= '0' && b[count] <= '9'; count++ { + x := uint64(b[count] - '0') + next := value*10 + x + if next < value { + return 0, b, unmarshalOverflow(b, t) + } + value = next + } + + if count == 0 { + b, err := d.inputError(b, t) + return 0, b, err + } + + if count < len(b) { + switch b[count] { + case '.', 'e', 'E': // was this actually a float? + v, r, _, err := d.parseNumber(b) + if err != nil { + v, r = b[:count+1], b[count+1:] + } + return 0, r, unmarshalTypeError(v, t) + } + } + + return value, b[count:], nil +} + +// parseUintHex parses a hexadecimanl representation of a uint64 from b. +// +// The function is equivalent to calling strconv.ParseUint(string(b), 16, 64) but +// it prevents Go from making a memory allocation for converting a byte slice to +// a string (escape analysis fails due to the error returned by strconv.ParseUint). +// +// Because it only works with base 16 the function is also significantly faster +// than strconv.ParseUint. +func (d decoder) parseUintHex(b []byte) (uint64, []byte, error) { + const max = math.MaxUint64 + const lim = max / 0x10 + + var value uint64 + var count int + + if len(b) == 0 { + return 0, b, syntaxError(b, "cannot decode hexadecimal value from an empty input") + } + +parseLoop: + for i, c := range b { + var x uint64 + + switch { + case c >= '0' && c <= '9': + x = uint64(c - '0') + + case c >= 'A' && c <= 'F': + x = uint64(c-'A') + 0xA + + case c >= 'a' && c <= 'f': + x = uint64(c-'a') + 0xA + + default: + if i == 0 { + return 0, b, syntaxError(b, "expected hexadecimal digit but found '%c'", c) + } + break parseLoop + } + + if value > lim { + return 0, b, syntaxError(b, "hexadecimal value out of range") + } + + if value *= 0x10; value > (max - x) { + return 0, b, syntaxError(b, "hexadecimal value out of range") + } + + value += x + count++ + } + + return value, b[count:], nil +} + +func (d decoder) parseNull(b []byte) ([]byte, []byte, Kind, error) { + if hasNullPrefix(b) { + return b[:4], b[4:], Null, nil + } + if len(b) < 4 { + return nil, b[len(b):], Undefined, unexpectedEOF(b) + } + return nil, b, Undefined, syntaxError(b, "expected 'null' but found invalid token") +} + +func (d decoder) parseTrue(b []byte) ([]byte, []byte, Kind, error) { + if hasTruePrefix(b) { + return b[:4], b[4:], True, nil + } + if len(b) < 4 { + return nil, b[len(b):], Undefined, unexpectedEOF(b) + } + return nil, b, Undefined, syntaxError(b, "expected 'true' but found invalid token") +} + +func (d decoder) parseFalse(b []byte) ([]byte, []byte, Kind, error) { + if hasFalsePrefix(b) { + return b[:5], b[5:], False, nil + } + if len(b) < 5 { + return nil, b[len(b):], Undefined, unexpectedEOF(b) + } + return nil, b, Undefined, syntaxError(b, "expected 'false' but found invalid token") +} + +func (d decoder) parseNumber(b []byte) (v, r []byte, kind Kind, err error) { + if len(b) == 0 { + r, err = b, unexpectedEOF(b) + return + } + + // Assume it's an unsigned integer at first. + kind = Uint + + i := 0 + // sign + if b[i] == '-' { + kind = Int + i++ + } + + if i == len(b) { + r, err = b[i:], syntaxError(b, "missing number value after sign") + return + } + + if b[i] < '0' || b[i] > '9' { + r, err = b[i:], syntaxError(b, "expected digit but got '%c'", b[i]) + return + } + + // integer part + if b[i] == '0' { + i++ + if i == len(b) || (b[i] != '.' && b[i] != 'e' && b[i] != 'E') { + v, r = b[:i], b[i:] + return + } + if '0' <= b[i] && b[i] <= '9' { + r, err = b[i:], syntaxError(b, "cannot decode number with leading '0' character") + return + } + } + + for i < len(b) && '0' <= b[i] && b[i] <= '9' { + i++ + } + + // decimal part + if i < len(b) && b[i] == '.' { + kind = Float + i++ + decimalStart := i + + for i < len(b) { + if c := b[i]; '0' > c || c > '9' { + if i == decimalStart { + r, err = b[i:], syntaxError(b, "expected digit but found '%c'", c) + return + } + break + } + i++ + } + + if i == decimalStart { + r, err = b[i:], syntaxError(b, "expected decimal part after '.'") + return + } + } + + // exponent part + if i < len(b) && (b[i] == 'e' || b[i] == 'E') { + kind = Float + i++ + + if i < len(b) { + if c := b[i]; c == '+' || c == '-' { + i++ + } + } + + if i == len(b) { + r, err = b[i:], syntaxError(b, "missing exponent in number") + return + } + + exponentStart := i + + for i < len(b) { + if c := b[i]; '0' > c || c > '9' { + if i == exponentStart { + err = syntaxError(b, "expected digit but found '%c'", c) + return + } + break + } + i++ + } + } + + v, r = b[:i], b[i:] + return +} + +func (d decoder) parseUnicode(b []byte) (rune, int, error) { + if len(b) < 4 { + return 0, len(b), syntaxError(b, "unicode code point must have at least 4 characters") + } + + u, r, err := d.parseUintHex(b[:4]) + if err != nil { + return 0, 4, syntaxError(b, "parsing unicode code point: %s", err) + } + + if len(r) != 0 { + return 0, 4, syntaxError(b, "invalid unicode code point") + } + + return rune(u), 4, nil +} + +func (d decoder) parseString(b []byte) ([]byte, []byte, Kind, error) { + if len(b) < 2 { + return nil, b[len(b):], Undefined, unexpectedEOF(b) + } + if b[0] != '"' { + return nil, b, Undefined, syntaxError(b, "expected '\"' at the beginning of a string value") + } + + var n int + if len(b) >= 9 { + // This is an optimization for short strings. We read 8/16 bytes, + // and XOR each with 0x22 (") so that these bytes (and only + // these bytes) are now zero. We use the hasless(u,1) trick + // from https://graphics.stanford.edu/~seander/bithacks.html#ZeroInWord + // to determine whether any bytes are zero. Finally, we CTZ + // to find the index of that byte. + const mask1 = 0x2222222222222222 + const mask2 = 0x0101010101010101 + const mask3 = 0x8080808080808080 + u := binary.LittleEndian.Uint64(b[1:]) ^ mask1 + if mask := (u - mask2) & ^u & mask3; mask != 0 { + n = bits.TrailingZeros64(mask)/8 + 2 + goto found + } + if len(b) >= 17 { + u = binary.LittleEndian.Uint64(b[9:]) ^ mask1 + if mask := (u - mask2) & ^u & mask3; mask != 0 { + n = bits.TrailingZeros64(mask)/8 + 10 + goto found + } + } + } + n = bytes.IndexByte(b[1:], '"') + 2 + if n <= 1 { + return nil, b[len(b):], Undefined, syntaxError(b, "missing '\"' at the end of a string value") + } +found: + if (d.flags.has(noBackslash) || bytes.IndexByte(b[1:n], '\\') < 0) && + (d.flags.has(validAsciiPrint) || ascii.ValidPrint(b[1:n])) { + return b[:n], b[n:], Unescaped, nil + } + + for i := 1; i < len(b); i++ { + switch b[i] { + case '\\': + if i++; i < len(b) { + switch b[i] { + case '"', '\\', '/', 'n', 'r', 't', 'f', 'b': + case 'u': + _, n, err := d.parseUnicode(b[i+1:]) + if err != nil { + return nil, b[i+1+n:], Undefined, err + } + i += n + default: + return nil, b, Undefined, syntaxError(b, "invalid character '%c' in string escape code", b[i]) + } + } + + case '"': + return b[:i+1], b[i+1:], String, nil + + default: + if b[i] < 0x20 { + return nil, b, Undefined, syntaxError(b, "invalid character '%c' in string escape code", b[i]) + } + } + } + + return nil, b[len(b):], Undefined, syntaxError(b, "missing '\"' at the end of a string value") +} + +func (d decoder) parseStringUnquote(b []byte, r []byte) ([]byte, []byte, bool, error) { + s, b, k, err := d.parseString(b) + if err != nil { + return s, b, false, err + } + + s = s[1 : len(s)-1] // trim the quotes + + if k == Unescaped { + return s, b, false, nil + } + + if r == nil { + r = make([]byte, 0, len(s)) + } + + for len(s) != 0 { + i := bytes.IndexByte(s, '\\') + + if i < 0 { + r = appendCoerceInvalidUTF8(r, s) + break + } + + r = appendCoerceInvalidUTF8(r, s[:i]) + s = s[i+1:] + + c := s[0] + switch c { + case '"', '\\', '/': + // simple escaped character + case 'n': + c = '\n' + + case 'r': + c = '\r' + + case 't': + c = '\t' + + case 'b': + c = '\b' + + case 'f': + c = '\f' + + case 'u': + s = s[1:] + + r1, n1, err := d.parseUnicode(s) + if err != nil { + return r, b, true, err + } + s = s[n1:] + + if utf16.IsSurrogate(r1) { + if !hasPrefix(s, `\u`) { + r1 = unicode.ReplacementChar + } else { + r2, n2, err := d.parseUnicode(s[2:]) + if err != nil { + return r, b, true, err + } + if r1 = utf16.DecodeRune(r1, r2); r1 != unicode.ReplacementChar { + s = s[2+n2:] + } + } + } + + r = appendRune(r, r1) + continue + + default: // not sure what this escape sequence is + return r, b, false, syntaxError(s, "invalid character '%c' in string escape code", c) + } + + r = append(r, c) + s = s[1:] + } + + return r, b, true, nil +} + +func appendRune(b []byte, r rune) []byte { + n := len(b) + b = append(b, 0, 0, 0, 0) + return b[:n+utf8.EncodeRune(b[n:], r)] +} + +func appendCoerceInvalidUTF8(b []byte, s []byte) []byte { + c := [4]byte{} + + for _, r := range string(s) { + b = append(b, c[:utf8.EncodeRune(c[:], r)]...) + } + + return b +} + +func (d decoder) parseObject(b []byte) ([]byte, []byte, Kind, error) { + if len(b) < 2 { + return nil, b[len(b):], Undefined, unexpectedEOF(b) + } + + if b[0] != '{' { + return nil, b, Undefined, syntaxError(b, "expected '{' at the beginning of an object value") + } + + var err error + a := b + n := len(b) + i := 0 + + b = b[1:] + for { + b = skipSpaces(b) + + if len(b) == 0 { + return nil, b, Undefined, syntaxError(b, "cannot decode object from empty input") + } + + if b[0] == '}' { + j := (n - len(b)) + 1 + return a[:j], a[j:], Object, nil + } + + if i != 0 { + if len(b) == 0 { + return nil, b, Undefined, syntaxError(b, "unexpected EOF after object field value") + } + if b[0] != ',' { + return nil, b, Undefined, syntaxError(b, "expected ',' after object field value but found '%c'", b[0]) + } + b = skipSpaces(b[1:]) + if len(b) == 0 { + return nil, b, Undefined, unexpectedEOF(b) + } + if b[0] == '}' { + return nil, b, Undefined, syntaxError(b, "unexpected trailing comma after object field") + } + } + + _, b, _, err = d.parseString(b) + if err != nil { + return nil, b, Undefined, err + } + b = skipSpaces(b) + + if len(b) == 0 { + return nil, b, Undefined, syntaxError(b, "unexpected EOF after object field key") + } + if b[0] != ':' { + return nil, b, Undefined, syntaxError(b, "expected ':' after object field key but found '%c'", b[0]) + } + b = skipSpaces(b[1:]) + + _, b, _, err = d.parseValue(b) + if err != nil { + return nil, b, Undefined, err + } + + i++ + } +} + +func (d decoder) parseArray(b []byte) ([]byte, []byte, Kind, error) { + if len(b) < 2 { + return nil, b[len(b):], Undefined, unexpectedEOF(b) + } + + if b[0] != '[' { + return nil, b, Undefined, syntaxError(b, "expected '[' at the beginning of array value") + } + + var err error + a := b + n := len(b) + i := 0 + + b = b[1:] + for { + b = skipSpaces(b) + + if len(b) == 0 { + return nil, b, Undefined, syntaxError(b, "missing closing ']' after array value") + } + + if b[0] == ']' { + j := (n - len(b)) + 1 + return a[:j], a[j:], Array, nil + } + + if i != 0 { + if len(b) == 0 { + return nil, b, Undefined, syntaxError(b, "unexpected EOF after array element") + } + if b[0] != ',' { + return nil, b, Undefined, syntaxError(b, "expected ',' after array element but found '%c'", b[0]) + } + b = skipSpaces(b[1:]) + if len(b) == 0 { + return nil, b, Undefined, unexpectedEOF(b) + } + if b[0] == ']' { + return nil, b, Undefined, syntaxError(b, "unexpected trailing comma after object field") + } + } + + _, b, _, err = d.parseValue(b) + if err != nil { + return nil, b, Undefined, err + } + + i++ + } +} + +func (d decoder) parseValue(b []byte) ([]byte, []byte, Kind, error) { + if len(b) == 0 { + return nil, b, Undefined, syntaxError(b, "unexpected end of JSON input") + } + + var v []byte + var k Kind + var err error + + switch b[0] { + case '{': + v, b, k, err = d.parseObject(b) + case '[': + v, b, k, err = d.parseArray(b) + case '"': + v, b, k, err = d.parseString(b) + case 'n': + v, b, k, err = d.parseNull(b) + case 't': + v, b, k, err = d.parseTrue(b) + case 'f': + v, b, k, err = d.parseFalse(b) + case '-', '0', '1', '2', '3', '4', '5', '6', '7', '8', '9': + v, b, k, err = d.parseNumber(b) + default: + err = syntaxError(b, "invalid character '%c' looking for beginning of value", b[0]) + } + + return v, b, k, err +} + +func hasNullPrefix(b []byte) bool { + return len(b) >= 4 && string(b[:4]) == "null" +} + +func hasTruePrefix(b []byte) bool { + return len(b) >= 4 && string(b[:4]) == "true" +} + +func hasFalsePrefix(b []byte) bool { + return len(b) >= 5 && string(b[:5]) == "false" +} + +func hasPrefix(b []byte, s string) bool { + return len(b) >= len(s) && s == string(b[:len(s)]) +} + +func hasLeadingSign(b []byte) bool { + return len(b) > 0 && (b[0] == '+' || b[0] == '-') +} + +func hasLeadingZeroes(b []byte) bool { + if hasLeadingSign(b) { + b = b[1:] + } + return len(b) > 1 && b[0] == '0' && '0' <= b[1] && b[1] <= '9' +} + +func appendToLower(b, s []byte) []byte { + if ascii.Valid(s) { // fast path for ascii strings + i := 0 + + for j := range s { + c := s[j] + + if 'A' <= c && c <= 'Z' { + b = append(b, s[i:j]...) + b = append(b, c+('a'-'A')) + i = j + 1 + } + } + + return append(b, s[i:]...) + } + + for _, r := range string(s) { + b = appendRune(b, foldRune(r)) + } + + return b +} + +func foldRune(r rune) rune { + if r = unicode.SimpleFold(r); 'A' <= r && r <= 'Z' { + r = r + ('a' - 'A') + } + return r +} diff --git a/mcp/dsx-exchange-mcp/vendor/github.com/segmentio/encoding/json/reflect.go b/mcp/dsx-exchange-mcp/vendor/github.com/segmentio/encoding/json/reflect.go new file mode 100644 index 0000000..6edd80e --- /dev/null +++ b/mcp/dsx-exchange-mcp/vendor/github.com/segmentio/encoding/json/reflect.go @@ -0,0 +1,20 @@ +//go:build go1.20 +// +build go1.20 + +package json + +import ( + "reflect" + "unsafe" +) + +func extendSlice(t reflect.Type, s *slice, n int) slice { + arrayType := reflect.ArrayOf(n, t.Elem()) + arrayData := reflect.New(arrayType) + reflect.Copy(arrayData.Elem(), reflect.NewAt(t, unsafe.Pointer(s)).Elem()) + return slice{ + data: unsafe.Pointer(arrayData.Pointer()), + len: s.len, + cap: n, + } +} diff --git a/mcp/dsx-exchange-mcp/vendor/github.com/segmentio/encoding/json/reflect_optimize.go b/mcp/dsx-exchange-mcp/vendor/github.com/segmentio/encoding/json/reflect_optimize.go new file mode 100644 index 0000000..6588433 --- /dev/null +++ b/mcp/dsx-exchange-mcp/vendor/github.com/segmentio/encoding/json/reflect_optimize.go @@ -0,0 +1,30 @@ +//go:build !go1.20 +// +build !go1.20 + +package json + +import ( + "reflect" + "unsafe" +) + +//go:linkname unsafe_NewArray reflect.unsafe_NewArray +func unsafe_NewArray(rtype unsafe.Pointer, length int) unsafe.Pointer + +//go:linkname typedslicecopy reflect.typedslicecopy +//go:noescape +func typedslicecopy(elemType unsafe.Pointer, dst, src slice) int + +func extendSlice(t reflect.Type, s *slice, n int) slice { + elemTypeRef := t.Elem() + elemTypePtr := ((*iface)(unsafe.Pointer(&elemTypeRef))).ptr + + d := slice{ + data: unsafe_NewArray(elemTypePtr, n), + len: s.len, + cap: n, + } + + typedslicecopy(elemTypePtr, d, *s) + return d +} diff --git a/mcp/dsx-exchange-mcp/vendor/github.com/segmentio/encoding/json/string.go b/mcp/dsx-exchange-mcp/vendor/github.com/segmentio/encoding/json/string.go new file mode 100644 index 0000000..a9a972b --- /dev/null +++ b/mcp/dsx-exchange-mcp/vendor/github.com/segmentio/encoding/json/string.go @@ -0,0 +1,89 @@ +package json + +import ( + "math/bits" + "unsafe" +) + +const ( + lsb = 0x0101010101010101 + msb = 0x8080808080808080 +) + +// escapeIndex finds the index of the first char in `s` that requires escaping. +// A char requires escaping if it's outside of the range of [0x20, 0x7F] or if +// it includes a double quote or backslash. If the escapeHTML mode is enabled, +// the chars <, > and & also require escaping. If no chars in `s` require +// escaping, the return value is -1. +func escapeIndex(s string, escapeHTML bool) int { + chunks := stringToUint64(s) + for _, n := range chunks { + // combine masks before checking for the MSB of each byte. We include + // `n` in the mask to check whether any of the *input* byte MSBs were + // set (i.e. the byte was outside the ASCII range). + mask := n | below(n, 0x20) | contains(n, '"') | contains(n, '\\') + if escapeHTML { + mask |= contains(n, '<') | contains(n, '>') | contains(n, '&') + } + if (mask & msb) != 0 { + return bits.TrailingZeros64(mask&msb) / 8 + } + } + + for i := len(chunks) * 8; i < len(s); i++ { + c := s[i] + if c < 0x20 || c > 0x7f || c == '"' || c == '\\' || (escapeHTML && (c == '<' || c == '>' || c == '&')) { + return i + } + } + + return -1 +} + +func escapeByteRepr(b byte) byte { + switch b { + case '\\', '"': + return b + case '\b': + return 'b' + case '\f': + return 'f' + case '\n': + return 'n' + case '\r': + return 'r' + case '\t': + return 't' + } + + return 0 +} + +// below return a mask that can be used to determine if any of the bytes +// in `n` are below `b`. If a byte's MSB is set in the mask then that byte was +// below `b`. The result is only valid if `b`, and each byte in `n`, is below +// 0x80. +func below(n uint64, b byte) uint64 { + return n - expand(b) +} + +// contains returns a mask that can be used to determine if any of the +// bytes in `n` are equal to `b`. If a byte's MSB is set in the mask then +// that byte is equal to `b`. The result is only valid if `b`, and each +// byte in `n`, is below 0x80. +func contains(n uint64, b byte) uint64 { + return (n ^ expand(b)) - lsb +} + +// expand puts the specified byte into each of the 8 bytes of a uint64. +func expand(b byte) uint64 { + return lsb * uint64(b) +} + +func stringToUint64(s string) []uint64 { + return *(*[]uint64)(unsafe.Pointer(&sliceHeader{ + Data: *(*unsafe.Pointer)(unsafe.Pointer(&s)), + Len: len(s) / 8, + Cap: len(s) / 8, + })) +} diff --git a/mcp/dsx-exchange-mcp/vendor/github.com/segmentio/encoding/json/token.go b/mcp/dsx-exchange-mcp/vendor/github.com/segmentio/encoding/json/token.go new file mode 100644 index 0000000..ddcd05d --- /dev/null +++ b/mcp/dsx-exchange-mcp/vendor/github.com/segmentio/encoding/json/token.go @@ -0,0 +1,426 @@ +package json + +import ( + "strconv" + "sync" + "unsafe" +) + +// Tokenizer is an iterator-style type which can be used to progressively parse +// through a json input. +// +// Tokenizing json is useful to build highly efficient parsing operations, for +// example when doing tranformations on-the-fly where as the program reads the +// input and produces the transformed json to an output buffer. +// +// Here is a common pattern to use a tokenizer: +// +// for t := json.NewTokenizer(b); t.Next(); { +// switch k := t.Kind(); k.Class() { +// case json.Null: +// ... +// case json.Bool: +// ... +// case json.Num: +// ... +// case json.String: +// ... +// case json.Array: +// ... +// case json.Object: +// ... +// } +// } +type Tokenizer struct { + // When the tokenizer is positioned on a json delimiter this field is not + // zero. In this case the possible values are '{', '}', '[', ']', ':', and + // ','. + Delim Delim + + // This field contains the raw json token that the tokenizer is pointing at. + // When Delim is not zero, this field is a single-element byte slice + // continaing the delimiter value. Otherwise, this field holds values like + // null, true, false, numbers, or quoted strings. + Value RawValue + + // When the tokenizer has encountered invalid content this field is not nil. + Err error + + // When the value is in an array or an object, this field contains the depth + // at which it was found. + Depth int + + // When the value is in an array or an object, this field contains the + // position at which it was found. + Index int + + // This field is true when the value is the key of an object. + IsKey bool + + // Tells whether the next value read from the tokenizer is a key. + isKey bool + + // json input for the tokenizer, pointing at data right after the last token + // that was parsed. + json []byte + + // Stack used to track entering and leaving arrays, objects, and keys. + stack *stack + + // Decoder used for parsing. + decoder +} + +// NewTokenizer constructs a new Tokenizer which reads its json input from b. +func NewTokenizer(b []byte) *Tokenizer { + return &Tokenizer{ + json: b, + decoder: decoder{flags: internalParseFlags(b)}, + } +} + +// Reset erases the state of t and re-initializes it with the json input from b. +func (t *Tokenizer) Reset(b []byte) { + if t.stack != nil { + releaseStack(t.stack) + } + // This code is similar to: + // + // *t = Tokenizer{json: b} + // + // However, it does not compile down to an invocation of duff-copy. + t.Delim = 0 + t.Value = nil + t.Err = nil + t.Depth = 0 + t.Index = 0 + t.IsKey = false + t.isKey = false + t.json = b + t.stack = nil + t.decoder = decoder{flags: internalParseFlags(b)} +} + +// Next returns a new tokenizer pointing at the next token, or the zero-value of +// Tokenizer if the end of the json input has been reached. +// +// If the tokenizer encounters malformed json while reading the input the method +// sets t.Err to an error describing the issue, and returns false. Once an error +// has been encountered, the tokenizer will always fail until its input is +// cleared by a call to its Reset method. +func (t *Tokenizer) Next() bool { + if t.Err != nil { + return false + } + + // Inlined code of the skipSpaces function, this give a ~15% speed boost. + i := 0 +skipLoop: + for _, c := range t.json { + switch c { + case sp, ht, nl, cr: + i++ + default: + break skipLoop + } + } + + if i > 0 { + t.json = t.json[i:] + } + + if len(t.json) == 0 { + t.Reset(nil) + return false + } + + var kind Kind + switch t.json[0] { + case '"': + t.Delim = 0 + t.Value, t.json, kind, t.Err = t.parseString(t.json) + case 'n': + t.Delim = 0 + t.Value, t.json, kind, t.Err = t.parseNull(t.json) + case 't': + t.Delim = 0 + t.Value, t.json, kind, t.Err = t.parseTrue(t.json) + case 'f': + t.Delim = 0 + t.Value, t.json, kind, t.Err = t.parseFalse(t.json) + case '-', '0', '1', '2', '3', '4', '5', '6', '7', '8', '9': + t.Delim = 0 + t.Value, t.json, kind, t.Err = t.parseNumber(t.json) + case '{', '}', '[', ']', ':', ',': + t.Delim, t.Value, t.json = Delim(t.json[0]), t.json[:1], t.json[1:] + switch t.Delim { + case '{': + kind = Object + case '[': + kind = Array + } + default: + t.Delim = 0 + t.Value, t.json, t.Err = t.json[:1], t.json[1:], syntaxError(t.json, "expected token but found '%c'", t.json[0]) + } + + t.Depth = t.depth() + t.Index = t.index() + t.flags = t.flags.withKind(kind) + + if t.Delim == 0 { + t.IsKey = t.isKey + } else { + t.IsKey = false + + switch t.Delim { + case '{': + t.isKey = true + t.push(inObject) + case '[': + t.push(inArray) + case '}': + t.Err = t.pop(inObject) + t.Depth-- + t.Index = t.index() + case ']': + t.Err = t.pop(inArray) + t.Depth-- + t.Index = t.index() + case ':': + t.isKey = false + case ',': + if t.stack == nil || len(t.stack.state) == 0 { + t.Err = syntaxError(t.json, "found unexpected comma") + return false + } + if t.stack.is(inObject) { + t.isKey = true + } + t.stack.state[len(t.stack.state)-1].len++ + } + } + + return (t.Delim != 0 || len(t.Value) != 0) && t.Err == nil +} + +func (t *Tokenizer) depth() int { + if t.stack == nil { + return 0 + } + return t.stack.depth() +} + +func (t *Tokenizer) index() int { + if t.stack == nil { + return 0 + } + return t.stack.index() +} + +func (t *Tokenizer) push(typ scope) { + if t.stack == nil { + t.stack = acquireStack() + } + t.stack.push(typ) +} + +func (t *Tokenizer) pop(expect scope) error { + if t.stack == nil || !t.stack.pop(expect) { + return syntaxError(t.json, "found unexpected character while tokenizing json input") + } + return nil +} + +// Kind returns the kind of the value that the tokenizer is currently positioned +// on. +func (t *Tokenizer) Kind() Kind { return t.flags.kind() } + +// Bool returns a bool containing the value of the json boolean that the +// tokenizer is currently pointing at. +// +// This method must only be called after checking the kind of the token via a +// call to Kind. +// +// If the tokenizer is not positioned on a boolean, the behavior is undefined. +func (t *Tokenizer) Bool() bool { return t.flags.kind() == True } + +// Int returns a byte slice containing the value of the json number that the +// tokenizer is currently pointing at. +// +// This method must only be called after checking the kind of the token via a +// call to Kind. +// +// If the tokenizer is not positioned on an integer, the behavior is undefined. +func (t *Tokenizer) Int() int64 { + i, _, _ := t.parseInt(t.Value, int64Type) + return i +} + +// Uint returns a byte slice containing the value of the json number that the +// tokenizer is currently pointing at. +// +// This method must only be called after checking the kind of the token via a +// call to Kind. +// +// If the tokenizer is not positioned on a positive integer, the behavior is +// undefined. +func (t *Tokenizer) Uint() uint64 { + u, _, _ := t.parseUint(t.Value, uint64Type) + return u +} + +// Float returns a byte slice containing the value of the json number that the +// tokenizer is currently pointing at. +// +// This method must only be called after checking the kind of the token via a +// call to Kind. +// +// If the tokenizer is not positioned on a number, the behavior is undefined. +func (t *Tokenizer) Float() float64 { + f, _ := strconv.ParseFloat(*(*string)(unsafe.Pointer(&t.Value)), 64) + return f +} + +// String returns a byte slice containing the value of the json string that the +// tokenizer is currently pointing at. +// +// This method must only be called after checking the kind of the token via a +// call to Kind. +// +// When possible, the returned byte slice references the backing array of the +// tokenizer. A new slice is only allocated if the tokenizer needed to unescape +// the json string. +// +// If the tokenizer is not positioned on a string, the behavior is undefined. +func (t *Tokenizer) String() []byte { + if t.flags.kind() == Unescaped && len(t.Value) > 1 { + return t.Value[1 : len(t.Value)-1] // unquote + } + s, _, _, _ := t.parseStringUnquote(t.Value, nil) + return s +} + +// Remaining returns the number of bytes left to parse. +// +// The position of the tokenizer's current Value within the original byte slice +// can be calculated like so: +// +// end := len(b) - tok.Remaining() +// start := end - len(tok.Value) +// +// And slicing b[start:end] will yield the tokenizer's current Value. +func (t *Tokenizer) Remaining() int { + return len(t.json) +} + +// RawValue represents a raw json value, it is intended to carry null, true, +// false, number, and string values only. +type RawValue []byte + +// String returns true if v contains a string value. +func (v RawValue) String() bool { return len(v) != 0 && v[0] == '"' } + +// Null returns true if v contains a null value. +func (v RawValue) Null() bool { return len(v) != 0 && v[0] == 'n' } + +// True returns true if v contains a true value. +func (v RawValue) True() bool { return len(v) != 0 && v[0] == 't' } + +// False returns true if v contains a false value. +func (v RawValue) False() bool { return len(v) != 0 && v[0] == 'f' } + +// Number returns true if v contains a number value. +func (v RawValue) Number() bool { + if len(v) != 0 { + switch v[0] { + case '-', '0', '1', '2', '3', '4', '5', '6', '7', '8', '9': + return true + } + } + return false +} + +// AppendUnquote writes the unquoted version of the string value in v into b. +func (v RawValue) AppendUnquote(b []byte) []byte { + d := decoder{} + s, r, _, err := d.parseStringUnquote(v, b) + if err != nil { + panic(err) + } + if len(r) != 0 { + panic(syntaxError(r, "unexpected trailing tokens after json value")) + } + return append(b, s...) +} + +// Unquote returns the unquoted version of the string value in v. +func (v RawValue) Unquote() []byte { + return v.AppendUnquote(nil) +} + +type scope int + +const ( + inArray scope = iota + inObject +) + +type state struct { + typ scope + len int +} + +type stack struct { + state []state +} + +func (s *stack) push(typ scope) { + s.state = append(s.state, state{typ: typ, len: 1}) +} + +func (s *stack) pop(expect scope) bool { + i := len(s.state) - 1 + + if i < 0 { + return false + } + + if found := s.state[i]; expect != found.typ { + return false + } + + s.state = s.state[:i] + return true +} + +func (s *stack) is(typ scope) bool { + return len(s.state) != 0 && s.state[len(s.state)-1].typ == typ +} + +func (s *stack) depth() int { + return len(s.state) +} + +func (s *stack) index() int { + if len(s.state) == 0 { + return 0 + } + return s.state[len(s.state)-1].len - 1 +} + +func acquireStack() *stack { + s, _ := stackPool.Get().(*stack) + if s == nil { + s = &stack{state: make([]state, 0, 4)} + } else { + s.state = s.state[:0] + } + return s +} + +func releaseStack(s *stack) { + stackPool.Put(s) +} + +var stackPool sync.Pool // *stack diff --git a/mcp/dsx-exchange-mcp/vendor/github.com/yosida95/uritemplate/v3/LICENSE b/mcp/dsx-exchange-mcp/vendor/github.com/yosida95/uritemplate/v3/LICENSE new file mode 100644 index 0000000..79e8f87 --- /dev/null +++ b/mcp/dsx-exchange-mcp/vendor/github.com/yosida95/uritemplate/v3/LICENSE @@ -0,0 +1,25 @@ +Copyright (C) 2016, Kohei YOSHIDA . All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are met: + + * Redistributions of source code must retain the above copyright + notice, this list of conditions and the following disclaimer. + * Redistributions in binary form must reproduce the above copyright + notice, this list of conditions and the following disclaimer in the + documentation and/or other materials provided with the distribution. + * Neither the name of the copyright holder nor the names of its + contributors may be used to endorse or promote products derived from + this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT +HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT +LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, +DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY +THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/mcp/dsx-exchange-mcp/vendor/github.com/yosida95/uritemplate/v3/README.rst b/mcp/dsx-exchange-mcp/vendor/github.com/yosida95/uritemplate/v3/README.rst new file mode 100644 index 0000000..6815d0a --- /dev/null +++ b/mcp/dsx-exchange-mcp/vendor/github.com/yosida95/uritemplate/v3/README.rst @@ -0,0 +1,46 @@ +uritemplate +=========== + +`uritemplate`_ is a Go implementation of `URI Template`_ [RFC6570] with +full functionality of URI Template Level 4. + +uritemplate can also generate a regexp that matches expansion of the +URI Template from a URI Template. + +Getting Started +--------------- + +Installation +~~~~~~~~~~~~ + +.. code-block:: sh + + $ go get -u github.com/yosida95/uritemplate/v3 + +Documentation +~~~~~~~~~~~~~ + +The documentation is available on GoDoc_. + +Examples +-------- + +See `examples on GoDoc`_. + +License +------- + +`uritemplate`_ is distributed under the BSD 3-Clause license. +PLEASE READ ./LICENSE carefully and follow its clauses to use this software. + +Author +------ + +yosida95_ + + +.. _`URI Template`: https://tools.ietf.org/html/rfc6570 +.. _Godoc: https://godoc.org/github.com/yosida95/uritemplate +.. _`examples on GoDoc`: https://godoc.org/github.com/yosida95/uritemplate#pkg-examples +.. _yosida95: https://yosida95.com/ +.. _uritemplate: https://github.com/yosida95/uritemplate diff --git a/mcp/dsx-exchange-mcp/vendor/github.com/yosida95/uritemplate/v3/compile.go b/mcp/dsx-exchange-mcp/vendor/github.com/yosida95/uritemplate/v3/compile.go new file mode 100644 index 0000000..bd774d1 --- /dev/null +++ b/mcp/dsx-exchange-mcp/vendor/github.com/yosida95/uritemplate/v3/compile.go @@ -0,0 +1,224 @@ +// Copyright (C) 2016 Kohei YOSHIDA. All rights reserved. +// +// This program is free software; you can redistribute it and/or +// modify it under the terms of The BSD 3-Clause License +// that can be found in the LICENSE file. + +package uritemplate + +import ( + "fmt" + "unicode/utf8" +) + +type compiler struct { + prog *prog +} + +func (c *compiler) init() { + c.prog = &prog{} +} + +func (c *compiler) op(opcode progOpcode) uint32 { + i := len(c.prog.op) + c.prog.op = append(c.prog.op, progOp{code: opcode}) + return uint32(i) +} + +func (c *compiler) opWithRune(opcode progOpcode, r rune) uint32 { + addr := c.op(opcode) + (&c.prog.op[addr]).r = r + return addr +} + +func (c *compiler) opWithRuneClass(opcode progOpcode, rc runeClass) uint32 { + addr := c.op(opcode) + (&c.prog.op[addr]).rc = rc + return addr +} + +func (c *compiler) opWithAddr(opcode progOpcode, absaddr uint32) uint32 { + addr := c.op(opcode) + (&c.prog.op[addr]).i = absaddr + return addr +} + +func (c *compiler) opWithAddrDelta(opcode progOpcode, delta uint32) uint32 { + return c.opWithAddr(opcode, uint32(len(c.prog.op))+delta) +} + +func (c *compiler) opWithName(opcode progOpcode, name string) uint32 { + addr := c.op(opcode) + (&c.prog.op[addr]).name = name + return addr +} + +func (c *compiler) compileString(str string) { + for i := 0; i < len(str); { + // NOTE(yosida95): It is confirmed at parse time that literals + // consist of only valid-UTF8 runes. + r, size := utf8.DecodeRuneInString(str[i:]) + c.opWithRune(opRune, r) + i += size + } +} + +func (c *compiler) compileRuneClass(rc runeClass, maxlen int) { + for i := 0; i < maxlen; i++ { + if i > 0 { + c.opWithAddrDelta(opSplit, 7) + } + c.opWithAddrDelta(opSplit, 3) // raw rune or pct-encoded + c.opWithRuneClass(opRuneClass, rc) // raw rune + c.opWithAddrDelta(opJmp, 4) // + c.opWithRune(opRune, '%') // pct-encoded + c.opWithRuneClass(opRuneClass, runeClassPctE) // + c.opWithRuneClass(opRuneClass, runeClassPctE) // + } +} + +func (c *compiler) compileRuneClassInfinite(rc runeClass) { + start := c.opWithAddrDelta(opSplit, 3) // raw rune or pct-encoded + c.opWithRuneClass(opRuneClass, rc) // raw rune + c.opWithAddrDelta(opJmp, 4) // + c.opWithRune(opRune, '%') // pct-encoded + c.opWithRuneClass(opRuneClass, runeClassPctE) // + c.opWithRuneClass(opRuneClass, runeClassPctE) // + c.opWithAddrDelta(opSplit, 2) // loop + c.opWithAddr(opJmp, start) // +} + +func (c *compiler) compileVarspecValue(spec varspec, expr *expression) { + var specname string + if spec.maxlen > 0 { + specname = fmt.Sprintf("%s:%d", spec.name, spec.maxlen) + } else { + specname = spec.name + } + + c.prog.numCap++ + + c.opWithName(opCapStart, specname) + + split := c.op(opSplit) + if spec.maxlen > 0 { + c.compileRuneClass(expr.allow, spec.maxlen) + } else { + c.compileRuneClassInfinite(expr.allow) + } + + capEnd := c.opWithName(opCapEnd, specname) + c.prog.op[split].i = capEnd +} + +func (c *compiler) compileVarspec(spec varspec, expr *expression) { + switch { + case expr.named && spec.explode: + split1 := c.op(opSplit) + noop := c.op(opNoop) + c.compileString(spec.name) + + split2 := c.op(opSplit) + c.opWithRune(opRune, '=') + c.compileVarspecValue(spec, expr) + + split3 := c.op(opSplit) + c.compileString(expr.sep) + c.opWithAddr(opJmp, noop) + + c.prog.op[split2].i = uint32(len(c.prog.op)) + c.compileString(expr.ifemp) + c.opWithAddr(opJmp, split3) + + c.prog.op[split1].i = uint32(len(c.prog.op)) + c.prog.op[split3].i = uint32(len(c.prog.op)) + + case expr.named && !spec.explode: + c.compileString(spec.name) + + split2 := c.op(opSplit) + c.opWithRune(opRune, '=') + + split3 := c.op(opSplit) + + split4 := c.op(opSplit) + c.compileVarspecValue(spec, expr) + + split5 := c.op(opSplit) + c.prog.op[split4].i = split5 + c.compileString(",") + c.opWithAddr(opJmp, split4) + + c.prog.op[split3].i = uint32(len(c.prog.op)) + c.compileString(",") + jmp1 := c.op(opJmp) + + c.prog.op[split2].i = uint32(len(c.prog.op)) + c.compileString(expr.ifemp) + + c.prog.op[split5].i = uint32(len(c.prog.op)) + c.prog.op[jmp1].i = uint32(len(c.prog.op)) + + case !expr.named: + start := uint32(len(c.prog.op)) + c.compileVarspecValue(spec, expr) + + split1 := c.op(opSplit) + jmp := c.op(opJmp) + + c.prog.op[split1].i = uint32(len(c.prog.op)) + if spec.explode { + c.compileString(expr.sep) + } else { + c.opWithRune(opRune, ',') + } + c.opWithAddr(opJmp, start) + + c.prog.op[jmp].i = uint32(len(c.prog.op)) + } +} + +func (c *compiler) compileExpression(expr *expression) { + if len(expr.vars) < 1 { + return + } + + split1 := c.op(opSplit) + c.compileString(expr.first) + + for i, size := 0, len(expr.vars); i < size; i++ { + spec := expr.vars[i] + + split2 := c.op(opSplit) + if i > 0 { + split3 := c.op(opSplit) + c.compileString(expr.sep) + c.prog.op[split3].i = uint32(len(c.prog.op)) + } + c.compileVarspec(spec, expr) + c.prog.op[split2].i = uint32(len(c.prog.op)) + } + + c.prog.op[split1].i = uint32(len(c.prog.op)) +} + +func (c *compiler) compileLiterals(lt literals) { + c.compileString(string(lt)) +} + +func (c *compiler) compile(tmpl *Template) { + c.op(opLineBegin) + for i := range tmpl.exprs { + expr := tmpl.exprs[i] + switch expr := expr.(type) { + default: + panic("unhandled expression") + case *expression: + c.compileExpression(expr) + case literals: + c.compileLiterals(expr) + } + } + c.op(opLineEnd) + c.op(opEnd) +} diff --git a/mcp/dsx-exchange-mcp/vendor/github.com/yosida95/uritemplate/v3/equals.go b/mcp/dsx-exchange-mcp/vendor/github.com/yosida95/uritemplate/v3/equals.go new file mode 100644 index 0000000..aa59a5c --- /dev/null +++ b/mcp/dsx-exchange-mcp/vendor/github.com/yosida95/uritemplate/v3/equals.go @@ -0,0 +1,53 @@ +// Copyright (C) 2016 Kohei YOSHIDA. All rights reserved. +// +// This program is free software; you can redistribute it and/or +// modify it under the terms of The BSD 3-Clause License +// that can be found in the LICENSE file. + +package uritemplate + +type CompareFlags uint8 + +const ( + CompareVarname CompareFlags = 1 << iota +) + +// Equals reports whether or not two URI Templates t1 and t2 are equivalent. +func Equals(t1 *Template, t2 *Template, flags CompareFlags) bool { + if len(t1.exprs) != len(t2.exprs) { + return false + } + for i := 0; i < len(t1.exprs); i++ { + switch t1 := t1.exprs[i].(type) { + case literals: + t2, ok := t2.exprs[i].(literals) + if !ok { + return false + } + if t1 != t2 { + return false + } + case *expression: + t2, ok := t2.exprs[i].(*expression) + if !ok { + return false + } + if t1.op != t2.op || len(t1.vars) != len(t2.vars) { + return false + } + for n := 0; n < len(t1.vars); n++ { + v1 := t1.vars[n] + v2 := t2.vars[n] + if flags&CompareVarname == CompareVarname && v1.name != v2.name { + return false + } + if v1.maxlen != v2.maxlen || v1.explode != v2.explode { + return false + } + } + default: + panic("unhandled case") + } + } + return true +} diff --git a/mcp/dsx-exchange-mcp/vendor/github.com/yosida95/uritemplate/v3/error.go b/mcp/dsx-exchange-mcp/vendor/github.com/yosida95/uritemplate/v3/error.go new file mode 100644 index 0000000..2fd34a8 --- /dev/null +++ b/mcp/dsx-exchange-mcp/vendor/github.com/yosida95/uritemplate/v3/error.go @@ -0,0 +1,16 @@ +// Copyright (C) 2016 Kohei YOSHIDA. All rights reserved. +// +// This program is free software; you can redistribute it and/or +// modify it under the terms of The BSD 3-Clause License +// that can be found in the LICENSE file. + +package uritemplate + +import ( + "fmt" +) + +func errorf(pos int, format string, a ...interface{}) error { + msg := fmt.Sprintf(format, a...) + return fmt.Errorf("uritemplate:%d:%s", pos, msg) +} diff --git a/mcp/dsx-exchange-mcp/vendor/github.com/yosida95/uritemplate/v3/escape.go b/mcp/dsx-exchange-mcp/vendor/github.com/yosida95/uritemplate/v3/escape.go new file mode 100644 index 0000000..6d27e69 --- /dev/null +++ b/mcp/dsx-exchange-mcp/vendor/github.com/yosida95/uritemplate/v3/escape.go @@ -0,0 +1,190 @@ +// Copyright (C) 2016 Kohei YOSHIDA. All rights reserved. +// +// This program is free software; you can redistribute it and/or +// modify it under the terms of The BSD 3-Clause License +// that can be found in the LICENSE file. + +package uritemplate + +import ( + "strings" + "unicode" + "unicode/utf8" +) + +var ( + hex = []byte("0123456789ABCDEF") + // reserved = gen-delims / sub-delims + // gen-delims = ":" / "/" / "?" / "#" / "[" / "]" / "@" + // sub-delims = "!" / "$" / "&" / "’" / "(" / ")" + // / "*" / "+" / "," / ";" / "=" + rangeReserved = &unicode.RangeTable{ + R16: []unicode.Range16{ + {Lo: 0x21, Hi: 0x21, Stride: 1}, // '!' + {Lo: 0x23, Hi: 0x24, Stride: 1}, // '#' - '$' + {Lo: 0x26, Hi: 0x2C, Stride: 1}, // '&' - ',' + {Lo: 0x2F, Hi: 0x2F, Stride: 1}, // '/' + {Lo: 0x3A, Hi: 0x3B, Stride: 1}, // ':' - ';' + {Lo: 0x3D, Hi: 0x3D, Stride: 1}, // '=' + {Lo: 0x3F, Hi: 0x40, Stride: 1}, // '?' - '@' + {Lo: 0x5B, Hi: 0x5B, Stride: 1}, // '[' + {Lo: 0x5D, Hi: 0x5D, Stride: 1}, // ']' + }, + LatinOffset: 9, + } + reReserved = `\x21\x23\x24\x26-\x2c\x2f\x3a\x3b\x3d\x3f\x40\x5b\x5d` + // ALPHA = %x41-5A / %x61-7A + // DIGIT = %x30-39 + // unreserved = ALPHA / DIGIT / "-" / "." / "_" / "~" + rangeUnreserved = &unicode.RangeTable{ + R16: []unicode.Range16{ + {Lo: 0x2D, Hi: 0x2E, Stride: 1}, // '-' - '.' + {Lo: 0x30, Hi: 0x39, Stride: 1}, // '0' - '9' + {Lo: 0x41, Hi: 0x5A, Stride: 1}, // 'A' - 'Z' + {Lo: 0x5F, Hi: 0x5F, Stride: 1}, // '_' + {Lo: 0x61, Hi: 0x7A, Stride: 1}, // 'a' - 'z' + {Lo: 0x7E, Hi: 0x7E, Stride: 1}, // '~' + }, + } + reUnreserved = `\x2d\x2e\x30-\x39\x41-\x5a\x5f\x61-\x7a\x7e` +) + +type runeClass uint8 + +const ( + runeClassU runeClass = 1 << iota + runeClassR + runeClassPctE + runeClassLast + + runeClassUR = runeClassU | runeClassR +) + +var runeClassNames = []string{ + "U", + "R", + "pct-encoded", +} + +func (rc runeClass) String() string { + ret := make([]string, 0, len(runeClassNames)) + for i, j := 0, runeClass(1); j < runeClassLast; j <<= 1 { + if rc&j == j { + ret = append(ret, runeClassNames[i]) + } + i++ + } + return strings.Join(ret, "+") +} + +func pctEncode(w *strings.Builder, r rune) { + if s := r >> 24 & 0xff; s > 0 { + w.Write([]byte{'%', hex[s/16], hex[s%16]}) + } + if s := r >> 16 & 0xff; s > 0 { + w.Write([]byte{'%', hex[s/16], hex[s%16]}) + } + if s := r >> 8 & 0xff; s > 0 { + w.Write([]byte{'%', hex[s/16], hex[s%16]}) + } + if s := r & 0xff; s > 0 { + w.Write([]byte{'%', hex[s/16], hex[s%16]}) + } +} + +func unhex(c byte) byte { + switch { + case '0' <= c && c <= '9': + return c - '0' + case 'a' <= c && c <= 'f': + return c - 'a' + 10 + case 'A' <= c && c <= 'F': + return c - 'A' + 10 + } + return 0 +} + +func ishex(c byte) bool { + switch { + case '0' <= c && c <= '9': + return true + case 'a' <= c && c <= 'f': + return true + case 'A' <= c && c <= 'F': + return true + default: + return false + } +} + +func pctDecode(s string) string { + size := len(s) + for i := 0; i < len(s); { + switch s[i] { + case '%': + size -= 2 + i += 3 + default: + i++ + } + } + if size == len(s) { + return s + } + + buf := make([]byte, size) + j := 0 + for i := 0; i < len(s); { + switch c := s[i]; c { + case '%': + buf[j] = unhex(s[i+1])<<4 | unhex(s[i+2]) + i += 3 + j++ + default: + buf[j] = c + i++ + j++ + } + } + return string(buf) +} + +type escapeFunc func(*strings.Builder, string) error + +func escapeLiteral(w *strings.Builder, v string) error { + w.WriteString(v) + return nil +} + +func escapeExceptU(w *strings.Builder, v string) error { + for i := 0; i < len(v); { + r, size := utf8.DecodeRuneInString(v[i:]) + if r == utf8.RuneError { + return errorf(i, "invalid encoding") + } + if unicode.Is(rangeUnreserved, r) { + w.WriteRune(r) + } else { + pctEncode(w, r) + } + i += size + } + return nil +} + +func escapeExceptUR(w *strings.Builder, v string) error { + for i := 0; i < len(v); { + r, size := utf8.DecodeRuneInString(v[i:]) + if r == utf8.RuneError { + return errorf(i, "invalid encoding") + } + // TODO(yosida95): is pct-encoded triplets allowed here? + if unicode.In(r, rangeUnreserved, rangeReserved) { + w.WriteRune(r) + } else { + pctEncode(w, r) + } + i += size + } + return nil +} diff --git a/mcp/dsx-exchange-mcp/vendor/github.com/yosida95/uritemplate/v3/expression.go b/mcp/dsx-exchange-mcp/vendor/github.com/yosida95/uritemplate/v3/expression.go new file mode 100644 index 0000000..4858c2d --- /dev/null +++ b/mcp/dsx-exchange-mcp/vendor/github.com/yosida95/uritemplate/v3/expression.go @@ -0,0 +1,173 @@ +// Copyright (C) 2016 Kohei YOSHIDA. All rights reserved. +// +// This program is free software; you can redistribute it and/or +// modify it under the terms of The BSD 3-Clause License +// that can be found in the LICENSE file. + +package uritemplate + +import ( + "regexp" + "strconv" + "strings" +) + +type template interface { + expand(*strings.Builder, Values) error + regexp(*strings.Builder) +} + +type literals string + +func (l literals) expand(b *strings.Builder, _ Values) error { + b.WriteString(string(l)) + return nil +} + +func (l literals) regexp(b *strings.Builder) { + b.WriteString("(?:") + b.WriteString(regexp.QuoteMeta(string(l))) + b.WriteByte(')') +} + +type varspec struct { + name string + maxlen int + explode bool +} + +type expression struct { + vars []varspec + op parseOp + first string + sep string + named bool + ifemp string + escape escapeFunc + allow runeClass +} + +func (e *expression) init() { + switch e.op { + case parseOpSimple: + e.sep = "," + e.escape = escapeExceptU + e.allow = runeClassU + case parseOpPlus: + e.sep = "," + e.escape = escapeExceptUR + e.allow = runeClassUR + case parseOpCrosshatch: + e.first = "#" + e.sep = "," + e.escape = escapeExceptUR + e.allow = runeClassUR + case parseOpDot: + e.first = "." + e.sep = "." + e.escape = escapeExceptU + e.allow = runeClassU + case parseOpSlash: + e.first = "/" + e.sep = "/" + e.escape = escapeExceptU + e.allow = runeClassU + case parseOpSemicolon: + e.first = ";" + e.sep = ";" + e.named = true + e.escape = escapeExceptU + e.allow = runeClassU + case parseOpQuestion: + e.first = "?" + e.sep = "&" + e.named = true + e.ifemp = "=" + e.escape = escapeExceptU + e.allow = runeClassU + case parseOpAmpersand: + e.first = "&" + e.sep = "&" + e.named = true + e.ifemp = "=" + e.escape = escapeExceptU + e.allow = runeClassU + } +} + +func (e *expression) expand(w *strings.Builder, values Values) error { + first := true + for _, varspec := range e.vars { + value := values.Get(varspec.name) + if !value.Valid() { + continue + } + + if first { + w.WriteString(e.first) + first = false + } else { + w.WriteString(e.sep) + } + + if err := value.expand(w, varspec, e); err != nil { + return err + } + + } + return nil +} + +func (e *expression) regexp(b *strings.Builder) { + if e.first != "" { + b.WriteString("(?:") // $1 + b.WriteString(regexp.QuoteMeta(e.first)) + } + b.WriteByte('(') // $2 + runeClassToRegexp(b, e.allow, e.named || e.vars[0].explode) + if len(e.vars) > 1 || e.vars[0].explode { + max := len(e.vars) - 1 + for i := 0; i < len(e.vars); i++ { + if e.vars[i].explode { + max = -1 + break + } + } + + b.WriteString("(?:") // $3 + b.WriteString(regexp.QuoteMeta(e.sep)) + runeClassToRegexp(b, e.allow, e.named || max < 0) + b.WriteByte(')') // $3 + if max > 0 { + b.WriteString("{0,") + b.WriteString(strconv.Itoa(max)) + b.WriteByte('}') + } else { + b.WriteByte('*') + } + } + b.WriteByte(')') // $2 + if e.first != "" { + b.WriteByte(')') // $1 + } + b.WriteByte('?') +} + +func runeClassToRegexp(b *strings.Builder, class runeClass, named bool) { + b.WriteString("(?:(?:[") + if class&runeClassR == 0 { + b.WriteString(`\x2c`) + if named { + b.WriteString(`\x3d`) + } + } + if class&runeClassU == runeClassU { + b.WriteString(reUnreserved) + } + if class&runeClassR == runeClassR { + b.WriteString(reReserved) + } + b.WriteString("]") + b.WriteString("|%[[:xdigit:]][[:xdigit:]]") + b.WriteString(")*)") +} diff --git a/mcp/dsx-exchange-mcp/vendor/github.com/yosida95/uritemplate/v3/machine.go b/mcp/dsx-exchange-mcp/vendor/github.com/yosida95/uritemplate/v3/machine.go new file mode 100644 index 0000000..7b1d0b5 --- /dev/null +++ b/mcp/dsx-exchange-mcp/vendor/github.com/yosida95/uritemplate/v3/machine.go @@ -0,0 +1,23 @@ +// Copyright (C) 2016 Kohei YOSHIDA. All rights reserved. +// +// This program is free software; you can redistribute it and/or +// modify it under the terms of The BSD 3-Clause License +// that can be found in the LICENSE file. + +package uritemplate + +// threadList implements https://research.swtch.com/sparse. +type threadList struct { + dense []threadEntry + sparse []uint32 +} + +type threadEntry struct { + pc uint32 + t *thread +} + +type thread struct { + op *progOp + cap map[string][]int +} diff --git a/mcp/dsx-exchange-mcp/vendor/github.com/yosida95/uritemplate/v3/match.go b/mcp/dsx-exchange-mcp/vendor/github.com/yosida95/uritemplate/v3/match.go new file mode 100644 index 0000000..02fe638 --- /dev/null +++ b/mcp/dsx-exchange-mcp/vendor/github.com/yosida95/uritemplate/v3/match.go @@ -0,0 +1,213 @@ +// Copyright (C) 2016 Kohei YOSHIDA. All rights reserved. +// +// This program is free software; you can redistribute it and/or +// modify it under the terms of The BSD 3-Clause License +// that can be found in the LICENSE file. + +package uritemplate + +import ( + "bytes" + "unicode" + "unicode/utf8" +) + +type matcher struct { + prog *prog + + list1 threadList + list2 threadList + matched bool + cap map[string][]int + + input string +} + +func (m *matcher) at(pos int) (rune, int, bool) { + if l := len(m.input); pos < l { + c := m.input[pos] + if c < utf8.RuneSelf { + return rune(c), 1, pos+1 < l + } + r, size := utf8.DecodeRuneInString(m.input[pos:]) + return r, size, pos+size < l + } + return -1, 0, false +} + +func (m *matcher) add(list *threadList, pc uint32, pos int, next bool, cap map[string][]int) { + if i := list.sparse[pc]; i < uint32(len(list.dense)) && list.dense[i].pc == pc { + return + } + + n := len(list.dense) + list.dense = list.dense[:n+1] + list.sparse[pc] = uint32(n) + + e := &list.dense[n] + e.pc = pc + e.t = nil + + op := &m.prog.op[pc] + switch op.code { + default: + panic("unhandled opcode") + case opRune, opRuneClass, opEnd: + e.t = &thread{ + op: &m.prog.op[pc], + cap: make(map[string][]int, len(m.cap)), + } + for k, v := range cap { + e.t.cap[k] = make([]int, len(v)) + copy(e.t.cap[k], v) + } + case opLineBegin: + if pos == 0 { + m.add(list, pc+1, pos, next, cap) + } + case opLineEnd: + if !next { + m.add(list, pc+1, pos, next, cap) + } + case opCapStart, opCapEnd: + ocap := make(map[string][]int, len(m.cap)) + for k, v := range cap { + ocap[k] = make([]int, len(v)) + copy(ocap[k], v) + } + ocap[op.name] = append(ocap[op.name], pos) + m.add(list, pc+1, pos, next, ocap) + case opSplit: + m.add(list, pc+1, pos, next, cap) + m.add(list, op.i, pos, next, cap) + case opJmp: + m.add(list, op.i, pos, next, cap) + case opJmpIfNotDefined: + m.add(list, pc+1, pos, next, cap) + m.add(list, op.i, pos, next, cap) + case opJmpIfNotFirst: + m.add(list, pc+1, pos, next, cap) + m.add(list, op.i, pos, next, cap) + case opJmpIfNotEmpty: + m.add(list, op.i, pos, next, cap) + m.add(list, pc+1, pos, next, cap) + case opNoop: + m.add(list, pc+1, pos, next, cap) + } +} + +func (m *matcher) step(clist *threadList, nlist *threadList, r rune, pos int, nextPos int, next bool) { + debug.Printf("===== %q =====", string(r)) + for i := 0; i < len(clist.dense); i++ { + e := clist.dense[i] + if debug { + var buf bytes.Buffer + dumpProg(&buf, m.prog, e.pc) + debug.Printf("\n%s", buf.String()) + } + if e.t == nil { + continue + } + + t := e.t + op := t.op + switch op.code { + default: + panic("unhandled opcode") + case opRune: + if op.r == r { + m.add(nlist, e.pc+1, nextPos, next, t.cap) + } + case opRuneClass: + ret := false + if !ret && op.rc&runeClassU == runeClassU { + ret = ret || unicode.Is(rangeUnreserved, r) + } + if !ret && op.rc&runeClassR == runeClassR { + ret = ret || unicode.Is(rangeReserved, r) + } + if !ret && op.rc&runeClassPctE == runeClassPctE { + ret = ret || unicode.Is(unicode.ASCII_Hex_Digit, r) + } + if ret { + m.add(nlist, e.pc+1, nextPos, next, t.cap) + } + case opEnd: + m.matched = true + for k, v := range t.cap { + m.cap[k] = make([]int, len(v)) + copy(m.cap[k], v) + } + clist.dense = clist.dense[:0] + } + } + clist.dense = clist.dense[:0] +} + +func (m *matcher) match() bool { + pos := 0 + clist, nlist := &m.list1, &m.list2 + for { + if len(clist.dense) == 0 && m.matched { + break + } + r, width, next := m.at(pos) + if !m.matched { + m.add(clist, 0, pos, next, m.cap) + } + m.step(clist, nlist, r, pos, pos+width, next) + + if width < 1 { + break + } + pos += width + + clist, nlist = nlist, clist + } + return m.matched +} + +func (tmpl *Template) Match(expansion string) Values { + tmpl.mu.Lock() + if tmpl.prog == nil { + c := compiler{} + c.init() + c.compile(tmpl) + tmpl.prog = c.prog + } + prog := tmpl.prog + tmpl.mu.Unlock() + + n := len(prog.op) + m := matcher{ + prog: prog, + list1: threadList{ + dense: make([]threadEntry, 0, n), + sparse: make([]uint32, n), + }, + list2: threadList{ + dense: make([]threadEntry, 0, n), + sparse: make([]uint32, n), + }, + cap: make(map[string][]int, prog.numCap), + input: expansion, + } + if !m.match() { + return nil + } + + match := make(Values, len(m.cap)) + for name, indices := range m.cap { + v := Value{V: make([]string, len(indices)/2)} + for i := range v.V { + v.V[i] = pctDecode(expansion[indices[2*i]:indices[2*i+1]]) + } + if len(v.V) == 1 { + v.T = ValueTypeString + } else { + v.T = ValueTypeList + } + match[name] = v + } + return match +} diff --git a/mcp/dsx-exchange-mcp/vendor/github.com/yosida95/uritemplate/v3/parse.go b/mcp/dsx-exchange-mcp/vendor/github.com/yosida95/uritemplate/v3/parse.go new file mode 100644 index 0000000..fd38a68 --- /dev/null +++ b/mcp/dsx-exchange-mcp/vendor/github.com/yosida95/uritemplate/v3/parse.go @@ -0,0 +1,277 @@ +// Copyright (C) 2016 Kohei YOSHIDA. All rights reserved. +// +// This program is free software; you can redistribute it and/or +// modify it under the terms of The BSD 3-Clause License +// that can be found in the LICENSE file. + +package uritemplate + +import ( + "fmt" + "unicode" + "unicode/utf8" +) + +type parseOp int + +const ( + parseOpSimple parseOp = iota + parseOpPlus + parseOpCrosshatch + parseOpDot + parseOpSlash + parseOpSemicolon + parseOpQuestion + parseOpAmpersand +) + +var ( + rangeVarchar = &unicode.RangeTable{ + R16: []unicode.Range16{ + {Lo: 0x0030, Hi: 0x0039, Stride: 1}, // '0' - '9' + {Lo: 0x0041, Hi: 0x005A, Stride: 1}, // 'A' - 'Z' + {Lo: 0x005F, Hi: 0x005F, Stride: 1}, // '_' + {Lo: 0x0061, Hi: 0x007A, Stride: 1}, // 'a' - 'z' + }, + LatinOffset: 4, + } + rangeLiterals = &unicode.RangeTable{ + R16: []unicode.Range16{ + {Lo: 0x0021, Hi: 0x0021, Stride: 1}, // '!' + {Lo: 0x0023, Hi: 0x0024, Stride: 1}, // '#' - '$' + {Lo: 0x0026, Hi: 0x003B, Stride: 1}, // '&' ''' '(' - ';'. '''/27 used to be excluded but an errata is in the review process https://www.rfc-editor.org/errata/eid6937 + {Lo: 0x003D, Hi: 0x003D, Stride: 1}, // '=' + {Lo: 0x003F, Hi: 0x005B, Stride: 1}, // '?' - '[' + {Lo: 0x005D, Hi: 0x005D, Stride: 1}, // ']' + {Lo: 0x005F, Hi: 0x005F, Stride: 1}, // '_' + {Lo: 0x0061, Hi: 0x007A, Stride: 1}, // 'a' - 'z' + {Lo: 0x007E, Hi: 0x007E, Stride: 1}, // '~' + {Lo: 0x00A0, Hi: 0xD7FF, Stride: 1}, // ucschar + {Lo: 0xE000, Hi: 0xF8FF, Stride: 1}, // iprivate + {Lo: 0xF900, Hi: 0xFDCF, Stride: 1}, // ucschar + {Lo: 0xFDF0, Hi: 0xFFEF, Stride: 1}, // ucschar + }, + R32: []unicode.Range32{ + {Lo: 0x00010000, Hi: 0x0001FFFD, Stride: 1}, // ucschar + {Lo: 0x00020000, Hi: 0x0002FFFD, Stride: 1}, // ucschar + {Lo: 0x00030000, Hi: 0x0003FFFD, Stride: 1}, // ucschar + {Lo: 0x00040000, Hi: 0x0004FFFD, Stride: 1}, // ucschar + {Lo: 0x00050000, Hi: 0x0005FFFD, Stride: 1}, // ucschar + {Lo: 0x00060000, Hi: 0x0006FFFD, Stride: 1}, // ucschar + {Lo: 0x00070000, Hi: 0x0007FFFD, Stride: 1}, // ucschar + {Lo: 0x00080000, Hi: 0x0008FFFD, Stride: 1}, // ucschar + {Lo: 0x00090000, Hi: 0x0009FFFD, Stride: 1}, // ucschar + {Lo: 0x000A0000, Hi: 0x000AFFFD, Stride: 1}, // ucschar + {Lo: 0x000B0000, Hi: 0x000BFFFD, Stride: 1}, // ucschar + {Lo: 0x000C0000, Hi: 0x000CFFFD, Stride: 1}, // ucschar + {Lo: 0x000D0000, Hi: 0x000DFFFD, Stride: 1}, // ucschar + {Lo: 0x000E1000, Hi: 0x000EFFFD, Stride: 1}, // ucschar + {Lo: 0x000F0000, Hi: 0x000FFFFD, Stride: 1}, // iprivate + {Lo: 0x00100000, Hi: 0x0010FFFD, Stride: 1}, // iprivate + }, + LatinOffset: 10, + } +) + +type parser struct { + r string + start int + stop int + state parseState +} + +func (p *parser) errorf(i rune, format string, a ...interface{}) error { + return fmt.Errorf("%s: %s%s", fmt.Sprintf(format, a...), p.r[0:p.stop], string(i)) +} + +func (p *parser) rune() (rune, int) { + r, size := utf8.DecodeRuneInString(p.r[p.stop:]) + if r != utf8.RuneError { + p.stop += size + } + return r, size +} + +func (p *parser) unread(r rune) { + p.stop -= utf8.RuneLen(r) +} + +type parseState int + +const ( + parseStateDefault = parseState(iota) + parseStateOperator + parseStateVarList + parseStateVarName + parseStatePrefix +) + +func (p *parser) setState(state parseState) { + p.state = state + p.start = p.stop +} + +func (p *parser) parseURITemplate() (*Template, error) { + tmpl := Template{ + raw: p.r, + exprs: []template{}, + } + + var exp *expression + for { + r, size := p.rune() + if r == utf8.RuneError { + if size == 0 { + if p.state != parseStateDefault { + return nil, p.errorf('_', "incomplete expression") + } + if p.start < p.stop { + tmpl.exprs = append(tmpl.exprs, literals(p.r[p.start:p.stop])) + } + return &tmpl, nil + } + return nil, p.errorf('_', "invalid UTF-8 sequence") + } + + switch p.state { + case parseStateDefault: + switch r { + case '{': + if stop := p.stop - size; stop > p.start { + tmpl.exprs = append(tmpl.exprs, literals(p.r[p.start:stop])) + } + exp = &expression{} + tmpl.exprs = append(tmpl.exprs, exp) + p.setState(parseStateOperator) + case '%': + p.unread(r) + if err := p.consumeTriplet(); err != nil { + return nil, err + } + default: + if !unicode.Is(rangeLiterals, r) { + p.unread(r) + return nil, p.errorf('_', "unacceptable character (hint: use %%XX encoding)") + } + } + case parseStateOperator: + switch r { + default: + p.unread(r) + exp.op = parseOpSimple + case '+': + exp.op = parseOpPlus + case '#': + exp.op = parseOpCrosshatch + case '.': + exp.op = parseOpDot + case '/': + exp.op = parseOpSlash + case ';': + exp.op = parseOpSemicolon + case '?': + exp.op = parseOpQuestion + case '&': + exp.op = parseOpAmpersand + case '=', ',', '!', '@', '|': // op-reserved + return nil, p.errorf('|', "unimplemented operator (op-reserved)") + } + p.setState(parseStateVarName) + case parseStateVarList: + switch r { + case ',': + p.setState(parseStateVarName) + case '}': + exp.init() + p.setState(parseStateDefault) + default: + p.unread(r) + return nil, p.errorf('_', "unrecognized value modifier") + } + case parseStateVarName: + switch r { + case ':', '*': + name := p.r[p.start : p.stop-size] + if !isValidVarname(name) { + return nil, p.errorf('|', "unacceptable variable name") + } + explode := r == '*' + exp.vars = append(exp.vars, varspec{ + name: name, + explode: explode, + }) + if explode { + p.setState(parseStateVarList) + } else { + p.setState(parseStatePrefix) + } + case ',', '}': + p.unread(r) + name := p.r[p.start:p.stop] + if !isValidVarname(name) { + return nil, p.errorf('|', "unacceptable variable name") + } + exp.vars = append(exp.vars, varspec{ + name: name, + }) + p.setState(parseStateVarList) + case '%': + p.unread(r) + if err := p.consumeTriplet(); err != nil { + return nil, err + } + case '.': + if dot := p.stop - size; dot == p.start || p.r[dot-1] == '.' { + return nil, p.errorf('|', "unacceptable variable name") + } + default: + if !unicode.Is(rangeVarchar, r) { + p.unread(r) + return nil, p.errorf('_', "unacceptable variable name") + } + } + case parseStatePrefix: + spec := &(exp.vars[len(exp.vars)-1]) + switch { + case '0' <= r && r <= '9': + spec.maxlen *= 10 + spec.maxlen += int(r - '0') + if spec.maxlen == 0 || spec.maxlen > 9999 { + return nil, p.errorf('|', "max-length must be (0, 9999]") + } + default: + p.unread(r) + if spec.maxlen == 0 { + return nil, p.errorf('_', "max-length must be (0, 9999]") + } + p.setState(parseStateVarList) + } + default: + p.unread(r) + panic(p.errorf('_', "unhandled parseState(%d)", p.state)) + } + } +} + +func isValidVarname(name string) bool { + if l := len(name); l == 0 || name[0] == '.' || name[l-1] == '.' { + return false + } + for i := 1; i < len(name)-1; i++ { + switch c := name[i]; c { + case '.': + if name[i-1] == '.' { + return false + } + } + } + return true +} + +func (p *parser) consumeTriplet() error { + if len(p.r)-p.stop < 3 || p.r[p.stop] != '%' || !ishex(p.r[p.stop+1]) || !ishex(p.r[p.stop+2]) { + return p.errorf('_', "incomplete pct-encodeed") + } + p.stop += 3 + return nil +} diff --git a/mcp/dsx-exchange-mcp/vendor/github.com/yosida95/uritemplate/v3/prog.go b/mcp/dsx-exchange-mcp/vendor/github.com/yosida95/uritemplate/v3/prog.go new file mode 100644 index 0000000..97af4f0 --- /dev/null +++ b/mcp/dsx-exchange-mcp/vendor/github.com/yosida95/uritemplate/v3/prog.go @@ -0,0 +1,130 @@ +// Copyright (C) 2016 Kohei YOSHIDA. All rights reserved. +// +// This program is free software; you can redistribute it and/or +// modify it under the terms of The BSD 3-Clause License +// that can be found in the LICENSE file. + +package uritemplate + +import ( + "bytes" + "strconv" +) + +type progOpcode uint16 + +const ( + // match + opRune progOpcode = iota + opRuneClass + opLineBegin + opLineEnd + // capture + opCapStart + opCapEnd + // stack + opSplit + opJmp + opJmpIfNotDefined + opJmpIfNotEmpty + opJmpIfNotFirst + // result + opEnd + // fake + opNoop + opcodeMax +) + +var opcodeNames = []string{ + // match + "opRune", + "opRuneClass", + "opLineBegin", + "opLineEnd", + // capture + "opCapStart", + "opCapEnd", + // stack + "opSplit", + "opJmp", + "opJmpIfNotDefined", + "opJmpIfNotEmpty", + "opJmpIfNotFirst", + // result + "opEnd", +} + +func (code progOpcode) String() string { + if code >= opcodeMax { + return "" + } + return opcodeNames[code] +} + +type progOp struct { + code progOpcode + r rune + rc runeClass + i uint32 + + name string +} + +func dumpProgOp(b *bytes.Buffer, op *progOp) { + b.WriteString(op.code.String()) + switch op.code { + case opRune: + b.WriteString("(") + b.WriteString(strconv.QuoteToASCII(string(op.r))) + b.WriteString(")") + case opRuneClass: + b.WriteString("(") + b.WriteString(op.rc.String()) + b.WriteString(")") + case opCapStart, opCapEnd: + b.WriteString("(") + b.WriteString(strconv.QuoteToASCII(op.name)) + b.WriteString(")") + case opSplit: + b.WriteString(" -> ") + b.WriteString(strconv.FormatInt(int64(op.i), 10)) + case opJmp, opJmpIfNotFirst: + b.WriteString(" -> ") + b.WriteString(strconv.FormatInt(int64(op.i), 10)) + case opJmpIfNotDefined, opJmpIfNotEmpty: + b.WriteString("(") + b.WriteString(strconv.QuoteToASCII(op.name)) + b.WriteString(")") + b.WriteString(" -> ") + b.WriteString(strconv.FormatInt(int64(op.i), 10)) + } +} + +type prog struct { + op []progOp + numCap int +} + +func dumpProg(b *bytes.Buffer, prog *prog, pc uint32) { + for i := range prog.op { + op := prog.op[i] + + pos := strconv.Itoa(i) + if uint32(i) == pc { + pos = "*" + pos + } + b.WriteString(" "[len(pos):]) + b.WriteString(pos) + + b.WriteByte('\t') + dumpProgOp(b, &op) + + b.WriteByte('\n') + } +} + +func (p *prog) String() string { + b := bytes.Buffer{} + dumpProg(&b, p, 0) + return b.String() +} diff --git a/mcp/dsx-exchange-mcp/vendor/github.com/yosida95/uritemplate/v3/uritemplate.go b/mcp/dsx-exchange-mcp/vendor/github.com/yosida95/uritemplate/v3/uritemplate.go new file mode 100644 index 0000000..dbd2673 --- /dev/null +++ b/mcp/dsx-exchange-mcp/vendor/github.com/yosida95/uritemplate/v3/uritemplate.go @@ -0,0 +1,116 @@ +// Copyright (C) 2016 Kohei YOSHIDA. All rights reserved. +// +// This program is free software; you can redistribute it and/or +// modify it under the terms of The BSD 3-Clause License +// that can be found in the LICENSE file. + +package uritemplate + +import ( + "log" + "regexp" + "strings" + "sync" +) + +var ( + debug = debugT(false) +) + +type debugT bool + +func (t debugT) Printf(format string, v ...interface{}) { + if t { + log.Printf(format, v...) + } +} + +// Template represents a URI Template. +type Template struct { + raw string + exprs []template + + // protects the rest of fields + mu sync.Mutex + varnames []string + re *regexp.Regexp + prog *prog +} + +// New parses and constructs a new Template instance based on the template. +// New returns an error if the template cannot be recognized. +func New(template string) (*Template, error) { + return (&parser{r: template}).parseURITemplate() +} + +// MustNew panics if the template cannot be recognized. +func MustNew(template string) *Template { + ret, err := New(template) + if err != nil { + panic(err) + } + return ret +} + +// Raw returns a raw URI template passed to New in string. +func (t *Template) Raw() string { + return t.raw +} + +// Varnames returns variable names used in the template. +func (t *Template) Varnames() []string { + t.mu.Lock() + defer t.mu.Unlock() + if t.varnames != nil { + return t.varnames + } + + reg := map[string]struct{}{} + t.varnames = []string{} + for i := range t.exprs { + expr, ok := t.exprs[i].(*expression) + if !ok { + continue + } + for _, spec := range expr.vars { + if _, ok := reg[spec.name]; ok { + continue + } + reg[spec.name] = struct{}{} + t.varnames = append(t.varnames, spec.name) + } + } + + return t.varnames +} + +// Expand returns a URI reference corresponding to the template expanded using the passed variables. +func (t *Template) Expand(vars Values) (string, error) { + var w strings.Builder + for i := range t.exprs { + expr := t.exprs[i] + if err := expr.expand(&w, vars); err != nil { + return w.String(), err + } + } + return w.String(), nil +} + +// Regexp converts the template to regexp and returns compiled *regexp.Regexp. +func (t *Template) Regexp() *regexp.Regexp { + t.mu.Lock() + defer t.mu.Unlock() + if t.re != nil { + return t.re + } + + var b strings.Builder + b.WriteByte('^') + for _, expr := range t.exprs { + expr.regexp(&b) + } + b.WriteByte('$') + t.re = regexp.MustCompile(b.String()) + + return t.re +} diff --git a/mcp/dsx-exchange-mcp/vendor/github.com/yosida95/uritemplate/v3/value.go b/mcp/dsx-exchange-mcp/vendor/github.com/yosida95/uritemplate/v3/value.go new file mode 100644 index 0000000..0550eab --- /dev/null +++ b/mcp/dsx-exchange-mcp/vendor/github.com/yosida95/uritemplate/v3/value.go @@ -0,0 +1,216 @@ +// Copyright (C) 2016 Kohei YOSHIDA. All rights reserved. +// +// This program is free software; you can redistribute it and/or +// modify it under the terms of The BSD 3-Clause License +// that can be found in the LICENSE file. + +package uritemplate + +import "strings" + +// A varname containing pct-encoded characters is not the same variable as +// a varname with those same characters decoded. +// +// -- https://tools.ietf.org/html/rfc6570#section-2.3 +type Values map[string]Value + +func (v Values) Set(name string, value Value) { + v[name] = value +} + +func (v Values) Get(name string) Value { + if v == nil { + return Value{} + } + return v[name] +} + +type ValueType uint8 + +const ( + ValueTypeString = iota + ValueTypeList + ValueTypeKV + valueTypeLast +) + +var valueTypeNames = []string{ + "String", + "List", + "KV", +} + +func (vt ValueType) String() string { + if vt < valueTypeLast { + return valueTypeNames[vt] + } + return "" +} + +type Value struct { + T ValueType + V []string +} + +func (v Value) String() string { + if v.Valid() && v.T == ValueTypeString { + return v.V[0] + } + return "" +} + +func (v Value) List() []string { + if v.Valid() && v.T == ValueTypeList { + return v.V + } + return nil +} + +func (v Value) KV() []string { + if v.Valid() && v.T == ValueTypeKV { + return v.V + } + return nil +} + +func (v Value) Valid() bool { + switch v.T { + default: + return false + case ValueTypeString: + return len(v.V) > 0 + case ValueTypeList: + return len(v.V) > 0 + case ValueTypeKV: + return len(v.V) > 0 && len(v.V)%2 == 0 + } +} + +func (v Value) expand(w *strings.Builder, spec varspec, exp *expression) error { + switch v.T { + case ValueTypeString: + val := v.V[0] + var maxlen int + if max := len(val); spec.maxlen < 1 || spec.maxlen > max { + maxlen = max + } else { + maxlen = spec.maxlen + } + + if exp.named { + w.WriteString(spec.name) + if val == "" { + w.WriteString(exp.ifemp) + return nil + } + w.WriteByte('=') + } + return exp.escape(w, val[:maxlen]) + case ValueTypeList: + var sep string + if spec.explode { + sep = exp.sep + } else { + sep = "," + } + + var pre string + var preifemp string + if spec.explode && exp.named { + pre = spec.name + "=" + preifemp = spec.name + exp.ifemp + } + + if !spec.explode && exp.named { + w.WriteString(spec.name) + w.WriteByte('=') + } + for i := range v.V { + val := v.V[i] + if i > 0 { + w.WriteString(sep) + } + if val == "" { + w.WriteString(preifemp) + continue + } + w.WriteString(pre) + + if err := exp.escape(w, val); err != nil { + return err + } + } + case ValueTypeKV: + var sep string + var kvsep string + if spec.explode { + sep = exp.sep + kvsep = "=" + } else { + sep = "," + kvsep = "," + } + + var ifemp string + var kescape escapeFunc + if spec.explode && exp.named { + ifemp = exp.ifemp + kescape = escapeLiteral + } else { + ifemp = "," + kescape = exp.escape + } + + if !spec.explode && exp.named { + w.WriteString(spec.name) + w.WriteByte('=') + } + + for i := 0; i < len(v.V); i += 2 { + if i > 0 { + w.WriteString(sep) + } + if err := kescape(w, v.V[i]); err != nil { + return err + } + if v.V[i+1] == "" { + w.WriteString(ifemp) + continue + } + w.WriteString(kvsep) + + if err := exp.escape(w, v.V[i+1]); err != nil { + return err + } + } + } + return nil +} + +// String returns Value that represents string. +func String(v string) Value { + return Value{ + T: ValueTypeString, + V: []string{v}, + } +} + +// List returns Value that represents list. +func List(v ...string) Value { + return Value{ + T: ValueTypeList, + V: v, + } +} + +// KV returns Value that represents associative list. +// KV panics if len(kv) is not even. +func KV(kv ...string) Value { + if len(kv)%2 != 0 { + panic("uritemplate.go: count of the kv must be even number") + } + return Value{ + T: ValueTypeKV, + V: kv, + } +} diff --git a/mcp/dsx-exchange-mcp/vendor/golang.org/x/net/LICENSE b/mcp/dsx-exchange-mcp/vendor/golang.org/x/net/LICENSE new file mode 100644 index 0000000..2a7cf70 --- /dev/null +++ b/mcp/dsx-exchange-mcp/vendor/golang.org/x/net/LICENSE @@ -0,0 +1,27 @@ +Copyright 2009 The Go Authors. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are +met: + + * Redistributions of source code must retain the above copyright +notice, this list of conditions and the following disclaimer. + * Redistributions in binary form must reproduce the above +copyright notice, this list of conditions and the following disclaimer +in the documentation and/or other materials provided with the +distribution. + * Neither the name of Google LLC nor the names of its +contributors may be used to endorse or promote products derived from +this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT +OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT +LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, +DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY +THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/mcp/dsx-exchange-mcp/vendor/golang.org/x/net/PATENTS b/mcp/dsx-exchange-mcp/vendor/golang.org/x/net/PATENTS new file mode 100644 index 0000000..7330990 --- /dev/null +++ b/mcp/dsx-exchange-mcp/vendor/golang.org/x/net/PATENTS @@ -0,0 +1,22 @@ +Additional IP Rights Grant (Patents) + +"This implementation" means the copyrightable works distributed by +Google as part of the Go project. + +Google hereby grants to You a perpetual, worldwide, non-exclusive, +no-charge, royalty-free, irrevocable (except as stated in this section) +patent license to make, have made, use, offer to sell, sell, import, +transfer and otherwise run, modify and propagate the contents of this +implementation of Go, where such license applies only to those patent +claims, both currently owned or controlled by Google and acquired in +the future, licensable by Google that are necessarily infringed by this +implementation of Go. This grant does not include claims that would be +infringed only as a consequence of further modification of this +implementation. If you or your agent or exclusive licensee institute or +order or agree to the institution of patent litigation against any +entity (including a cross-claim or counterclaim in a lawsuit) alleging +that this implementation of Go or any code incorporated within this +implementation of Go constitutes direct or contributory patent +infringement, or inducement of patent infringement, then any patent +rights granted to you under this License for this implementation of Go +shall terminate as of the date such litigation is filed. diff --git a/mcp/dsx-exchange-mcp/vendor/golang.org/x/net/internal/socks/client.go b/mcp/dsx-exchange-mcp/vendor/golang.org/x/net/internal/socks/client.go new file mode 100644 index 0000000..3d6f516 --- /dev/null +++ b/mcp/dsx-exchange-mcp/vendor/golang.org/x/net/internal/socks/client.go @@ -0,0 +1,168 @@ +// Copyright 2018 The Go Authors. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +package socks + +import ( + "context" + "errors" + "io" + "net" + "strconv" + "time" +) + +var ( + noDeadline = time.Time{} + aLongTimeAgo = time.Unix(1, 0) +) + +func (d *Dialer) connect(ctx context.Context, c net.Conn, address string) (_ net.Addr, ctxErr error) { + host, port, err := splitHostPort(address) + if err != nil { + return nil, err + } + if deadline, ok := ctx.Deadline(); ok && !deadline.IsZero() { + c.SetDeadline(deadline) + defer c.SetDeadline(noDeadline) + } + if ctx != context.Background() { + errCh := make(chan error, 1) + done := make(chan struct{}) + defer func() { + close(done) + if ctxErr == nil { + ctxErr = <-errCh + } + }() + go func() { + select { + case <-ctx.Done(): + c.SetDeadline(aLongTimeAgo) + errCh <- ctx.Err() + case <-done: + errCh <- nil + } + }() + } + + b := make([]byte, 0, 6+len(host)) // the size here is just an estimate + b = append(b, Version5) + if len(d.AuthMethods) == 0 || d.Authenticate == nil { + b = append(b, 1, byte(AuthMethodNotRequired)) + } else { + ams := d.AuthMethods + if len(ams) > 255 { + return nil, errors.New("too many authentication methods") + } + b = append(b, byte(len(ams))) + for _, am := range ams { + b = append(b, byte(am)) + } + } + if _, ctxErr = c.Write(b); ctxErr != nil { + return + } + + if _, ctxErr = io.ReadFull(c, b[:2]); ctxErr != nil { + return + } + if b[0] != Version5 { + return nil, errors.New("unexpected protocol version " + strconv.Itoa(int(b[0]))) + } + am := AuthMethod(b[1]) + if am == AuthMethodNoAcceptableMethods { + return nil, errors.New("no acceptable authentication methods") + } + if d.Authenticate != nil { + if ctxErr = d.Authenticate(ctx, c, am); ctxErr != nil { + return + } + } + + b = b[:0] + b = append(b, Version5, byte(d.cmd), 0) + if ip := net.ParseIP(host); ip != nil { + if ip4 := ip.To4(); ip4 != nil { + b = append(b, AddrTypeIPv4) + b = append(b, ip4...) + } else if ip6 := ip.To16(); ip6 != nil { + b = append(b, AddrTypeIPv6) + b = append(b, ip6...) + } else { + return nil, errors.New("unknown address type") + } + } else { + if len(host) > 255 { + return nil, errors.New("FQDN too long") + } + b = append(b, AddrTypeFQDN) + b = append(b, byte(len(host))) + b = append(b, host...) + } + b = append(b, byte(port>>8), byte(port)) + if _, ctxErr = c.Write(b); ctxErr != nil { + return + } + + if _, ctxErr = io.ReadFull(c, b[:4]); ctxErr != nil { + return + } + if b[0] != Version5 { + return nil, errors.New("unexpected protocol version " + strconv.Itoa(int(b[0]))) + } + if cmdErr := Reply(b[1]); cmdErr != StatusSucceeded { + return nil, errors.New("unknown error " + cmdErr.String()) + } + if b[2] != 0 { + return nil, errors.New("non-zero reserved field") + } + l := 2 + var a Addr + switch b[3] { + case AddrTypeIPv4: + l += net.IPv4len + a.IP = make(net.IP, net.IPv4len) + case AddrTypeIPv6: + l += net.IPv6len + a.IP = make(net.IP, net.IPv6len) + case AddrTypeFQDN: + if _, err := io.ReadFull(c, b[:1]); err != nil { + return nil, err + } + l += int(b[0]) + default: + return nil, errors.New("unknown address type " + strconv.Itoa(int(b[3]))) + } + if cap(b) < l { + b = make([]byte, l) + } else { + b = b[:l] + } + if _, ctxErr = io.ReadFull(c, b); ctxErr != nil { + return + } + if a.IP != nil { + copy(a.IP, b) + } else { + a.Name = string(b[:len(b)-2]) + } + a.Port = int(b[len(b)-2])<<8 | int(b[len(b)-1]) + return &a, nil +} + +func splitHostPort(address string) (string, int, error) { + host, port, err := net.SplitHostPort(address) + if err != nil { + return "", 0, err + } + portnum, err := strconv.Atoi(port) + if err != nil { + return "", 0, err + } + if 1 > portnum || portnum > 0xffff { + return "", 0, errors.New("port number out of range " + port) + } + return host, portnum, nil +} diff --git a/mcp/dsx-exchange-mcp/vendor/golang.org/x/net/internal/socks/socks.go b/mcp/dsx-exchange-mcp/vendor/golang.org/x/net/internal/socks/socks.go new file mode 100644 index 0000000..84fcc32 --- /dev/null +++ b/mcp/dsx-exchange-mcp/vendor/golang.org/x/net/internal/socks/socks.go @@ -0,0 +1,317 @@ +// Copyright 2018 The Go Authors. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +// Package socks provides a SOCKS version 5 client implementation. +// +// SOCKS protocol version 5 is defined in RFC 1928. +// Username/Password authentication for SOCKS version 5 is defined in +// RFC 1929. +package socks + +import ( + "context" + "errors" + "io" + "net" + "strconv" +) + +// A Command represents a SOCKS command. +type Command int + +func (cmd Command) String() string { + switch cmd { + case CmdConnect: + return "socks connect" + case cmdBind: + return "socks bind" + default: + return "socks " + strconv.Itoa(int(cmd)) + } +} + +// An AuthMethod represents a SOCKS authentication method. +type AuthMethod int + +// A Reply represents a SOCKS command reply code. +type Reply int + +func (code Reply) String() string { + switch code { + case StatusSucceeded: + return "succeeded" + case 0x01: + return "general SOCKS server failure" + case 0x02: + return "connection not allowed by ruleset" + case 0x03: + return "network unreachable" + case 0x04: + return "host unreachable" + case 0x05: + return "connection refused" + case 0x06: + return "TTL expired" + case 0x07: + return "command not supported" + case 0x08: + return "address type not supported" + default: + return "unknown code: " + strconv.Itoa(int(code)) + } +} + +// Wire protocol constants. +const ( + Version5 = 0x05 + + AddrTypeIPv4 = 0x01 + AddrTypeFQDN = 0x03 + AddrTypeIPv6 = 0x04 + + CmdConnect Command = 0x01 // establishes an active-open forward proxy connection + cmdBind Command = 0x02 // establishes a passive-open forward proxy connection + + AuthMethodNotRequired AuthMethod = 0x00 // no authentication required + AuthMethodUsernamePassword AuthMethod = 0x02 // use username/password + AuthMethodNoAcceptableMethods AuthMethod = 0xff // no acceptable authentication methods + + StatusSucceeded Reply = 0x00 +) + +// An Addr represents a SOCKS-specific address. +// Either Name or IP is used exclusively. +type Addr struct { + Name string // fully-qualified domain name + IP net.IP + Port int +} + +func (a *Addr) Network() string { return "socks" } + +func (a *Addr) String() string { + if a == nil { + return "" + } + port := strconv.Itoa(a.Port) + if a.IP == nil { + return net.JoinHostPort(a.Name, port) + } + return net.JoinHostPort(a.IP.String(), port) +} + +// A Conn represents a forward proxy connection. +type Conn struct { + net.Conn + + boundAddr net.Addr +} + +// BoundAddr returns the address assigned by the proxy server for +// connecting to the command target address from the proxy server. +func (c *Conn) BoundAddr() net.Addr { + if c == nil { + return nil + } + return c.boundAddr +} + +// A Dialer holds SOCKS-specific options. +type Dialer struct { + cmd Command // either CmdConnect or cmdBind + proxyNetwork string // network between a proxy server and a client + proxyAddress string // proxy server address + + // ProxyDial specifies the optional dial function for + // establishing the transport connection. + ProxyDial func(context.Context, string, string) (net.Conn, error) + + // AuthMethods specifies the list of request authentication + // methods. + // If empty, SOCKS client requests only AuthMethodNotRequired. + AuthMethods []AuthMethod + + // Authenticate specifies the optional authentication + // function. It must be non-nil when AuthMethods is not empty. + // It must return an error when the authentication is failed. + Authenticate func(context.Context, io.ReadWriter, AuthMethod) error +} + +// DialContext connects to the provided address on the provided +// network. +// +// The returned error value may be a net.OpError. When the Op field of +// net.OpError contains "socks", the Source field contains a proxy +// server address and the Addr field contains a command target +// address. +// +// See func Dial of the net package of standard library for a +// description of the network and address parameters. +func (d *Dialer) DialContext(ctx context.Context, network, address string) (net.Conn, error) { + if err := d.validateTarget(network, address); err != nil { + proxy, dst, _ := d.pathAddrs(address) + return nil, &net.OpError{Op: d.cmd.String(), Net: network, Source: proxy, Addr: dst, Err: err} + } + if ctx == nil { + proxy, dst, _ := d.pathAddrs(address) + return nil, &net.OpError{Op: d.cmd.String(), Net: network, Source: proxy, Addr: dst, Err: errors.New("nil context")} + } + var err error + var c net.Conn + if d.ProxyDial != nil { + c, err = d.ProxyDial(ctx, d.proxyNetwork, d.proxyAddress) + } else { + var dd net.Dialer + c, err = dd.DialContext(ctx, d.proxyNetwork, d.proxyAddress) + } + if err != nil { + proxy, dst, _ := d.pathAddrs(address) + return nil, &net.OpError{Op: d.cmd.String(), Net: network, Source: proxy, Addr: dst, Err: err} + } + a, err := d.connect(ctx, c, address) + if err != nil { + c.Close() + proxy, dst, _ := d.pathAddrs(address) + return nil, &net.OpError{Op: d.cmd.String(), Net: network, Source: proxy, Addr: dst, Err: err} + } + return &Conn{Conn: c, boundAddr: a}, nil +} + +// DialWithConn initiates a connection from SOCKS server to the target +// network and address using the connection c that is already +// connected to the SOCKS server. +// +// It returns the connection's local address assigned by the SOCKS +// server. +func (d *Dialer) DialWithConn(ctx context.Context, c net.Conn, network, address string) (net.Addr, error) { + if err := d.validateTarget(network, address); err != nil { + proxy, dst, _ := d.pathAddrs(address) + return nil, &net.OpError{Op: d.cmd.String(), Net: network, Source: proxy, Addr: dst, Err: err} + } + if ctx == nil { + proxy, dst, _ := d.pathAddrs(address) + return nil, &net.OpError{Op: d.cmd.String(), Net: network, Source: proxy, Addr: dst, Err: errors.New("nil context")} + } + a, err := d.connect(ctx, c, address) + if err != nil { + proxy, dst, _ := d.pathAddrs(address) + return nil, &net.OpError{Op: d.cmd.String(), Net: network, Source: proxy, Addr: dst, Err: err} + } + return a, nil +} + +// Dial connects to the provided address on the provided network. +// +// Unlike DialContext, it returns a raw transport connection instead +// of a forward proxy connection. +// +// Deprecated: Use DialContext or DialWithConn instead. +func (d *Dialer) Dial(network, address string) (net.Conn, error) { + if err := d.validateTarget(network, address); err != nil { + proxy, dst, _ := d.pathAddrs(address) + return nil, &net.OpError{Op: d.cmd.String(), Net: network, Source: proxy, Addr: dst, Err: err} + } + var err error + var c net.Conn + if d.ProxyDial != nil { + c, err = d.ProxyDial(context.Background(), d.proxyNetwork, d.proxyAddress) + } else { + c, err = net.Dial(d.proxyNetwork, d.proxyAddress) + } + if err != nil { + proxy, dst, _ := d.pathAddrs(address) + return nil, &net.OpError{Op: d.cmd.String(), Net: network, Source: proxy, Addr: dst, Err: err} + } + if _, err := d.DialWithConn(context.Background(), c, network, address); err != nil { + c.Close() + return nil, err + } + return c, nil +} + +func (d *Dialer) validateTarget(network, address string) error { + switch network { + case "tcp", "tcp6", "tcp4": + default: + return errors.New("network not implemented") + } + switch d.cmd { + case CmdConnect, cmdBind: + default: + return errors.New("command not implemented") + } + return nil +} + +func (d *Dialer) pathAddrs(address string) (proxy, dst net.Addr, err error) { + for i, s := range []string{d.proxyAddress, address} { + host, port, err := splitHostPort(s) + if err != nil { + return nil, nil, err + } + a := &Addr{Port: port} + a.IP = net.ParseIP(host) + if a.IP == nil { + a.Name = host + } + if i == 0 { + proxy = a + } else { + dst = a + } + } + return +} + +// NewDialer returns a new Dialer that dials through the provided +// proxy server's network and address. +func NewDialer(network, address string) *Dialer { + return &Dialer{proxyNetwork: network, proxyAddress: address, cmd: CmdConnect} +} + +const ( + authUsernamePasswordVersion = 0x01 + authStatusSucceeded = 0x00 +) + +// UsernamePassword are the credentials for the username/password +// authentication method. +type UsernamePassword struct { + Username string + Password string +} + +// Authenticate authenticates a pair of username and password with the +// proxy server. +func (up *UsernamePassword) Authenticate(ctx context.Context, rw io.ReadWriter, auth AuthMethod) error { + switch auth { + case AuthMethodNotRequired: + return nil + case AuthMethodUsernamePassword: + if len(up.Username) == 0 || len(up.Username) > 255 || len(up.Password) > 255 { + return errors.New("invalid username/password") + } + b := []byte{authUsernamePasswordVersion} + b = append(b, byte(len(up.Username))) + b = append(b, up.Username...) + b = append(b, byte(len(up.Password))) + b = append(b, up.Password...) + // TODO(mikio): handle IO deadlines and cancelation if + // necessary + if _, err := rw.Write(b); err != nil { + return err + } + if _, err := io.ReadFull(rw, b[:2]); err != nil { + return err + } + if b[0] != authUsernamePasswordVersion { + return errors.New("invalid username/password version") + } + if b[1] != authStatusSucceeded { + return errors.New("username/password authentication failed") + } + return nil + } + return errors.New("unsupported authentication method " + strconv.Itoa(int(auth))) +} diff --git a/mcp/dsx-exchange-mcp/vendor/golang.org/x/net/proxy/dial.go b/mcp/dsx-exchange-mcp/vendor/golang.org/x/net/proxy/dial.go new file mode 100644 index 0000000..811c2e4 --- /dev/null +++ b/mcp/dsx-exchange-mcp/vendor/golang.org/x/net/proxy/dial.go @@ -0,0 +1,54 @@ +// Copyright 2019 The Go Authors. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +package proxy + +import ( + "context" + "net" +) + +// A ContextDialer dials using a context. +type ContextDialer interface { + DialContext(ctx context.Context, network, address string) (net.Conn, error) +} + +// Dial works like DialContext on net.Dialer but using a dialer returned by FromEnvironment. +// +// The passed ctx is only used for returning the Conn, not the lifetime of the Conn. +// +// Custom dialers (registered via RegisterDialerType) that do not implement ContextDialer +// can leak a goroutine for as long as it takes the underlying Dialer implementation to timeout. +// +// A Conn returned from a successful Dial after the context has been cancelled will be immediately closed. +func Dial(ctx context.Context, network, address string) (net.Conn, error) { + d := FromEnvironment() + if xd, ok := d.(ContextDialer); ok { + return xd.DialContext(ctx, network, address) + } + return dialContext(ctx, d, network, address) +} + +// WARNING: this can leak a goroutine for as long as the underlying Dialer implementation takes to timeout +// A Conn returned from a successful Dial after the context has been cancelled will be immediately closed. +func dialContext(ctx context.Context, d Dialer, network, address string) (net.Conn, error) { + var ( + conn net.Conn + done = make(chan struct{}, 1) + err error + ) + go func() { + conn, err = d.Dial(network, address) + close(done) + if conn != nil && ctx.Err() != nil { + conn.Close() + } + }() + select { + case <-ctx.Done(): + err = ctx.Err() + case <-done: + } + return conn, err +} diff --git a/mcp/dsx-exchange-mcp/vendor/golang.org/x/net/proxy/direct.go b/mcp/dsx-exchange-mcp/vendor/golang.org/x/net/proxy/direct.go new file mode 100644 index 0000000..3d66bde --- /dev/null +++ b/mcp/dsx-exchange-mcp/vendor/golang.org/x/net/proxy/direct.go @@ -0,0 +1,31 @@ +// Copyright 2011 The Go Authors. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +package proxy + +import ( + "context" + "net" +) + +type direct struct{} + +// Direct implements Dialer by making network connections directly using net.Dial or net.DialContext. +var Direct = direct{} + +var ( + _ Dialer = Direct + _ ContextDialer = Direct +) + +// Dial directly invokes net.Dial with the supplied parameters. +func (direct) Dial(network, addr string) (net.Conn, error) { + return net.Dial(network, addr) +} + +// DialContext instantiates a net.Dialer and invokes its DialContext receiver with the supplied parameters. +func (direct) DialContext(ctx context.Context, network, addr string) (net.Conn, error) { + var d net.Dialer + return d.DialContext(ctx, network, addr) +} diff --git a/mcp/dsx-exchange-mcp/vendor/golang.org/x/net/proxy/per_host.go b/mcp/dsx-exchange-mcp/vendor/golang.org/x/net/proxy/per_host.go new file mode 100644 index 0000000..32bdf43 --- /dev/null +++ b/mcp/dsx-exchange-mcp/vendor/golang.org/x/net/proxy/per_host.go @@ -0,0 +1,153 @@ +// Copyright 2011 The Go Authors. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +package proxy + +import ( + "context" + "net" + "net/netip" + "strings" +) + +// A PerHost directs connections to a default Dialer unless the host name +// requested matches one of a number of exceptions. +type PerHost struct { + def, bypass Dialer + + bypassNetworks []*net.IPNet + bypassIPs []net.IP + bypassZones []string + bypassHosts []string +} + +// NewPerHost returns a PerHost Dialer that directs connections to either +// defaultDialer or bypass, depending on whether the connection matches one of +// the configured rules. +func NewPerHost(defaultDialer, bypass Dialer) *PerHost { + return &PerHost{ + def: defaultDialer, + bypass: bypass, + } +} + +// Dial connects to the address addr on the given network through either +// defaultDialer or bypass. +func (p *PerHost) Dial(network, addr string) (c net.Conn, err error) { + host, _, err := net.SplitHostPort(addr) + if err != nil { + return nil, err + } + + return p.dialerForRequest(host).Dial(network, addr) +} + +// DialContext connects to the address addr on the given network through either +// defaultDialer or bypass. +func (p *PerHost) DialContext(ctx context.Context, network, addr string) (c net.Conn, err error) { + host, _, err := net.SplitHostPort(addr) + if err != nil { + return nil, err + } + d := p.dialerForRequest(host) + if x, ok := d.(ContextDialer); ok { + return x.DialContext(ctx, network, addr) + } + return dialContext(ctx, d, network, addr) +} + +func (p *PerHost) dialerForRequest(host string) Dialer { + if nip, err := netip.ParseAddr(host); err == nil { + ip := net.IP(nip.AsSlice()) + for _, net := range p.bypassNetworks { + if net.Contains(ip) { + return p.bypass + } + } + for _, bypassIP := range p.bypassIPs { + if bypassIP.Equal(ip) { + return p.bypass + } + } + return p.def + } + + for _, zone := range p.bypassZones { + if strings.HasSuffix(host, zone) { + return p.bypass + } + if host == zone[1:] { + // For a zone ".example.com", we match "example.com" + // too. + return p.bypass + } + } + for _, bypassHost := range p.bypassHosts { + if bypassHost == host { + return p.bypass + } + } + return p.def +} + +// AddFromString parses a string that contains comma-separated values +// specifying hosts that should use the bypass proxy. Each value is either an +// IP address, a CIDR range, a zone (*.example.com) or a host name +// (localhost). A best effort is made to parse the string and errors are +// ignored. +func (p *PerHost) AddFromString(s string) { + hosts := strings.Split(s, ",") + for _, host := range hosts { + host = strings.TrimSpace(host) + if len(host) == 0 { + continue + } + if strings.Contains(host, "/") { + // We assume that it's a CIDR address like 127.0.0.0/8 + if _, net, err := net.ParseCIDR(host); err == nil { + p.AddNetwork(net) + } + continue + } + if nip, err := netip.ParseAddr(host); err == nil { + p.AddIP(net.IP(nip.AsSlice())) + continue + } + if strings.HasPrefix(host, "*.") { + p.AddZone(host[1:]) + continue + } + p.AddHost(host) + } +} + +// AddIP specifies an IP address that will use the bypass proxy. Note that +// this will only take effect if a literal IP address is dialed. A connection +// to a named host will never match an IP. +func (p *PerHost) AddIP(ip net.IP) { + p.bypassIPs = append(p.bypassIPs, ip) +} + +// AddNetwork specifies an IP range that will use the bypass proxy. Note that +// this will only take effect if a literal IP address is dialed. A connection +// to a named host will never match. +func (p *PerHost) AddNetwork(net *net.IPNet) { + p.bypassNetworks = append(p.bypassNetworks, net) +} + +// AddZone specifies a DNS suffix that will use the bypass proxy. A zone of +// "example.com" matches "example.com" and all of its subdomains. +func (p *PerHost) AddZone(zone string) { + zone = strings.TrimSuffix(zone, ".") + if !strings.HasPrefix(zone, ".") { + zone = "." + zone + } + p.bypassZones = append(p.bypassZones, zone) +} + +// AddHost specifies a host name that will use the bypass proxy. +func (p *PerHost) AddHost(host string) { + host = strings.TrimSuffix(host, ".") + p.bypassHosts = append(p.bypassHosts, host) +} diff --git a/mcp/dsx-exchange-mcp/vendor/golang.org/x/net/proxy/proxy.go b/mcp/dsx-exchange-mcp/vendor/golang.org/x/net/proxy/proxy.go new file mode 100644 index 0000000..9ff4b9a --- /dev/null +++ b/mcp/dsx-exchange-mcp/vendor/golang.org/x/net/proxy/proxy.go @@ -0,0 +1,149 @@ +// Copyright 2011 The Go Authors. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +// Package proxy provides support for a variety of protocols to proxy network +// data. +package proxy // import "golang.org/x/net/proxy" + +import ( + "errors" + "net" + "net/url" + "os" + "sync" +) + +// A Dialer is a means to establish a connection. +// Custom dialers should also implement ContextDialer. +type Dialer interface { + // Dial connects to the given address via the proxy. + Dial(network, addr string) (c net.Conn, err error) +} + +// Auth contains authentication parameters that specific Dialers may require. +type Auth struct { + User, Password string +} + +// FromEnvironment returns the dialer specified by the proxy-related +// variables in the environment and makes underlying connections +// directly. +func FromEnvironment() Dialer { + return FromEnvironmentUsing(Direct) +} + +// FromEnvironmentUsing returns the dialer specify by the proxy-related +// variables in the environment and makes underlying connections +// using the provided forwarding Dialer (for instance, a *net.Dialer +// with desired configuration). +func FromEnvironmentUsing(forward Dialer) Dialer { + allProxy := allProxyEnv.Get() + if len(allProxy) == 0 { + return forward + } + + proxyURL, err := url.Parse(allProxy) + if err != nil { + return forward + } + proxy, err := FromURL(proxyURL, forward) + if err != nil { + return forward + } + + noProxy := noProxyEnv.Get() + if len(noProxy) == 0 { + return proxy + } + + perHost := NewPerHost(proxy, forward) + perHost.AddFromString(noProxy) + return perHost +} + +// proxySchemes is a map from URL schemes to a function that creates a Dialer +// from a URL with such a scheme. +var proxySchemes map[string]func(*url.URL, Dialer) (Dialer, error) + +// RegisterDialerType takes a URL scheme and a function to generate Dialers from +// a URL with that scheme and a forwarding Dialer. Registered schemes are used +// by FromURL. +func RegisterDialerType(scheme string, f func(*url.URL, Dialer) (Dialer, error)) { + if proxySchemes == nil { + proxySchemes = make(map[string]func(*url.URL, Dialer) (Dialer, error)) + } + proxySchemes[scheme] = f +} + +// FromURL returns a Dialer given a URL specification and an underlying +// Dialer for it to make network requests. +func FromURL(u *url.URL, forward Dialer) (Dialer, error) { + var auth *Auth + if u.User != nil { + auth = new(Auth) + auth.User = u.User.Username() + if p, ok := u.User.Password(); ok { + auth.Password = p + } + } + + switch u.Scheme { + case "socks5", "socks5h": + addr := u.Hostname() + port := u.Port() + if port == "" { + port = "1080" + } + return SOCKS5("tcp", net.JoinHostPort(addr, port), auth, forward) + } + + // If the scheme doesn't match any of the built-in schemes, see if it + // was registered by another package. + if proxySchemes != nil { + if f, ok := proxySchemes[u.Scheme]; ok { + return f(u, forward) + } + } + + return nil, errors.New("proxy: unknown scheme: " + u.Scheme) +} + +var ( + allProxyEnv = &envOnce{ + names: []string{"ALL_PROXY", "all_proxy"}, + } + noProxyEnv = &envOnce{ + names: []string{"NO_PROXY", "no_proxy"}, + } +) + +// envOnce looks up an environment variable (optionally by multiple +// names) once. It mitigates expensive lookups on some platforms +// (e.g. Windows). +// (Borrowed from net/http/transport.go) +type envOnce struct { + names []string + once sync.Once + val string +} + +func (e *envOnce) Get() string { + e.once.Do(e.init) + return e.val +} + +func (e *envOnce) init() { + for _, n := range e.names { + e.val = os.Getenv(n) + if e.val != "" { + return + } + } +} + +// reset is used by tests +func (e *envOnce) reset() { + e.once = sync.Once{} + e.val = "" +} diff --git a/mcp/dsx-exchange-mcp/vendor/golang.org/x/net/proxy/socks5.go b/mcp/dsx-exchange-mcp/vendor/golang.org/x/net/proxy/socks5.go new file mode 100644 index 0000000..c91651f --- /dev/null +++ b/mcp/dsx-exchange-mcp/vendor/golang.org/x/net/proxy/socks5.go @@ -0,0 +1,42 @@ +// Copyright 2011 The Go Authors. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +package proxy + +import ( + "context" + "net" + + "golang.org/x/net/internal/socks" +) + +// SOCKS5 returns a Dialer that makes SOCKSv5 connections to the given +// address with an optional username and password. +// See RFC 1928 and RFC 1929. +func SOCKS5(network, address string, auth *Auth, forward Dialer) (Dialer, error) { + d := socks.NewDialer(network, address) + if forward != nil { + if f, ok := forward.(ContextDialer); ok { + d.ProxyDial = func(ctx context.Context, network string, address string) (net.Conn, error) { + return f.DialContext(ctx, network, address) + } + } else { + d.ProxyDial = func(ctx context.Context, network string, address string) (net.Conn, error) { + return dialContext(ctx, forward, network, address) + } + } + } + if auth != nil { + up := socks.UsernamePassword{ + Username: auth.User, + Password: auth.Password, + } + d.AuthMethods = []socks.AuthMethod{ + socks.AuthMethodNotRequired, + socks.AuthMethodUsernamePassword, + } + d.Authenticate = up.Authenticate + } + return d, nil +} diff --git a/mcp/dsx-exchange-mcp/vendor/golang.org/x/oauth2/.travis.yml b/mcp/dsx-exchange-mcp/vendor/golang.org/x/oauth2/.travis.yml new file mode 100644 index 0000000..fa139db --- /dev/null +++ b/mcp/dsx-exchange-mcp/vendor/golang.org/x/oauth2/.travis.yml @@ -0,0 +1,13 @@ +language: go + +go: + - tip + +install: + - export GOPATH="$HOME/gopath" + - mkdir -p "$GOPATH/src/golang.org/x" + - mv "$TRAVIS_BUILD_DIR" "$GOPATH/src/golang.org/x/oauth2" + - go get -v -t -d golang.org/x/oauth2/... + +script: + - go test -v golang.org/x/oauth2/... diff --git a/mcp/dsx-exchange-mcp/vendor/golang.org/x/oauth2/CONTRIBUTING.md b/mcp/dsx-exchange-mcp/vendor/golang.org/x/oauth2/CONTRIBUTING.md new file mode 100644 index 0000000..dfbed62 --- /dev/null +++ b/mcp/dsx-exchange-mcp/vendor/golang.org/x/oauth2/CONTRIBUTING.md @@ -0,0 +1,26 @@ +# Contributing to Go + +Go is an open source project. + +It is the work of hundreds of contributors. We appreciate your help! + +## Filing issues + +When [filing an issue](https://github.com/golang/oauth2/issues), make sure to answer these five questions: + +1. What version of Go are you using (`go version`)? +2. What operating system and processor architecture are you using? +3. What did you do? +4. What did you expect to see? +5. What did you see instead? + +General questions should go to the [golang-nuts mailing list](https://groups.google.com/group/golang-nuts) instead of the issue tracker. +The gophers there will answer or ask you to file an issue if you've tripped over a bug. + +## Contributing code + +Please read the [Contribution Guidelines](https://golang.org/doc/contribute.html) +before sending patches. + +Unless otherwise noted, the Go source files are distributed under +the BSD-style license found in the LICENSE file. diff --git a/mcp/dsx-exchange-mcp/vendor/golang.org/x/oauth2/LICENSE b/mcp/dsx-exchange-mcp/vendor/golang.org/x/oauth2/LICENSE new file mode 100644 index 0000000..2a7cf70 --- /dev/null +++ b/mcp/dsx-exchange-mcp/vendor/golang.org/x/oauth2/LICENSE @@ -0,0 +1,27 @@ +Copyright 2009 The Go Authors. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are +met: + + * Redistributions of source code must retain the above copyright +notice, this list of conditions and the following disclaimer. + * Redistributions in binary form must reproduce the above +copyright notice, this list of conditions and the following disclaimer +in the documentation and/or other materials provided with the +distribution. + * Neither the name of Google LLC nor the names of its +contributors may be used to endorse or promote products derived from +this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT +OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT +LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, +DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY +THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/mcp/dsx-exchange-mcp/vendor/golang.org/x/oauth2/README.md b/mcp/dsx-exchange-mcp/vendor/golang.org/x/oauth2/README.md new file mode 100644 index 0000000..48dbb9d --- /dev/null +++ b/mcp/dsx-exchange-mcp/vendor/golang.org/x/oauth2/README.md @@ -0,0 +1,35 @@ +# OAuth2 for Go + +[![Go Reference](https://pkg.go.dev/badge/golang.org/x/oauth2.svg)](https://pkg.go.dev/golang.org/x/oauth2) +[![Build Status](https://travis-ci.org/golang/oauth2.svg?branch=master)](https://travis-ci.org/golang/oauth2) + +oauth2 package contains a client implementation for OAuth 2.0 spec. + +See pkg.go.dev for further documentation and examples. + +* [pkg.go.dev/golang.org/x/oauth2](https://pkg.go.dev/golang.org/x/oauth2) +* [pkg.go.dev/golang.org/x/oauth2/google](https://pkg.go.dev/golang.org/x/oauth2/google) + +## Policy for new endpoints + +We no longer accept new provider-specific packages in this repo if all +they do is add a single endpoint variable. If you just want to add a +single endpoint, add it to the +[pkg.go.dev/golang.org/x/oauth2/endpoints](https://pkg.go.dev/golang.org/x/oauth2/endpoints) +package. + +## Report Issues / Send Patches + +The main issue tracker for the oauth2 repository is located at +https://github.com/golang/oauth2/issues. + +This repository uses Gerrit for code changes. To learn how to submit changes to +this repository, see https://go.dev/doc/contribute. + +The git repository is https://go.googlesource.com/oauth2. + +Note: + +* Excluding trivial changes, all contributions should be connected to an existing issue. +* API changes must go through the [change proposal process](https://go.dev/s/proposal-process) before they can be accepted. +* The code owners are listed at [dev.golang.org/owners](https://dev.golang.org/owners#:~:text=x/oauth2). diff --git a/mcp/dsx-exchange-mcp/vendor/golang.org/x/oauth2/deviceauth.go b/mcp/dsx-exchange-mcp/vendor/golang.org/x/oauth2/deviceauth.go new file mode 100644 index 0000000..e783a94 --- /dev/null +++ b/mcp/dsx-exchange-mcp/vendor/golang.org/x/oauth2/deviceauth.go @@ -0,0 +1,227 @@ +package oauth2 + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "io" + "mime" + "net/http" + "net/url" + "strings" + "time" + + "golang.org/x/oauth2/internal" +) + +// https://datatracker.ietf.org/doc/html/rfc8628#section-3.5 +const ( + errAuthorizationPending = "authorization_pending" + errSlowDown = "slow_down" + errAccessDenied = "access_denied" + errExpiredToken = "expired_token" +) + +// DeviceAuthResponse describes a successful RFC 8628 Device Authorization Response +// https://datatracker.ietf.org/doc/html/rfc8628#section-3.2 +type DeviceAuthResponse struct { + // DeviceCode + DeviceCode string `json:"device_code"` + // UserCode is the code the user should enter at the verification uri + UserCode string `json:"user_code"` + // VerificationURI is where user should enter the user code + VerificationURI string `json:"verification_uri"` + // VerificationURIComplete (if populated) includes the user code in the verification URI. This is typically shown to the user in non-textual form, such as a QR code. + VerificationURIComplete string `json:"verification_uri_complete,omitempty"` + // Expiry is when the device code and user code expire + Expiry time.Time `json:"expires_in,omitempty"` + // Interval is the duration in seconds that Poll should wait between requests + Interval int64 `json:"interval,omitempty"` +} + +func (d DeviceAuthResponse) MarshalJSON() ([]byte, error) { + type Alias DeviceAuthResponse + var expiresIn int64 + if !d.Expiry.IsZero() { + expiresIn = int64(time.Until(d.Expiry).Seconds()) + } + return json.Marshal(&struct { + ExpiresIn int64 `json:"expires_in,omitempty"` + *Alias + }{ + ExpiresIn: expiresIn, + Alias: (*Alias)(&d), + }) + +} + +func (c *DeviceAuthResponse) UnmarshalJSON(data []byte) error { + type Alias DeviceAuthResponse + aux := &struct { + ExpiresIn int64 `json:"expires_in"` + // workaround misspelling of verification_uri + VerificationURL string `json:"verification_url"` + *Alias + }{ + Alias: (*Alias)(c), + } + if err := json.Unmarshal(data, &aux); err != nil { + return err + } + if aux.ExpiresIn != 0 { + c.Expiry = time.Now().UTC().Add(time.Second * time.Duration(aux.ExpiresIn)) + } + if c.VerificationURI == "" { + c.VerificationURI = aux.VerificationURL + } + return nil +} + +// DeviceAuth returns a device auth struct which contains a device code +// and authorization information provided for users to enter on another device. +func (c *Config) DeviceAuth(ctx context.Context, opts ...AuthCodeOption) (*DeviceAuthResponse, error) { + // https://datatracker.ietf.org/doc/html/rfc8628#section-3.1 + v := url.Values{ + "client_id": {c.ClientID}, + } + if len(c.Scopes) > 0 { + v.Set("scope", strings.Join(c.Scopes, " ")) + } + for _, opt := range opts { + opt.setValue(v) + } + return retrieveDeviceAuth(ctx, c, v) +} + +func retrieveDeviceAuth(ctx context.Context, c *Config, v url.Values) (*DeviceAuthResponse, error) { + if c.Endpoint.DeviceAuthURL == "" { + return nil, errors.New("endpoint missing DeviceAuthURL") + } + + req, err := http.NewRequest("POST", c.Endpoint.DeviceAuthURL, strings.NewReader(v.Encode())) + if err != nil { + return nil, err + } + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + req.Header.Set("Accept", "application/json") + + t := time.Now() + r, err := internal.ContextClient(ctx).Do(req) + if err != nil { + return nil, err + } + + body, err := io.ReadAll(io.LimitReader(r.Body, 1<<20)) + if err != nil { + return nil, fmt.Errorf("oauth2: cannot auth device: %v", err) + } + if code := r.StatusCode; code < 200 || code > 299 { + retrieveError := &RetrieveError{ + Response: r, + Body: body, + } + + content, _, _ := mime.ParseMediaType(r.Header.Get("Content-Type")) + switch content { + case "application/x-www-form-urlencoded", "text/plain": + // some endpoints return a query string + vals, err := url.ParseQuery(string(body)) + if err != nil { + return nil, retrieveError + } + retrieveError.ErrorCode = vals.Get("error") + retrieveError.ErrorDescription = vals.Get("error_description") + retrieveError.ErrorURI = vals.Get("error_uri") + default: + var tj struct { + // https://datatracker.ietf.org/doc/html/rfc6749#section-5.2 + ErrorCode string `json:"error"` + ErrorDescription string `json:"error_description"` + ErrorURI string `json:"error_uri"` + } + if json.Unmarshal(body, &tj) != nil { + return nil, retrieveError + } + retrieveError.ErrorCode = tj.ErrorCode + retrieveError.ErrorDescription = tj.ErrorDescription + retrieveError.ErrorURI = tj.ErrorURI + } + + return nil, retrieveError + } + + da := &DeviceAuthResponse{} + err = json.Unmarshal(body, &da) + if err != nil { + return nil, fmt.Errorf("unmarshal %s", err) + } + + if !da.Expiry.IsZero() { + // Make a small adjustment to account for time taken by the request + da.Expiry = da.Expiry.Add(-time.Since(t)) + } + + return da, nil +} + +// DeviceAccessToken polls the server to exchange a device code for a token. +func (c *Config) DeviceAccessToken(ctx context.Context, da *DeviceAuthResponse, opts ...AuthCodeOption) (*Token, error) { + if !da.Expiry.IsZero() { + var cancel context.CancelFunc + ctx, cancel = context.WithDeadline(ctx, da.Expiry) + defer cancel() + } + + // https://datatracker.ietf.org/doc/html/rfc8628#section-3.4 + v := url.Values{ + "client_id": {c.ClientID}, + "grant_type": {"urn:ietf:params:oauth:grant-type:device_code"}, + "device_code": {da.DeviceCode}, + } + if len(c.Scopes) > 0 { + v.Set("scope", strings.Join(c.Scopes, " ")) + } + for _, opt := range opts { + opt.setValue(v) + } + + // "If no value is provided, clients MUST use 5 as the default." + // https://datatracker.ietf.org/doc/html/rfc8628#section-3.2 + interval := da.Interval + if interval == 0 { + interval = 5 + } + + ticker := time.NewTicker(time.Duration(interval) * time.Second) + defer ticker.Stop() + for { + select { + case <-ctx.Done(): + return nil, ctx.Err() + case <-ticker.C: + tok, err := retrieveToken(ctx, c, v) + if err == nil { + return tok, nil + } + + e, ok := err.(*RetrieveError) + if !ok { + return nil, err + } + switch e.ErrorCode { + case errSlowDown: + // https://datatracker.ietf.org/doc/html/rfc8628#section-3.5 + // "the interval MUST be increased by 5 seconds for this and all subsequent requests" + interval += 5 + ticker.Reset(time.Duration(interval) * time.Second) + case errAuthorizationPending: + // Do nothing. + case errAccessDenied, errExpiredToken: + fallthrough + default: + return tok, err + } + } + } +} diff --git a/mcp/dsx-exchange-mcp/vendor/golang.org/x/oauth2/internal/doc.go b/mcp/dsx-exchange-mcp/vendor/golang.org/x/oauth2/internal/doc.go new file mode 100644 index 0000000..8c7c475 --- /dev/null +++ b/mcp/dsx-exchange-mcp/vendor/golang.org/x/oauth2/internal/doc.go @@ -0,0 +1,6 @@ +// Copyright 2017 The Go Authors. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +// Package internal contains support packages for [golang.org/x/oauth2]. +package internal diff --git a/mcp/dsx-exchange-mcp/vendor/golang.org/x/oauth2/internal/oauth2.go b/mcp/dsx-exchange-mcp/vendor/golang.org/x/oauth2/internal/oauth2.go new file mode 100644 index 0000000..71ea6ad --- /dev/null +++ b/mcp/dsx-exchange-mcp/vendor/golang.org/x/oauth2/internal/oauth2.go @@ -0,0 +1,37 @@ +// Copyright 2014 The Go Authors. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +package internal + +import ( + "crypto/rsa" + "crypto/x509" + "encoding/pem" + "errors" + "fmt" +) + +// ParseKey converts the binary contents of a private key file +// to an [*rsa.PrivateKey]. It detects whether the private key is in a +// PEM container or not. If so, it extracts the private key +// from PEM container before conversion. It only supports PEM +// containers with no passphrase. +func ParseKey(key []byte) (*rsa.PrivateKey, error) { + block, _ := pem.Decode(key) + if block != nil { + key = block.Bytes + } + parsedKey, err := x509.ParsePKCS8PrivateKey(key) + if err != nil { + parsedKey, err = x509.ParsePKCS1PrivateKey(key) + if err != nil { + return nil, fmt.Errorf("private key should be a PEM or plain PKCS1 or PKCS8; parse error: %v", err) + } + } + parsed, ok := parsedKey.(*rsa.PrivateKey) + if !ok { + return nil, errors.New("private key is invalid") + } + return parsed, nil +} diff --git a/mcp/dsx-exchange-mcp/vendor/golang.org/x/oauth2/internal/token.go b/mcp/dsx-exchange-mcp/vendor/golang.org/x/oauth2/internal/token.go new file mode 100644 index 0000000..8389f24 --- /dev/null +++ b/mcp/dsx-exchange-mcp/vendor/golang.org/x/oauth2/internal/token.go @@ -0,0 +1,356 @@ +// Copyright 2014 The Go Authors. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +package internal + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "io" + "math" + "mime" + "net/http" + "net/url" + "strconv" + "strings" + "sync" + "sync/atomic" + "time" +) + +// Token represents the credentials used to authorize +// the requests to access protected resources on the OAuth 2.0 +// provider's backend. +// +// This type is a mirror of [golang.org/x/oauth2.Token] and exists to break +// an otherwise-circular dependency. Other internal packages +// should convert this Token into an [golang.org/x/oauth2.Token] before use. +type Token struct { + // AccessToken is the token that authorizes and authenticates + // the requests. + AccessToken string + + // TokenType is the type of token. + // The Type method returns either this or "Bearer", the default. + TokenType string + + // RefreshToken is a token that's used by the application + // (as opposed to the user) to refresh the access token + // if it expires. + RefreshToken string + + // Expiry is the optional expiration time of the access token. + // + // If zero, TokenSource implementations will reuse the same + // token forever and RefreshToken or equivalent + // mechanisms for that TokenSource will not be used. + Expiry time.Time + + // ExpiresIn is the OAuth2 wire format "expires_in" field, + // which specifies how many seconds later the token expires, + // relative to an unknown time base approximately around "now". + // It is the application's responsibility to populate + // `Expiry` from `ExpiresIn` when required. + ExpiresIn int64 `json:"expires_in,omitempty"` + + // Raw optionally contains extra metadata from the server + // when updating a token. + Raw any +} + +// tokenJSON is the struct representing the HTTP response from OAuth2 +// providers returning a token or error in JSON form. +// https://datatracker.ietf.org/doc/html/rfc6749#section-5.1 +type tokenJSON struct { + AccessToken string `json:"access_token"` + TokenType string `json:"token_type"` + RefreshToken string `json:"refresh_token"` + ExpiresIn expirationTime `json:"expires_in"` // at least PayPal returns string, while most return number + // error fields + // https://datatracker.ietf.org/doc/html/rfc6749#section-5.2 + ErrorCode string `json:"error"` + ErrorDescription string `json:"error_description"` + ErrorURI string `json:"error_uri"` +} + +func (e *tokenJSON) expiry() (t time.Time) { + if v := e.ExpiresIn; v != 0 { + return time.Now().Add(time.Duration(v) * time.Second) + } + return +} + +type expirationTime int32 + +func (e *expirationTime) UnmarshalJSON(b []byte) error { + if len(b) == 0 || string(b) == "null" { + return nil + } + var n json.Number + err := json.Unmarshal(b, &n) + if err != nil { + return err + } + i, err := n.Int64() + if err != nil { + return err + } + if i > math.MaxInt32 { + i = math.MaxInt32 + } + *e = expirationTime(i) + return nil +} + +// AuthStyle is a copy of the golang.org/x/oauth2 package's AuthStyle type. +type AuthStyle int + +const ( + AuthStyleUnknown AuthStyle = 0 + AuthStyleInParams AuthStyle = 1 + AuthStyleInHeader AuthStyle = 2 +) + +// LazyAuthStyleCache is a backwards compatibility compromise to let Configs +// have a lazily-initialized AuthStyleCache. +// +// The two users of this, oauth2.Config and oauth2/clientcredentials.Config, +// both would ideally just embed an unexported AuthStyleCache but because both +// were historically allowed to be copied by value we can't retroactively add an +// uncopyable Mutex to them. +// +// We could use an atomic.Pointer, but that was added recently enough (in Go +// 1.18) that we'd break Go 1.17 users where the tests as of 2023-08-03 +// still pass. By using an atomic.Value, it supports both Go 1.17 and +// copying by value, even if that's not ideal. +type LazyAuthStyleCache struct { + v atomic.Value // of *AuthStyleCache +} + +func (lc *LazyAuthStyleCache) Get() *AuthStyleCache { + if c, ok := lc.v.Load().(*AuthStyleCache); ok { + return c + } + c := new(AuthStyleCache) + if !lc.v.CompareAndSwap(nil, c) { + c = lc.v.Load().(*AuthStyleCache) + } + return c +} + +type authStyleCacheKey struct { + url string + clientID string +} + +// AuthStyleCache is the set of tokenURLs we've successfully used via +// RetrieveToken and which style auth we ended up using. +// It's called a cache, but it doesn't (yet?) shrink. It's expected that +// the set of OAuth2 servers a program contacts over time is fixed and +// small. +type AuthStyleCache struct { + mu sync.Mutex + m map[authStyleCacheKey]AuthStyle +} + +// lookupAuthStyle reports which auth style we last used with tokenURL +// when calling RetrieveToken and whether we have ever done so. +func (c *AuthStyleCache) lookupAuthStyle(tokenURL, clientID string) (style AuthStyle, ok bool) { + c.mu.Lock() + defer c.mu.Unlock() + style, ok = c.m[authStyleCacheKey{tokenURL, clientID}] + return +} + +// setAuthStyle adds an entry to authStyleCache, documented above. +func (c *AuthStyleCache) setAuthStyle(tokenURL, clientID string, v AuthStyle) { + c.mu.Lock() + defer c.mu.Unlock() + if c.m == nil { + c.m = make(map[authStyleCacheKey]AuthStyle) + } + c.m[authStyleCacheKey{tokenURL, clientID}] = v +} + +// newTokenRequest returns a new *http.Request to retrieve a new token +// from tokenURL using the provided clientID, clientSecret, and POST +// body parameters. +// +// inParams is whether the clientID & clientSecret should be encoded +// as the POST body. An 'inParams' value of true means to send it in +// the POST body (along with any values in v); false means to send it +// in the Authorization header. +func newTokenRequest(tokenURL, clientID, clientSecret string, v url.Values, authStyle AuthStyle) (*http.Request, error) { + if authStyle == AuthStyleInParams { + v = cloneURLValues(v) + if clientID != "" { + v.Set("client_id", clientID) + } + if clientSecret != "" { + v.Set("client_secret", clientSecret) + } + } + req, err := http.NewRequest("POST", tokenURL, strings.NewReader(v.Encode())) + if err != nil { + return nil, err + } + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + if authStyle == AuthStyleInHeader { + req.SetBasicAuth(url.QueryEscape(clientID), url.QueryEscape(clientSecret)) + } + return req, nil +} + +func cloneURLValues(v url.Values) url.Values { + v2 := make(url.Values, len(v)) + for k, vv := range v { + v2[k] = append([]string(nil), vv...) + } + return v2 +} + +func RetrieveToken(ctx context.Context, clientID, clientSecret, tokenURL string, v url.Values, authStyle AuthStyle, styleCache *AuthStyleCache) (*Token, error) { + needsAuthStyleProbe := authStyle == AuthStyleUnknown + if needsAuthStyleProbe { + if style, ok := styleCache.lookupAuthStyle(tokenURL, clientID); ok { + authStyle = style + needsAuthStyleProbe = false + } else { + authStyle = AuthStyleInHeader // the first way we'll try + } + } + req, err := newTokenRequest(tokenURL, clientID, clientSecret, v, authStyle) + if err != nil { + return nil, err + } + token, err := doTokenRoundTrip(ctx, req) + if err != nil && needsAuthStyleProbe { + // If we get an error, assume the server wants the + // clientID & clientSecret in a different form. + // See https://code.google.com/p/goauth2/issues/detail?id=31 for background. + // In summary: + // - Reddit only accepts client secret in the Authorization header + // - Dropbox accepts either it in URL param or Auth header, but not both. + // - Google only accepts URL param (not spec compliant?), not Auth header + // - Stripe only accepts client secret in Auth header with Bearer method, not Basic + // + // We used to maintain a big table in this code of all the sites and which way + // they went, but maintaining it didn't scale & got annoying. + // So just try both ways. + authStyle = AuthStyleInParams // the second way we'll try + req, _ = newTokenRequest(tokenURL, clientID, clientSecret, v, authStyle) + token, err = doTokenRoundTrip(ctx, req) + } + if needsAuthStyleProbe && err == nil { + styleCache.setAuthStyle(tokenURL, clientID, authStyle) + } + // Don't overwrite `RefreshToken` with an empty value + // if this was a token refreshing request. + if token != nil && token.RefreshToken == "" { + token.RefreshToken = v.Get("refresh_token") + } + return token, err +} + +func doTokenRoundTrip(ctx context.Context, req *http.Request) (*Token, error) { + r, err := ContextClient(ctx).Do(req.WithContext(ctx)) + if err != nil { + return nil, err + } + body, err := io.ReadAll(io.LimitReader(r.Body, 1<<20)) + r.Body.Close() + if err != nil { + return nil, fmt.Errorf("oauth2: cannot fetch token: %v", err) + } + + failureStatus := r.StatusCode < 200 || r.StatusCode > 299 + retrieveError := &RetrieveError{ + Response: r, + Body: body, + // attempt to populate error detail below + } + + var token *Token + content, _, _ := mime.ParseMediaType(r.Header.Get("Content-Type")) + switch content { + case "application/x-www-form-urlencoded", "text/plain": + // some endpoints return a query string + vals, err := url.ParseQuery(string(body)) + if err != nil { + if failureStatus { + return nil, retrieveError + } + return nil, fmt.Errorf("oauth2: cannot parse response: %v", err) + } + retrieveError.ErrorCode = vals.Get("error") + retrieveError.ErrorDescription = vals.Get("error_description") + retrieveError.ErrorURI = vals.Get("error_uri") + token = &Token{ + AccessToken: vals.Get("access_token"), + TokenType: vals.Get("token_type"), + RefreshToken: vals.Get("refresh_token"), + Raw: vals, + } + e := vals.Get("expires_in") + expires, _ := strconv.Atoi(e) + if expires != 0 { + token.Expiry = time.Now().Add(time.Duration(expires) * time.Second) + } + default: + var tj tokenJSON + if err = json.Unmarshal(body, &tj); err != nil { + if failureStatus { + return nil, retrieveError + } + return nil, fmt.Errorf("oauth2: cannot parse json: %v", err) + } + retrieveError.ErrorCode = tj.ErrorCode + retrieveError.ErrorDescription = tj.ErrorDescription + retrieveError.ErrorURI = tj.ErrorURI + token = &Token{ + AccessToken: tj.AccessToken, + TokenType: tj.TokenType, + RefreshToken: tj.RefreshToken, + Expiry: tj.expiry(), + ExpiresIn: int64(tj.ExpiresIn), + Raw: make(map[string]any), + } + json.Unmarshal(body, &token.Raw) // no error checks for optional fields + } + // according to spec, servers should respond status 400 in error case + // https://www.rfc-editor.org/rfc/rfc6749#section-5.2 + // but some unorthodox servers respond 200 in error case + if failureStatus || retrieveError.ErrorCode != "" { + return nil, retrieveError + } + if token.AccessToken == "" { + return nil, errors.New("oauth2: server response missing access_token") + } + return token, nil +} + +// mirrors oauth2.RetrieveError +type RetrieveError struct { + Response *http.Response + Body []byte + ErrorCode string + ErrorDescription string + ErrorURI string +} + +func (r *RetrieveError) Error() string { + if r.ErrorCode != "" { + s := fmt.Sprintf("oauth2: %q", r.ErrorCode) + if r.ErrorDescription != "" { + s += fmt.Sprintf(" %q", r.ErrorDescription) + } + if r.ErrorURI != "" { + s += fmt.Sprintf(" %q", r.ErrorURI) + } + return s + } + return fmt.Sprintf("oauth2: cannot fetch token: %v\nResponse: %s", r.Response.Status, r.Body) +} diff --git a/mcp/dsx-exchange-mcp/vendor/golang.org/x/oauth2/internal/transport.go b/mcp/dsx-exchange-mcp/vendor/golang.org/x/oauth2/internal/transport.go new file mode 100644 index 0000000..afc0aeb --- /dev/null +++ b/mcp/dsx-exchange-mcp/vendor/golang.org/x/oauth2/internal/transport.go @@ -0,0 +1,28 @@ +// Copyright 2014 The Go Authors. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +package internal + +import ( + "context" + "net/http" +) + +// HTTPClient is the context key to use with [context.WithValue] +// to associate an [*http.Client] value with a context. +var HTTPClient ContextKey + +// ContextKey is just an empty struct. It exists so HTTPClient can be +// an immutable public variable with a unique type. It's immutable +// because nobody else can create a ContextKey, being unexported. +type ContextKey struct{} + +func ContextClient(ctx context.Context) *http.Client { + if ctx != nil { + if hc, ok := ctx.Value(HTTPClient).(*http.Client); ok { + return hc + } + } + return http.DefaultClient +} diff --git a/mcp/dsx-exchange-mcp/vendor/golang.org/x/oauth2/oauth2.go b/mcp/dsx-exchange-mcp/vendor/golang.org/x/oauth2/oauth2.go new file mode 100644 index 0000000..5c527d3 --- /dev/null +++ b/mcp/dsx-exchange-mcp/vendor/golang.org/x/oauth2/oauth2.go @@ -0,0 +1,423 @@ +// Copyright 2014 The Go Authors. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +// Package oauth2 provides support for making +// OAuth2 authorized and authenticated HTTP requests, +// as specified in RFC 6749. +// It can additionally grant authorization with Bearer JWT. +package oauth2 // import "golang.org/x/oauth2" + +import ( + "context" + "errors" + "net/http" + "net/url" + "strings" + "sync" + "time" + + "golang.org/x/oauth2/internal" +) + +// NoContext is the default context you should supply if not using +// your own [context.Context]. +// +// Deprecated: Use [context.Background] or [context.TODO] instead. +var NoContext = context.TODO() + +// RegisterBrokenAuthHeaderProvider previously did something. It is now a no-op. +// +// Deprecated: this function no longer does anything. Caller code that +// wants to avoid potential extra HTTP requests made during +// auto-probing of the provider's auth style should set +// Endpoint.AuthStyle. +func RegisterBrokenAuthHeaderProvider(tokenURL string) {} + +// Config describes a typical 3-legged OAuth2 flow, with both the +// client application information and the server's endpoint URLs. +// For the client credentials 2-legged OAuth2 flow, see the +// [golang.org/x/oauth2/clientcredentials] package. +type Config struct { + // ClientID is the application's ID. + ClientID string + + // ClientSecret is the application's secret. + ClientSecret string + + // Endpoint contains the authorization server's token endpoint + // URLs. These are constants specific to each server and are + // often available via site-specific packages, such as + // google.Endpoint or github.Endpoint. + Endpoint Endpoint + + // RedirectURL is the URL to redirect users going through + // the OAuth flow, after the resource owner's URLs. + RedirectURL string + + // Scopes specifies optional requested permissions. + Scopes []string + + // authStyleCache caches which auth style to use when Endpoint.AuthStyle is + // the zero value (AuthStyleAutoDetect). + authStyleCache internal.LazyAuthStyleCache +} + +// A TokenSource is anything that can return a token. +type TokenSource interface { + // Token returns a token or an error. + // Token must be safe for concurrent use by multiple goroutines. + // The returned Token must not be modified. + Token() (*Token, error) +} + +// Endpoint represents an OAuth 2.0 provider's authorization and token +// endpoint URLs. +type Endpoint struct { + AuthURL string + DeviceAuthURL string + TokenURL string + + // AuthStyle optionally specifies how the endpoint wants the + // client ID & client secret sent. The zero value means to + // auto-detect. + AuthStyle AuthStyle +} + +// AuthStyle represents how requests for tokens are authenticated +// to the server. +type AuthStyle int + +const ( + // AuthStyleAutoDetect means to auto-detect which authentication + // style the provider wants by trying both ways and caching + // the successful way for the future. + AuthStyleAutoDetect AuthStyle = 0 + + // AuthStyleInParams sends the "client_id" and "client_secret" + // in the POST body as application/x-www-form-urlencoded parameters. + AuthStyleInParams AuthStyle = 1 + + // AuthStyleInHeader sends the client_id and client_secret + // using HTTP Basic Authorization. This is an optional style + // described in the OAuth2 RFC 6749 section 2.3.1. + AuthStyleInHeader AuthStyle = 2 +) + +var ( + // AccessTypeOnline and AccessTypeOffline are options passed + // to the Options.AuthCodeURL method. They modify the + // "access_type" field that gets sent in the URL returned by + // AuthCodeURL. + // + // Online is the default if neither is specified. If your + // application needs to refresh access tokens when the user + // is not present at the browser, then use offline. This will + // result in your application obtaining a refresh token the + // first time your application exchanges an authorization + // code for a user. + AccessTypeOnline AuthCodeOption = SetAuthURLParam("access_type", "online") + AccessTypeOffline AuthCodeOption = SetAuthURLParam("access_type", "offline") + + // ApprovalForce forces the users to view the consent dialog + // and confirm the permissions request at the URL returned + // from AuthCodeURL, even if they've already done so. + ApprovalForce AuthCodeOption = SetAuthURLParam("prompt", "consent") +) + +// An AuthCodeOption is passed to Config.AuthCodeURL. +type AuthCodeOption interface { + setValue(url.Values) +} + +type setParam struct{ k, v string } + +func (p setParam) setValue(m url.Values) { m.Set(p.k, p.v) } + +// SetAuthURLParam builds an [AuthCodeOption] which passes key/value parameters +// to a provider's authorization endpoint. +func SetAuthURLParam(key, value string) AuthCodeOption { + return setParam{key, value} +} + +// AuthCodeURL returns a URL to OAuth 2.0 provider's consent page +// that asks for permissions for the required scopes explicitly. +// +// State is an opaque value used by the client to maintain state between the +// request and callback. The authorization server includes this value when +// redirecting the user agent back to the client. +// +// Opts may include [AccessTypeOnline] or [AccessTypeOffline], as well +// as [ApprovalForce]. +// +// To protect against CSRF attacks, opts should include a PKCE challenge +// (S256ChallengeOption). Not all servers support PKCE. An alternative is to +// generate a random state parameter and verify it after exchange. +// See https://datatracker.ietf.org/doc/html/rfc6749#section-10.12 (predating +// PKCE), https://www.oauth.com/oauth2-servers/pkce/ and +// https://www.ietf.org/archive/id/draft-ietf-oauth-v2-1-09.html#name-cross-site-request-forgery (describing both approaches) +func (c *Config) AuthCodeURL(state string, opts ...AuthCodeOption) string { + var buf strings.Builder + buf.WriteString(c.Endpoint.AuthURL) + v := url.Values{ + "response_type": {"code"}, + "client_id": {c.ClientID}, + } + if c.RedirectURL != "" { + v.Set("redirect_uri", c.RedirectURL) + } + if len(c.Scopes) > 0 { + v.Set("scope", strings.Join(c.Scopes, " ")) + } + if state != "" { + v.Set("state", state) + } + for _, opt := range opts { + opt.setValue(v) + } + if strings.Contains(c.Endpoint.AuthURL, "?") { + buf.WriteByte('&') + } else { + buf.WriteByte('?') + } + buf.WriteString(v.Encode()) + return buf.String() +} + +// PasswordCredentialsToken converts a resource owner username and password +// pair into a token. +// +// Per the RFC, this grant type should only be used "when there is a high +// degree of trust between the resource owner and the client (e.g., the client +// is part of the device operating system or a highly privileged application), +// and when other authorization grant types are not available." +// See https://tools.ietf.org/html/rfc6749#section-4.3 for more info. +// +// The provided context optionally controls which HTTP client is used. See the [HTTPClient] variable. +func (c *Config) PasswordCredentialsToken(ctx context.Context, username, password string) (*Token, error) { + v := url.Values{ + "grant_type": {"password"}, + "username": {username}, + "password": {password}, + } + if len(c.Scopes) > 0 { + v.Set("scope", strings.Join(c.Scopes, " ")) + } + return retrieveToken(ctx, c, v) +} + +// Exchange converts an authorization code into a token. +// +// It is used after a resource provider redirects the user back +// to the Redirect URI (the URL obtained from AuthCodeURL). +// +// The provided context optionally controls which HTTP client is used. See the [HTTPClient] variable. +// +// The code will be in the [http.Request.FormValue]("code"). Before +// calling Exchange, be sure to validate [http.Request.FormValue]("state") if you are +// using it to protect against CSRF attacks. +// +// If using PKCE to protect against CSRF attacks, opts should include a +// VerifierOption. +func (c *Config) Exchange(ctx context.Context, code string, opts ...AuthCodeOption) (*Token, error) { + v := url.Values{ + "grant_type": {"authorization_code"}, + "code": {code}, + } + if c.RedirectURL != "" { + v.Set("redirect_uri", c.RedirectURL) + } + for _, opt := range opts { + opt.setValue(v) + } + return retrieveToken(ctx, c, v) +} + +// Client returns an HTTP client using the provided token. +// The token will auto-refresh as necessary. The underlying +// HTTP transport will be obtained using the provided context. +// The returned client and its Transport should not be modified. +func (c *Config) Client(ctx context.Context, t *Token) *http.Client { + return NewClient(ctx, c.TokenSource(ctx, t)) +} + +// TokenSource returns a [TokenSource] that returns t until t expires, +// automatically refreshing it as necessary using the provided context. +// +// Most users will use [Config.Client] instead. +func (c *Config) TokenSource(ctx context.Context, t *Token) TokenSource { + tkr := &tokenRefresher{ + ctx: ctx, + conf: c, + } + if t != nil { + tkr.refreshToken = t.RefreshToken + } + return &reuseTokenSource{ + t: t, + new: tkr, + } +} + +// tokenRefresher is a TokenSource that makes "grant_type=refresh_token" +// HTTP requests to renew a token using a RefreshToken. +type tokenRefresher struct { + ctx context.Context // used to get HTTP requests + conf *Config + refreshToken string +} + +// WARNING: Token is not safe for concurrent access, as it +// updates the tokenRefresher's refreshToken field. +// Within this package, it is used by reuseTokenSource which +// synchronizes calls to this method with its own mutex. +func (tf *tokenRefresher) Token() (*Token, error) { + if tf.refreshToken == "" { + return nil, errors.New("oauth2: token expired and refresh token is not set") + } + + tk, err := retrieveToken(tf.ctx, tf.conf, url.Values{ + "grant_type": {"refresh_token"}, + "refresh_token": {tf.refreshToken}, + }) + + if err != nil { + return nil, err + } + if tf.refreshToken != tk.RefreshToken { + tf.refreshToken = tk.RefreshToken + } + return tk, nil +} + +// reuseTokenSource is a TokenSource that holds a single token in memory +// and validates its expiry before each call to retrieve it with +// Token. If it's expired, it will be auto-refreshed using the +// new TokenSource. +type reuseTokenSource struct { + new TokenSource // called when t is expired. + + mu sync.Mutex // guards t + t *Token + + expiryDelta time.Duration +} + +// Token returns the current token if it's still valid, else will +// refresh the current token and return the new one. +func (s *reuseTokenSource) Token() (*Token, error) { + s.mu.Lock() + defer s.mu.Unlock() + if s.t.Valid() { + return s.t, nil + } + t, err := s.new.Token() + if err != nil { + return nil, err + } + t.expiryDelta = s.expiryDelta + s.t = t + return t, nil +} + +// StaticTokenSource returns a [TokenSource] that always returns the same token. +// Because the provided token t is never refreshed, StaticTokenSource is only +// useful for tokens that never expire. +func StaticTokenSource(t *Token) TokenSource { + return staticTokenSource{t} +} + +// staticTokenSource is a TokenSource that always returns the same Token. +type staticTokenSource struct { + t *Token +} + +func (s staticTokenSource) Token() (*Token, error) { + return s.t, nil +} + +// HTTPClient is the context key to use with [context.WithValue] +// to associate a [*http.Client] value with a context. +var HTTPClient internal.ContextKey + +// NewClient creates an [*http.Client] from a [context.Context] and [TokenSource]. +// The returned client is not valid beyond the lifetime of the context. +// +// Note that if a custom [*http.Client] is provided via the [context.Context] it +// is used only for token acquisition and is not used to configure the +// [*http.Client] returned from NewClient. +// +// As a special case, if src is nil, a non-OAuth2 client is returned +// using the provided context. This exists to support related OAuth2 +// packages. +func NewClient(ctx context.Context, src TokenSource) *http.Client { + if src == nil { + return internal.ContextClient(ctx) + } + cc := internal.ContextClient(ctx) + return &http.Client{ + Transport: &Transport{ + Base: cc.Transport, + Source: ReuseTokenSource(nil, src), + }, + CheckRedirect: cc.CheckRedirect, + Jar: cc.Jar, + Timeout: cc.Timeout, + } +} + +// ReuseTokenSource returns a [TokenSource] which repeatedly returns the +// same token as long as it's valid, starting with t. +// When its cached token is invalid, a new token is obtained from src. +// +// ReuseTokenSource is typically used to reuse tokens from a cache +// (such as a file on disk) between runs of a program, rather than +// obtaining new tokens unnecessarily. +// +// The initial token t may be nil, in which case the [TokenSource] is +// wrapped in a caching version if it isn't one already. This also +// means it's always safe to wrap ReuseTokenSource around any other +// [TokenSource] without adverse effects. +func ReuseTokenSource(t *Token, src TokenSource) TokenSource { + // Don't wrap a reuseTokenSource in itself. That would work, + // but cause an unnecessary number of mutex operations. + // Just build the equivalent one. + if rt, ok := src.(*reuseTokenSource); ok { + if t == nil { + // Just use it directly. + return rt + } + src = rt.new + } + return &reuseTokenSource{ + t: t, + new: src, + } +} + +// ReuseTokenSourceWithExpiry returns a [TokenSource] that acts in the same manner as the +// [TokenSource] returned by [ReuseTokenSource], except the expiry buffer is +// configurable. The expiration time of a token is calculated as +// t.Expiry.Add(-earlyExpiry). +func ReuseTokenSourceWithExpiry(t *Token, src TokenSource, earlyExpiry time.Duration) TokenSource { + // Don't wrap a reuseTokenSource in itself. That would work, + // but cause an unnecessary number of mutex operations. + // Just build the equivalent one. + if rt, ok := src.(*reuseTokenSource); ok { + if t == nil { + // Just use it directly, but set the expiryDelta to earlyExpiry, + // so the behavior matches what the user expects. + rt.expiryDelta = earlyExpiry + return rt + } + src = rt.new + } + if t != nil { + t.expiryDelta = earlyExpiry + } + return &reuseTokenSource{ + t: t, + new: src, + expiryDelta: earlyExpiry, + } +} diff --git a/mcp/dsx-exchange-mcp/vendor/golang.org/x/oauth2/pkce.go b/mcp/dsx-exchange-mcp/vendor/golang.org/x/oauth2/pkce.go new file mode 100644 index 0000000..f99384f --- /dev/null +++ b/mcp/dsx-exchange-mcp/vendor/golang.org/x/oauth2/pkce.go @@ -0,0 +1,69 @@ +// Copyright 2023 The Go Authors. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +package oauth2 + +import ( + "crypto/rand" + "crypto/sha256" + "encoding/base64" + "net/url" +) + +const ( + codeChallengeKey = "code_challenge" + codeChallengeMethodKey = "code_challenge_method" + codeVerifierKey = "code_verifier" +) + +// GenerateVerifier generates a PKCE code verifier with 32 octets of randomness. +// This follows recommendations in RFC 7636. +// +// A fresh verifier should be generated for each authorization. +// The resulting verifier should be passed to [Config.AuthCodeURL] or [Config.DeviceAuth] +// with [S256ChallengeOption], and to [Config.Exchange] or [Config.DeviceAccessToken] +// with [VerifierOption]. +func GenerateVerifier() string { + // "RECOMMENDED that the output of a suitable random number generator be + // used to create a 32-octet sequence. The octet sequence is then + // base64url-encoded to produce a 43-octet URL-safe string to use as the + // code verifier." + // https://datatracker.ietf.org/doc/html/rfc7636#section-4.1 + data := make([]byte, 32) + if _, err := rand.Read(data); err != nil { + panic(err) + } + return base64.RawURLEncoding.EncodeToString(data) +} + +// VerifierOption returns a PKCE code verifier [AuthCodeOption]. It should only be +// passed to [Config.Exchange] or [Config.DeviceAccessToken]. +func VerifierOption(verifier string) AuthCodeOption { + return setParam{k: codeVerifierKey, v: verifier} +} + +// S256ChallengeFromVerifier returns a PKCE code challenge derived from verifier with method S256. +// +// Prefer to use [S256ChallengeOption] where possible. +func S256ChallengeFromVerifier(verifier string) string { + sha := sha256.Sum256([]byte(verifier)) + return base64.RawURLEncoding.EncodeToString(sha[:]) +} + +// S256ChallengeOption derives a PKCE code challenge from the verifier with +// method S256. It should be passed to [Config.AuthCodeURL] or [Config.DeviceAuth] +// only. +func S256ChallengeOption(verifier string) AuthCodeOption { + return challengeOption{ + challenge_method: "S256", + challenge: S256ChallengeFromVerifier(verifier), + } +} + +type challengeOption struct{ challenge_method, challenge string } + +func (p challengeOption) setValue(m url.Values) { + m.Set(codeChallengeMethodKey, p.challenge_method) + m.Set(codeChallengeKey, p.challenge) +} diff --git a/mcp/dsx-exchange-mcp/vendor/golang.org/x/oauth2/token.go b/mcp/dsx-exchange-mcp/vendor/golang.org/x/oauth2/token.go new file mode 100644 index 0000000..e995eeb --- /dev/null +++ b/mcp/dsx-exchange-mcp/vendor/golang.org/x/oauth2/token.go @@ -0,0 +1,213 @@ +// Copyright 2014 The Go Authors. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +package oauth2 + +import ( + "context" + "fmt" + "net/http" + "net/url" + "strconv" + "strings" + "time" + + "golang.org/x/oauth2/internal" +) + +// defaultExpiryDelta determines how earlier a token should be considered +// expired than its actual expiration time. It is used to avoid late +// expirations due to client-server time mismatches. +const defaultExpiryDelta = 10 * time.Second + +// Token represents the credentials used to authorize +// the requests to access protected resources on the OAuth 2.0 +// provider's backend. +// +// Most users of this package should not access fields of Token +// directly. They're exported mostly for use by related packages +// implementing derivative OAuth2 flows. +type Token struct { + // AccessToken is the token that authorizes and authenticates + // the requests. + AccessToken string `json:"access_token"` + + // TokenType is the type of token. + // The Type method returns either this or "Bearer", the default. + TokenType string `json:"token_type,omitempty"` + + // RefreshToken is a token that's used by the application + // (as opposed to the user) to refresh the access token + // if it expires. + RefreshToken string `json:"refresh_token,omitempty"` + + // Expiry is the optional expiration time of the access token. + // + // If zero, [TokenSource] implementations will reuse the same + // token forever and RefreshToken or equivalent + // mechanisms for that TokenSource will not be used. + Expiry time.Time `json:"expiry,omitempty"` + + // ExpiresIn is the OAuth2 wire format "expires_in" field, + // which specifies how many seconds later the token expires, + // relative to an unknown time base approximately around "now". + // It is the application's responsibility to populate + // `Expiry` from `ExpiresIn` when required. + ExpiresIn int64 `json:"expires_in,omitempty"` + + // raw optionally contains extra metadata from the server + // when updating a token. + raw any + + // expiryDelta is used to calculate when a token is considered + // expired, by subtracting from Expiry. If zero, defaultExpiryDelta + // is used. + expiryDelta time.Duration +} + +// Type returns t.TokenType if non-empty, else "Bearer". +func (t *Token) Type() string { + if strings.EqualFold(t.TokenType, "bearer") { + return "Bearer" + } + if strings.EqualFold(t.TokenType, "mac") { + return "MAC" + } + if strings.EqualFold(t.TokenType, "basic") { + return "Basic" + } + if t.TokenType != "" { + return t.TokenType + } + return "Bearer" +} + +// SetAuthHeader sets the Authorization header to r using the access +// token in t. +// +// This method is unnecessary when using [Transport] or an HTTP Client +// returned by this package. +func (t *Token) SetAuthHeader(r *http.Request) { + r.Header.Set("Authorization", t.Type()+" "+t.AccessToken) +} + +// WithExtra returns a new [Token] that's a clone of t, but using the +// provided raw extra map. This is only intended for use by packages +// implementing derivative OAuth2 flows. +func (t *Token) WithExtra(extra any) *Token { + t2 := new(Token) + *t2 = *t + t2.raw = extra + return t2 +} + +// Extra returns an extra field. +// Extra fields are key-value pairs returned by the server as +// part of the token retrieval response. +func (t *Token) Extra(key string) any { + if raw, ok := t.raw.(map[string]any); ok { + return raw[key] + } + + vals, ok := t.raw.(url.Values) + if !ok { + return nil + } + + v := vals.Get(key) + switch s := strings.TrimSpace(v); strings.Count(s, ".") { + case 0: // Contains no "."; try to parse as int + if i, err := strconv.ParseInt(s, 10, 64); err == nil { + return i + } + case 1: // Contains a single "."; try to parse as float + if f, err := strconv.ParseFloat(s, 64); err == nil { + return f + } + } + + return v +} + +// timeNow is time.Now but pulled out as a variable for tests. +var timeNow = time.Now + +// expired reports whether the token is expired. +// t must be non-nil. +func (t *Token) expired() bool { + if t.Expiry.IsZero() { + return false + } + + expiryDelta := defaultExpiryDelta + if t.expiryDelta != 0 { + expiryDelta = t.expiryDelta + } + return t.Expiry.Round(0).Add(-expiryDelta).Before(timeNow()) +} + +// Valid reports whether t is non-nil, has an AccessToken, and is not expired. +func (t *Token) Valid() bool { + return t != nil && t.AccessToken != "" && !t.expired() +} + +// tokenFromInternal maps an *internal.Token struct into +// a *Token struct. +func tokenFromInternal(t *internal.Token) *Token { + if t == nil { + return nil + } + return &Token{ + AccessToken: t.AccessToken, + TokenType: t.TokenType, + RefreshToken: t.RefreshToken, + Expiry: t.Expiry, + ExpiresIn: t.ExpiresIn, + raw: t.Raw, + } +} + +// retrieveToken takes a *Config and uses that to retrieve an *internal.Token. +// This token is then mapped from *internal.Token into an *oauth2.Token which is returned along +// with an error. +func retrieveToken(ctx context.Context, c *Config, v url.Values) (*Token, error) { + tk, err := internal.RetrieveToken(ctx, c.ClientID, c.ClientSecret, c.Endpoint.TokenURL, v, internal.AuthStyle(c.Endpoint.AuthStyle), c.authStyleCache.Get()) + if err != nil { + if rErr, ok := err.(*internal.RetrieveError); ok { + return nil, (*RetrieveError)(rErr) + } + return nil, err + } + return tokenFromInternal(tk), nil +} + +// RetrieveError is the error returned when the token endpoint returns a +// non-2XX HTTP status code or populates RFC 6749's 'error' parameter. +// https://datatracker.ietf.org/doc/html/rfc6749#section-5.2 +type RetrieveError struct { + Response *http.Response + // Body is the body that was consumed by reading Response.Body. + // It may be truncated. + Body []byte + // ErrorCode is RFC 6749's 'error' parameter. + ErrorCode string + // ErrorDescription is RFC 6749's 'error_description' parameter. + ErrorDescription string + // ErrorURI is RFC 6749's 'error_uri' parameter. + ErrorURI string +} + +func (r *RetrieveError) Error() string { + if r.ErrorCode != "" { + s := fmt.Sprintf("oauth2: %q", r.ErrorCode) + if r.ErrorDescription != "" { + s += fmt.Sprintf(" %q", r.ErrorDescription) + } + if r.ErrorURI != "" { + s += fmt.Sprintf(" %q", r.ErrorURI) + } + return s + } + return fmt.Sprintf("oauth2: cannot fetch token: %v\nResponse: %s", r.Response.Status, r.Body) +} diff --git a/mcp/dsx-exchange-mcp/vendor/golang.org/x/oauth2/transport.go b/mcp/dsx-exchange-mcp/vendor/golang.org/x/oauth2/transport.go new file mode 100644 index 0000000..9922ec3 --- /dev/null +++ b/mcp/dsx-exchange-mcp/vendor/golang.org/x/oauth2/transport.go @@ -0,0 +1,75 @@ +// Copyright 2014 The Go Authors. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +package oauth2 + +import ( + "errors" + "log" + "net/http" + "sync" +) + +// Transport is an [http.RoundTripper] that makes OAuth 2.0 HTTP requests, +// wrapping a base [http.RoundTripper] and adding an Authorization header +// with a token from the supplied [TokenSource]. +// +// Transport is a low-level mechanism. Most code will use the +// higher-level [Config.Client] method instead. +type Transport struct { + // Source supplies the token to add to outgoing requests' + // Authorization headers. + Source TokenSource + + // Base is the base RoundTripper used to make HTTP requests. + // If nil, http.DefaultTransport is used. + Base http.RoundTripper +} + +// RoundTrip authorizes and authenticates the request with an +// access token from Transport's Source. +func (t *Transport) RoundTrip(req *http.Request) (*http.Response, error) { + reqBodyClosed := false + if req.Body != nil { + defer func() { + if !reqBodyClosed { + req.Body.Close() + } + }() + } + + if t.Source == nil { + return nil, errors.New("oauth2: Transport's Source is nil") + } + token, err := t.Source.Token() + if err != nil { + return nil, err + } + + req2 := req.Clone(req.Context()) + token.SetAuthHeader(req2) + + // req.Body is assumed to be closed by the base RoundTripper. + reqBodyClosed = true + return t.base().RoundTrip(req2) +} + +var cancelOnce sync.Once + +// CancelRequest does nothing. It used to be a legacy cancellation mechanism +// but now only logs on first use to warn that it's deprecated. +// +// Deprecated: use contexts for cancellation instead. +func (t *Transport) CancelRequest(req *http.Request) { + cancelOnce.Do(func() { + log.Printf("deprecated: golang.org/x/oauth2: Transport.CancelRequest no longer does anything; use contexts") + }) +} + +func (t *Transport) base() http.RoundTripper { + if t.Base != nil { + return t.Base + } + return http.DefaultTransport +} diff --git a/mcp/dsx-exchange-mcp/vendor/golang.org/x/sync/LICENSE b/mcp/dsx-exchange-mcp/vendor/golang.org/x/sync/LICENSE new file mode 100644 index 0000000..2a7cf70 --- /dev/null +++ b/mcp/dsx-exchange-mcp/vendor/golang.org/x/sync/LICENSE @@ -0,0 +1,27 @@ +Copyright 2009 The Go Authors. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are +met: + + * Redistributions of source code must retain the above copyright +notice, this list of conditions and the following disclaimer. + * Redistributions in binary form must reproduce the above +copyright notice, this list of conditions and the following disclaimer +in the documentation and/or other materials provided with the +distribution. + * Neither the name of Google LLC nor the names of its +contributors may be used to endorse or promote products derived from +this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT +OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT +LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, +DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY +THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/mcp/dsx-exchange-mcp/vendor/golang.org/x/sync/PATENTS b/mcp/dsx-exchange-mcp/vendor/golang.org/x/sync/PATENTS new file mode 100644 index 0000000..7330990 --- /dev/null +++ b/mcp/dsx-exchange-mcp/vendor/golang.org/x/sync/PATENTS @@ -0,0 +1,22 @@ +Additional IP Rights Grant (Patents) + +"This implementation" means the copyrightable works distributed by +Google as part of the Go project. + +Google hereby grants to You a perpetual, worldwide, non-exclusive, +no-charge, royalty-free, irrevocable (except as stated in this section) +patent license to make, have made, use, offer to sell, sell, import, +transfer and otherwise run, modify and propagate the contents of this +implementation of Go, where such license applies only to those patent +claims, both currently owned or controlled by Google and acquired in +the future, licensable by Google that are necessarily infringed by this +implementation of Go. This grant does not include claims that would be +infringed only as a consequence of further modification of this +implementation. If you or your agent or exclusive licensee institute or +order or agree to the institution of patent litigation against any +entity (including a cross-claim or counterclaim in a lawsuit) alleging +that this implementation of Go or any code incorporated within this +implementation of Go constitutes direct or contributory patent +infringement, or inducement of patent infringement, then any patent +rights granted to you under this License for this implementation of Go +shall terminate as of the date such litigation is filed. diff --git a/mcp/dsx-exchange-mcp/vendor/golang.org/x/sync/semaphore/semaphore.go b/mcp/dsx-exchange-mcp/vendor/golang.org/x/sync/semaphore/semaphore.go new file mode 100644 index 0000000..b618162 --- /dev/null +++ b/mcp/dsx-exchange-mcp/vendor/golang.org/x/sync/semaphore/semaphore.go @@ -0,0 +1,160 @@ +// Copyright 2017 The Go Authors. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +// Package semaphore provides a weighted semaphore implementation. +package semaphore // import "golang.org/x/sync/semaphore" + +import ( + "container/list" + "context" + "sync" +) + +type waiter struct { + n int64 + ready chan<- struct{} // Closed when semaphore acquired. +} + +// NewWeighted creates a new weighted semaphore with the given +// maximum combined weight for concurrent access. +func NewWeighted(n int64) *Weighted { + w := &Weighted{size: n} + return w +} + +// Weighted provides a way to bound concurrent access to a resource. +// The callers can request access with a given weight. +type Weighted struct { + size int64 + cur int64 + mu sync.Mutex + waiters list.List +} + +// Acquire acquires the semaphore with a weight of n, blocking until resources +// are available or ctx is done. On success, returns nil. On failure, returns +// ctx.Err() and leaves the semaphore unchanged. +func (s *Weighted) Acquire(ctx context.Context, n int64) error { + done := ctx.Done() + + s.mu.Lock() + select { + case <-done: + // ctx becoming done has "happened before" acquiring the semaphore, + // whether it became done before the call began or while we were + // waiting for the mutex. We prefer to fail even if we could acquire + // the mutex without blocking. + s.mu.Unlock() + return ctx.Err() + default: + } + if s.size-s.cur >= n && s.waiters.Len() == 0 { + // Since we hold s.mu and haven't synchronized since checking done, if + // ctx becomes done before we return here, it becoming done must have + // "happened concurrently" with this call - it cannot "happen before" + // we return in this branch. So, we're ok to always acquire here. + s.cur += n + s.mu.Unlock() + return nil + } + + if n > s.size { + // Don't make other Acquire calls block on one that's doomed to fail. + s.mu.Unlock() + <-done + return ctx.Err() + } + + ready := make(chan struct{}) + w := waiter{n: n, ready: ready} + elem := s.waiters.PushBack(w) + s.mu.Unlock() + + select { + case <-done: + s.mu.Lock() + select { + case <-ready: + // Acquired the semaphore after we were canceled. + // Pretend we didn't and put the tokens back. + s.cur -= n + s.notifyWaiters() + default: + isFront := s.waiters.Front() == elem + s.waiters.Remove(elem) + // If we're at the front and there're extra tokens left, notify other waiters. + if isFront && s.size > s.cur { + s.notifyWaiters() + } + } + s.mu.Unlock() + return ctx.Err() + + case <-ready: + // Acquired the semaphore. Check that ctx isn't already done. + // We check the done channel instead of calling ctx.Err because we + // already have the channel, and ctx.Err is O(n) with the nesting + // depth of ctx. + select { + case <-done: + s.Release(n) + return ctx.Err() + default: + } + return nil + } +} + +// TryAcquire acquires the semaphore with a weight of n without blocking. +// On success, returns true. On failure, returns false and leaves the semaphore unchanged. +func (s *Weighted) TryAcquire(n int64) bool { + s.mu.Lock() + success := s.size-s.cur >= n && s.waiters.Len() == 0 + if success { + s.cur += n + } + s.mu.Unlock() + return success +} + +// Release releases the semaphore with a weight of n. +func (s *Weighted) Release(n int64) { + s.mu.Lock() + s.cur -= n + if s.cur < 0 { + s.mu.Unlock() + panic("semaphore: released more than held") + } + s.notifyWaiters() + s.mu.Unlock() +} + +func (s *Weighted) notifyWaiters() { + for { + next := s.waiters.Front() + if next == nil { + break // No more waiters blocked. + } + + w := next.Value.(waiter) + if s.size-s.cur < w.n { + // Not enough tokens for the next waiter. We could keep going (to try to + // find a waiter with a smaller request), but under load that could cause + // starvation for large requests; instead, we leave all remaining waiters + // blocked. + // + // Consider a semaphore used as a read-write lock, with N tokens, N + // readers, and one writer. Each reader can Acquire(1) to obtain a read + // lock. The writer can Acquire(N) to obtain a write lock, excluding all + // of the readers. If we allow the readers to jump ahead in the queue, + // the writer will starve — there is always one token available for every + // reader. + break + } + + s.cur += w.n + s.waiters.Remove(next) + close(w.ready) + } +} diff --git a/mcp/dsx-exchange-mcp/vendor/golang.org/x/sys/LICENSE b/mcp/dsx-exchange-mcp/vendor/golang.org/x/sys/LICENSE new file mode 100644 index 0000000..2a7cf70 --- /dev/null +++ b/mcp/dsx-exchange-mcp/vendor/golang.org/x/sys/LICENSE @@ -0,0 +1,27 @@ +Copyright 2009 The Go Authors. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are +met: + + * Redistributions of source code must retain the above copyright +notice, this list of conditions and the following disclaimer. + * Redistributions in binary form must reproduce the above +copyright notice, this list of conditions and the following disclaimer +in the documentation and/or other materials provided with the +distribution. + * Neither the name of Google LLC nor the names of its +contributors may be used to endorse or promote products derived from +this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT +OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT +LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, +DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY +THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/mcp/dsx-exchange-mcp/vendor/golang.org/x/sys/PATENTS b/mcp/dsx-exchange-mcp/vendor/golang.org/x/sys/PATENTS new file mode 100644 index 0000000..7330990 --- /dev/null +++ b/mcp/dsx-exchange-mcp/vendor/golang.org/x/sys/PATENTS @@ -0,0 +1,22 @@ +Additional IP Rights Grant (Patents) + +"This implementation" means the copyrightable works distributed by +Google as part of the Go project. + +Google hereby grants to You a perpetual, worldwide, non-exclusive, +no-charge, royalty-free, irrevocable (except as stated in this section) +patent license to make, have made, use, offer to sell, sell, import, +transfer and otherwise run, modify and propagate the contents of this +implementation of Go, where such license applies only to those patent +claims, both currently owned or controlled by Google and acquired in +the future, licensable by Google that are necessarily infringed by this +implementation of Go. This grant does not include claims that would be +infringed only as a consequence of further modification of this +implementation. If you or your agent or exclusive licensee institute or +order or agree to the institution of patent litigation against any +entity (including a cross-claim or counterclaim in a lawsuit) alleging +that this implementation of Go or any code incorporated within this +implementation of Go constitutes direct or contributory patent +infringement, or inducement of patent infringement, then any patent +rights granted to you under this License for this implementation of Go +shall terminate as of the date such litigation is filed. diff --git a/mcp/dsx-exchange-mcp/vendor/golang.org/x/sys/cpu/asm_aix_ppc64.s b/mcp/dsx-exchange-mcp/vendor/golang.org/x/sys/cpu/asm_aix_ppc64.s new file mode 100644 index 0000000..269e173 --- /dev/null +++ b/mcp/dsx-exchange-mcp/vendor/golang.org/x/sys/cpu/asm_aix_ppc64.s @@ -0,0 +1,17 @@ +// Copyright 2018 The Go Authors. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +//go:build gc + +#include "textflag.h" + +// +// System calls for ppc64, AIX are implemented in runtime/syscall_aix.go +// + +TEXT ·syscall6(SB),NOSPLIT,$0-88 + JMP syscall·syscall6(SB) + +TEXT ·rawSyscall6(SB),NOSPLIT,$0-88 + JMP syscall·rawSyscall6(SB) diff --git a/mcp/dsx-exchange-mcp/vendor/golang.org/x/sys/cpu/asm_darwin_x86_gc.s b/mcp/dsx-exchange-mcp/vendor/golang.org/x/sys/cpu/asm_darwin_x86_gc.s new file mode 100644 index 0000000..ec2acfe --- /dev/null +++ b/mcp/dsx-exchange-mcp/vendor/golang.org/x/sys/cpu/asm_darwin_x86_gc.s @@ -0,0 +1,17 @@ +// Copyright 2024 The Go Authors. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +//go:build darwin && amd64 && gc + +#include "textflag.h" + +TEXT libc_sysctl_trampoline<>(SB),NOSPLIT,$0-0 + JMP libc_sysctl(SB) +GLOBL ·libc_sysctl_trampoline_addr(SB), RODATA, $8 +DATA ·libc_sysctl_trampoline_addr(SB)/8, $libc_sysctl_trampoline<>(SB) + +TEXT libc_sysctlbyname_trampoline<>(SB),NOSPLIT,$0-0 + JMP libc_sysctlbyname(SB) +GLOBL ·libc_sysctlbyname_trampoline_addr(SB), RODATA, $8 +DATA ·libc_sysctlbyname_trampoline_addr(SB)/8, $libc_sysctlbyname_trampoline<>(SB) diff --git a/mcp/dsx-exchange-mcp/vendor/golang.org/x/sys/cpu/byteorder.go b/mcp/dsx-exchange-mcp/vendor/golang.org/x/sys/cpu/byteorder.go new file mode 100644 index 0000000..271055b --- /dev/null +++ b/mcp/dsx-exchange-mcp/vendor/golang.org/x/sys/cpu/byteorder.go @@ -0,0 +1,66 @@ +// Copyright 2019 The Go Authors. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +package cpu + +import ( + "runtime" +) + +// byteOrder is a subset of encoding/binary.ByteOrder. +type byteOrder interface { + Uint32([]byte) uint32 + Uint64([]byte) uint64 +} + +type littleEndian struct{} +type bigEndian struct{} + +func (littleEndian) Uint32(b []byte) uint32 { + _ = b[3] // bounds check hint to compiler; see golang.org/issue/14808 + return uint32(b[0]) | uint32(b[1])<<8 | uint32(b[2])<<16 | uint32(b[3])<<24 +} + +func (littleEndian) Uint64(b []byte) uint64 { + _ = b[7] // bounds check hint to compiler; see golang.org/issue/14808 + return uint64(b[0]) | uint64(b[1])<<8 | uint64(b[2])<<16 | uint64(b[3])<<24 | + uint64(b[4])<<32 | uint64(b[5])<<40 | uint64(b[6])<<48 | uint64(b[7])<<56 +} + +func (bigEndian) Uint32(b []byte) uint32 { + _ = b[3] // bounds check hint to compiler; see golang.org/issue/14808 + return uint32(b[3]) | uint32(b[2])<<8 | uint32(b[1])<<16 | uint32(b[0])<<24 +} + +func (bigEndian) Uint64(b []byte) uint64 { + _ = b[7] // bounds check hint to compiler; see golang.org/issue/14808 + return uint64(b[7]) | uint64(b[6])<<8 | uint64(b[5])<<16 | uint64(b[4])<<24 | + uint64(b[3])<<32 | uint64(b[2])<<40 | uint64(b[1])<<48 | uint64(b[0])<<56 +} + +// hostByteOrder returns littleEndian on little-endian machines and +// bigEndian on big-endian machines. +func hostByteOrder() byteOrder { + switch runtime.GOARCH { + case "386", "amd64", "amd64p32", + "alpha", + "arm", "arm64", + "loong64", + "mipsle", "mips64le", "mips64p32le", + "nios2", + "ppc64le", + "riscv", "riscv64", + "sh": + return littleEndian{} + case "armbe", "arm64be", + "m68k", + "mips", "mips64", "mips64p32", + "ppc", "ppc64", + "s390", "s390x", + "shbe", + "sparc", "sparc64": + return bigEndian{} + } + panic("unknown architecture") +} diff --git a/mcp/dsx-exchange-mcp/vendor/golang.org/x/sys/cpu/cpu.go b/mcp/dsx-exchange-mcp/vendor/golang.org/x/sys/cpu/cpu.go new file mode 100644 index 0000000..6354199 --- /dev/null +++ b/mcp/dsx-exchange-mcp/vendor/golang.org/x/sys/cpu/cpu.go @@ -0,0 +1,338 @@ +// Copyright 2018 The Go Authors. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +// Package cpu implements processor feature detection for +// various CPU architectures. +package cpu + +import ( + "os" + "strings" +) + +// Initialized reports whether the CPU features were initialized. +// +// For some GOOS/GOARCH combinations initialization of the CPU features depends +// on reading an operating specific file, e.g. /proc/self/auxv on linux/arm +// Initialized will report false if reading the file fails. +var Initialized bool + +// CacheLinePad is used to pad structs to avoid false sharing. +type CacheLinePad struct{ _ [cacheLineSize]byte } + +// X86 contains the supported CPU features of the +// current X86/AMD64 platform. If the current platform +// is not X86/AMD64 then all feature flags are false. +// +// X86 is padded to avoid false sharing. Further the HasAVX +// and HasAVX2 are only set if the OS supports XMM and YMM +// registers in addition to the CPUID feature bit being set. +var X86 struct { + _ CacheLinePad + HasAES bool // AES hardware implementation (AES NI) + HasADX bool // Multi-precision add-carry instruction extensions + HasAVX bool // Advanced vector extension + HasAVX2 bool // Advanced vector extension 2 + HasAVX512 bool // Advanced vector extension 512 + HasAVX512F bool // Advanced vector extension 512 Foundation Instructions + HasAVX512CD bool // Advanced vector extension 512 Conflict Detection Instructions + HasAVX512ER bool // Advanced vector extension 512 Exponential and Reciprocal Instructions + HasAVX512PF bool // Advanced vector extension 512 Prefetch Instructions + HasAVX512VL bool // Advanced vector extension 512 Vector Length Extensions + HasAVX512BW bool // Advanced vector extension 512 Byte and Word Instructions + HasAVX512DQ bool // Advanced vector extension 512 Doubleword and Quadword Instructions + HasAVX512IFMA bool // Advanced vector extension 512 Integer Fused Multiply Add + HasAVX512VBMI bool // Advanced vector extension 512 Vector Byte Manipulation Instructions + HasAVX5124VNNIW bool // Advanced vector extension 512 Vector Neural Network Instructions Word variable precision + HasAVX5124FMAPS bool // Advanced vector extension 512 Fused Multiply Accumulation Packed Single precision + HasAVX512VPOPCNTDQ bool // Advanced vector extension 512 Double and quad word population count instructions + HasAVX512VPCLMULQDQ bool // Advanced vector extension 512 Vector carry-less multiply operations + HasAVX512VNNI bool // Advanced vector extension 512 Vector Neural Network Instructions + HasAVX512GFNI bool // Advanced vector extension 512 Galois field New Instructions + HasAVX512VAES bool // Advanced vector extension 512 Vector AES instructions + HasAVX512VBMI2 bool // Advanced vector extension 512 Vector Byte Manipulation Instructions 2 + HasAVX512BITALG bool // Advanced vector extension 512 Bit Algorithms + HasAVX512BF16 bool // Advanced vector extension 512 BFloat16 Instructions + HasAMXTile bool // Advanced Matrix Extension Tile instructions + HasAMXInt8 bool // Advanced Matrix Extension Int8 instructions + HasAMXBF16 bool // Advanced Matrix Extension BFloat16 instructions + HasBMI1 bool // Bit manipulation instruction set 1 + HasBMI2 bool // Bit manipulation instruction set 2 + HasCX16 bool // Compare and exchange 16 Bytes + HasERMS bool // Enhanced REP for MOVSB and STOSB + HasFMA bool // Fused-multiply-add instructions + HasOSXSAVE bool // OS supports XSAVE/XRESTOR for saving/restoring XMM registers. + HasPCLMULQDQ bool // PCLMULQDQ instruction - most often used for AES-GCM + HasPOPCNT bool // Hamming weight instruction POPCNT. + HasRDRAND bool // RDRAND instruction (on-chip random number generator) + HasRDSEED bool // RDSEED instruction (on-chip random number generator) + HasSSE2 bool // Streaming SIMD extension 2 (always available on amd64) + HasSSE3 bool // Streaming SIMD extension 3 + HasSSSE3 bool // Supplemental streaming SIMD extension 3 + HasSSE41 bool // Streaming SIMD extension 4 and 4.1 + HasSSE42 bool // Streaming SIMD extension 4 and 4.2 + HasAVXIFMA bool // Advanced vector extension Integer Fused Multiply Add + HasAVXVNNI bool // Advanced vector extension Vector Neural Network Instructions + HasAVXVNNIInt8 bool // Advanced vector extension Vector Neural Network Int8 instructions + _ CacheLinePad +} + +// ARM64 contains the supported CPU features of the +// current ARMv8(aarch64) platform. If the current platform +// is not arm64 then all feature flags are false. +var ARM64 struct { + _ CacheLinePad + HasFP bool // Floating-point instruction set (always available) + HasASIMD bool // Advanced SIMD (always available) + HasEVTSTRM bool // Event stream support + HasAES bool // AES hardware implementation + HasPMULL bool // Polynomial multiplication instruction set + HasSHA1 bool // SHA1 hardware implementation + HasSHA2 bool // SHA2 hardware implementation + HasCRC32 bool // CRC32 hardware implementation + HasATOMICS bool // Atomic memory operation instruction set + HasFPHP bool // Half precision floating-point instruction set + HasASIMDHP bool // Advanced SIMD half precision instruction set + HasCPUID bool // CPUID identification scheme registers + HasASIMDRDM bool // Rounding double multiply add/subtract instruction set + HasJSCVT bool // Javascript conversion from floating-point to integer + HasFCMA bool // Floating-point multiplication and addition of complex numbers + HasLRCPC bool // Release Consistent processor consistent support + HasDCPOP bool // Persistent memory support + HasSHA3 bool // SHA3 hardware implementation + HasSM3 bool // SM3 hardware implementation + HasSM4 bool // SM4 hardware implementation + HasASIMDDP bool // Advanced SIMD double precision instruction set + HasSHA512 bool // SHA512 hardware implementation + HasSVE bool // Scalable Vector Extensions + HasSVE2 bool // Scalable Vector Extensions 2 + HasASIMDFHM bool // Advanced SIMD multiplication FP16 to FP32 + HasDIT bool // Data Independent Timing support + HasI8MM bool // Advanced SIMD Int8 matrix multiplication instructions + _ CacheLinePad +} + +// ARM contains the supported CPU features of the current ARM (32-bit) platform. +// All feature flags are false if: +// 1. the current platform is not arm, or +// 2. the current operating system is not Linux. +var ARM struct { + _ CacheLinePad + HasSWP bool // SWP instruction support + HasHALF bool // Half-word load and store support + HasTHUMB bool // ARM Thumb instruction set + Has26BIT bool // Address space limited to 26-bits + HasFASTMUL bool // 32-bit operand, 64-bit result multiplication support + HasFPA bool // Floating point arithmetic support + HasVFP bool // Vector floating point support + HasEDSP bool // DSP Extensions support + HasJAVA bool // Java instruction set + HasIWMMXT bool // Intel Wireless MMX technology support + HasCRUNCH bool // MaverickCrunch context switching and handling + HasTHUMBEE bool // Thumb EE instruction set + HasNEON bool // NEON instruction set + HasVFPv3 bool // Vector floating point version 3 support + HasVFPv3D16 bool // Vector floating point version 3 D8-D15 + HasTLS bool // Thread local storage support + HasVFPv4 bool // Vector floating point version 4 support + HasIDIVA bool // Integer divide instruction support in ARM mode + HasIDIVT bool // Integer divide instruction support in Thumb mode + HasVFPD32 bool // Vector floating point version 3 D15-D31 + HasLPAE bool // Large Physical Address Extensions + HasEVTSTRM bool // Event stream support + HasAES bool // AES hardware implementation + HasPMULL bool // Polynomial multiplication instruction set + HasSHA1 bool // SHA1 hardware implementation + HasSHA2 bool // SHA2 hardware implementation + HasCRC32 bool // CRC32 hardware implementation + _ CacheLinePad +} + +// The booleans in Loong64 contain the correspondingly named cpu feature bit. +// The struct is padded to avoid false sharing. +var Loong64 struct { + _ CacheLinePad + HasLSX bool // support 128-bit vector extension + HasLASX bool // support 256-bit vector extension + HasCRC32 bool // support CRC instruction + HasLAM_BH bool // support AM{SWAP/ADD}[_DB].{B/H} instruction + HasLAMCAS bool // support AMCAS[_DB].{B/H/W/D} instruction + _ CacheLinePad +} + +// MIPS64X contains the supported CPU features of the current mips64/mips64le +// platforms. If the current platform is not mips64/mips64le or the current +// operating system is not Linux then all feature flags are false. +var MIPS64X struct { + _ CacheLinePad + HasMSA bool // MIPS SIMD architecture + _ CacheLinePad +} + +// PPC64 contains the supported CPU features of the current ppc64/ppc64le platforms. +// If the current platform is not ppc64/ppc64le then all feature flags are false. +// +// For ppc64/ppc64le, it is safe to check only for ISA level starting on ISA v3.00, +// since there are no optional categories. There are some exceptions that also +// require kernel support to work (DARN, SCV), so there are feature bits for +// those as well. The struct is padded to avoid false sharing. +var PPC64 struct { + _ CacheLinePad + HasDARN bool // Hardware random number generator (requires kernel enablement) + HasSCV bool // Syscall vectored (requires kernel enablement) + IsPOWER8 bool // ISA v2.07 (POWER8) + IsPOWER9 bool // ISA v3.00 (POWER9), implies IsPOWER8 + _ CacheLinePad +} + +// S390X contains the supported CPU features of the current IBM Z +// (s390x) platform. If the current platform is not IBM Z then all +// feature flags are false. +// +// S390X is padded to avoid false sharing. Further HasVX is only set +// if the OS supports vector registers in addition to the STFLE +// feature bit being set. +var S390X struct { + _ CacheLinePad + HasZARCH bool // z/Architecture mode is active [mandatory] + HasSTFLE bool // store facility list extended + HasLDISP bool // long (20-bit) displacements + HasEIMM bool // 32-bit immediates + HasDFP bool // decimal floating point + HasETF3EH bool // ETF-3 enhanced + HasMSA bool // message security assist (CPACF) + HasAES bool // KM-AES{128,192,256} functions + HasAESCBC bool // KMC-AES{128,192,256} functions + HasAESCTR bool // KMCTR-AES{128,192,256} functions + HasAESGCM bool // KMA-GCM-AES{128,192,256} functions + HasGHASH bool // KIMD-GHASH function + HasSHA1 bool // K{I,L}MD-SHA-1 functions + HasSHA256 bool // K{I,L}MD-SHA-256 functions + HasSHA512 bool // K{I,L}MD-SHA-512 functions + HasSHA3 bool // K{I,L}MD-SHA3-{224,256,384,512} and K{I,L}MD-SHAKE-{128,256} functions + HasVX bool // vector facility + HasVXE bool // vector-enhancements facility 1 + _ CacheLinePad +} + +// RISCV64 contains the supported CPU features and performance characteristics for riscv64 +// platforms. The booleans in RISCV64, with the exception of HasFastMisaligned, indicate +// the presence of RISC-V extensions. +// +// It is safe to assume that all the RV64G extensions are supported and so they are omitted from +// this structure. As riscv64 Go programs require at least RV64G, the code that populates +// this structure cannot run successfully if some of the RV64G extensions are missing. +// The struct is padded to avoid false sharing. +var RISCV64 struct { + _ CacheLinePad + HasFastMisaligned bool // Fast misaligned accesses + HasC bool // Compressed instruction-set extension + HasV bool // Vector extension compatible with RVV 1.0 + HasZba bool // Address generation instructions extension + HasZbb bool // Basic bit-manipulation extension + HasZbs bool // Single-bit instructions extension + HasZvbb bool // Vector Basic Bit-manipulation + HasZvbc bool // Vector Carryless Multiplication + HasZvkb bool // Vector Cryptography Bit-manipulation + HasZvkt bool // Vector Data-Independent Execution Latency + HasZvkg bool // Vector GCM/GMAC + HasZvkn bool // NIST Algorithm Suite (AES/SHA256/SHA512) + HasZvknc bool // NIST Algorithm Suite with carryless multiply + HasZvkng bool // NIST Algorithm Suite with GCM + HasZvks bool // ShangMi Algorithm Suite + HasZvksc bool // ShangMi Algorithm Suite with carryless multiplication + HasZvksg bool // ShangMi Algorithm Suite with GCM + _ CacheLinePad +} + +func init() { + archInit() + initOptions() + processOptions() +} + +// options contains the cpu debug options that can be used in GODEBUG. +// Options are arch dependent and are added by the arch specific initOptions functions. +// Features that are mandatory for the specific GOARCH should have the Required field set +// (e.g. SSE2 on amd64). +var options []option + +// Option names should be lower case. e.g. avx instead of AVX. +type option struct { + Name string + Feature *bool + Specified bool // whether feature value was specified in GODEBUG + Enable bool // whether feature should be enabled + Required bool // whether feature is mandatory and can not be disabled +} + +func processOptions() { + env := os.Getenv("GODEBUG") +field: + for env != "" { + field := "" + i := strings.IndexByte(env, ',') + if i < 0 { + field, env = env, "" + } else { + field, env = env[:i], env[i+1:] + } + if len(field) < 4 || field[:4] != "cpu." { + continue + } + i = strings.IndexByte(field, '=') + if i < 0 { + print("GODEBUG sys/cpu: no value specified for \"", field, "\"\n") + continue + } + key, value := field[4:i], field[i+1:] // e.g. "SSE2", "on" + + var enable bool + switch value { + case "on": + enable = true + case "off": + enable = false + default: + print("GODEBUG sys/cpu: value \"", value, "\" not supported for cpu option \"", key, "\"\n") + continue field + } + + if key == "all" { + for i := range options { + options[i].Specified = true + options[i].Enable = enable || options[i].Required + } + continue field + } + + for i := range options { + if options[i].Name == key { + options[i].Specified = true + options[i].Enable = enable + continue field + } + } + + print("GODEBUG sys/cpu: unknown cpu feature \"", key, "\"\n") + } + + for _, o := range options { + if !o.Specified { + continue + } + + if o.Enable && !*o.Feature { + print("GODEBUG sys/cpu: can not enable \"", o.Name, "\", missing CPU support\n") + continue + } + + if !o.Enable && o.Required { + print("GODEBUG sys/cpu: can not disable \"", o.Name, "\", required CPU feature\n") + continue + } + + *o.Feature = o.Enable + } +} diff --git a/mcp/dsx-exchange-mcp/vendor/golang.org/x/sys/cpu/cpu_aix.go b/mcp/dsx-exchange-mcp/vendor/golang.org/x/sys/cpu/cpu_aix.go new file mode 100644 index 0000000..9bf0c32 --- /dev/null +++ b/mcp/dsx-exchange-mcp/vendor/golang.org/x/sys/cpu/cpu_aix.go @@ -0,0 +1,33 @@ +// Copyright 2019 The Go Authors. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +//go:build aix + +package cpu + +const ( + // getsystemcfg constants + _SC_IMPL = 2 + _IMPL_POWER8 = 0x10000 + _IMPL_POWER9 = 0x20000 +) + +func archInit() { + impl := getsystemcfg(_SC_IMPL) + if impl&_IMPL_POWER8 != 0 { + PPC64.IsPOWER8 = true + } + if impl&_IMPL_POWER9 != 0 { + PPC64.IsPOWER8 = true + PPC64.IsPOWER9 = true + } + + Initialized = true +} + +func getsystemcfg(label int) (n uint64) { + r0, _ := callgetsystemcfg(label) + n = uint64(r0) + return +} diff --git a/mcp/dsx-exchange-mcp/vendor/golang.org/x/sys/cpu/cpu_arm.go b/mcp/dsx-exchange-mcp/vendor/golang.org/x/sys/cpu/cpu_arm.go new file mode 100644 index 0000000..301b752 --- /dev/null +++ b/mcp/dsx-exchange-mcp/vendor/golang.org/x/sys/cpu/cpu_arm.go @@ -0,0 +1,73 @@ +// Copyright 2018 The Go Authors. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +package cpu + +const cacheLineSize = 32 + +// HWCAP/HWCAP2 bits. +// These are specific to Linux. +const ( + hwcap_SWP = 1 << 0 + hwcap_HALF = 1 << 1 + hwcap_THUMB = 1 << 2 + hwcap_26BIT = 1 << 3 + hwcap_FAST_MULT = 1 << 4 + hwcap_FPA = 1 << 5 + hwcap_VFP = 1 << 6 + hwcap_EDSP = 1 << 7 + hwcap_JAVA = 1 << 8 + hwcap_IWMMXT = 1 << 9 + hwcap_CRUNCH = 1 << 10 + hwcap_THUMBEE = 1 << 11 + hwcap_NEON = 1 << 12 + hwcap_VFPv3 = 1 << 13 + hwcap_VFPv3D16 = 1 << 14 + hwcap_TLS = 1 << 15 + hwcap_VFPv4 = 1 << 16 + hwcap_IDIVA = 1 << 17 + hwcap_IDIVT = 1 << 18 + hwcap_VFPD32 = 1 << 19 + hwcap_LPAE = 1 << 20 + hwcap_EVTSTRM = 1 << 21 + + hwcap2_AES = 1 << 0 + hwcap2_PMULL = 1 << 1 + hwcap2_SHA1 = 1 << 2 + hwcap2_SHA2 = 1 << 3 + hwcap2_CRC32 = 1 << 4 +) + +func initOptions() { + options = []option{ + {Name: "pmull", Feature: &ARM.HasPMULL}, + {Name: "sha1", Feature: &ARM.HasSHA1}, + {Name: "sha2", Feature: &ARM.HasSHA2}, + {Name: "swp", Feature: &ARM.HasSWP}, + {Name: "thumb", Feature: &ARM.HasTHUMB}, + {Name: "thumbee", Feature: &ARM.HasTHUMBEE}, + {Name: "tls", Feature: &ARM.HasTLS}, + {Name: "vfp", Feature: &ARM.HasVFP}, + {Name: "vfpd32", Feature: &ARM.HasVFPD32}, + {Name: "vfpv3", Feature: &ARM.HasVFPv3}, + {Name: "vfpv3d16", Feature: &ARM.HasVFPv3D16}, + {Name: "vfpv4", Feature: &ARM.HasVFPv4}, + {Name: "half", Feature: &ARM.HasHALF}, + {Name: "26bit", Feature: &ARM.Has26BIT}, + {Name: "fastmul", Feature: &ARM.HasFASTMUL}, + {Name: "fpa", Feature: &ARM.HasFPA}, + {Name: "edsp", Feature: &ARM.HasEDSP}, + {Name: "java", Feature: &ARM.HasJAVA}, + {Name: "iwmmxt", Feature: &ARM.HasIWMMXT}, + {Name: "crunch", Feature: &ARM.HasCRUNCH}, + {Name: "neon", Feature: &ARM.HasNEON}, + {Name: "idivt", Feature: &ARM.HasIDIVT}, + {Name: "idiva", Feature: &ARM.HasIDIVA}, + {Name: "lpae", Feature: &ARM.HasLPAE}, + {Name: "evtstrm", Feature: &ARM.HasEVTSTRM}, + {Name: "aes", Feature: &ARM.HasAES}, + {Name: "crc32", Feature: &ARM.HasCRC32}, + } + +} diff --git a/mcp/dsx-exchange-mcp/vendor/golang.org/x/sys/cpu/cpu_arm64.go b/mcp/dsx-exchange-mcp/vendor/golang.org/x/sys/cpu/cpu_arm64.go new file mode 100644 index 0000000..af2aa99 --- /dev/null +++ b/mcp/dsx-exchange-mcp/vendor/golang.org/x/sys/cpu/cpu_arm64.go @@ -0,0 +1,194 @@ +// Copyright 2019 The Go Authors. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +package cpu + +import "runtime" + +// cacheLineSize is used to prevent false sharing of cache lines. +// We choose 128 because Apple Silicon, a.k.a. M1, has 128-byte cache line size. +// It doesn't cost much and is much more future-proof. +const cacheLineSize = 128 + +func initOptions() { + options = []option{ + {Name: "fp", Feature: &ARM64.HasFP}, + {Name: "asimd", Feature: &ARM64.HasASIMD}, + {Name: "evstrm", Feature: &ARM64.HasEVTSTRM}, + {Name: "aes", Feature: &ARM64.HasAES}, + {Name: "fphp", Feature: &ARM64.HasFPHP}, + {Name: "jscvt", Feature: &ARM64.HasJSCVT}, + {Name: "lrcpc", Feature: &ARM64.HasLRCPC}, + {Name: "pmull", Feature: &ARM64.HasPMULL}, + {Name: "sha1", Feature: &ARM64.HasSHA1}, + {Name: "sha2", Feature: &ARM64.HasSHA2}, + {Name: "sha3", Feature: &ARM64.HasSHA3}, + {Name: "sha512", Feature: &ARM64.HasSHA512}, + {Name: "sm3", Feature: &ARM64.HasSM3}, + {Name: "sm4", Feature: &ARM64.HasSM4}, + {Name: "sve", Feature: &ARM64.HasSVE}, + {Name: "sve2", Feature: &ARM64.HasSVE2}, + {Name: "crc32", Feature: &ARM64.HasCRC32}, + {Name: "atomics", Feature: &ARM64.HasATOMICS}, + {Name: "asimdhp", Feature: &ARM64.HasASIMDHP}, + {Name: "cpuid", Feature: &ARM64.HasCPUID}, + {Name: "asimrdm", Feature: &ARM64.HasASIMDRDM}, + {Name: "fcma", Feature: &ARM64.HasFCMA}, + {Name: "dcpop", Feature: &ARM64.HasDCPOP}, + {Name: "asimddp", Feature: &ARM64.HasASIMDDP}, + {Name: "asimdfhm", Feature: &ARM64.HasASIMDFHM}, + {Name: "dit", Feature: &ARM64.HasDIT}, + {Name: "i8mm", Feature: &ARM64.HasI8MM}, + } +} + +func archInit() { + switch runtime.GOOS { + case "freebsd": + readARM64Registers() + case "linux", "netbsd", "openbsd": + doinit() + default: + // Many platforms don't seem to allow reading these registers. + setMinimalFeatures() + } +} + +// setMinimalFeatures fakes the minimal ARM64 features expected by +// TestARM64minimalFeatures. +func setMinimalFeatures() { + ARM64.HasASIMD = true + ARM64.HasFP = true +} + +func readARM64Registers() { + Initialized = true + + parseARM64SystemRegisters(getisar0(), getisar1(), getpfr0()) +} + +func parseARM64SystemRegisters(isar0, isar1, pfr0 uint64) { + // ID_AA64ISAR0_EL1 + switch extractBits(isar0, 4, 7) { + case 1: + ARM64.HasAES = true + case 2: + ARM64.HasAES = true + ARM64.HasPMULL = true + } + + switch extractBits(isar0, 8, 11) { + case 1: + ARM64.HasSHA1 = true + } + + switch extractBits(isar0, 12, 15) { + case 1: + ARM64.HasSHA2 = true + case 2: + ARM64.HasSHA2 = true + ARM64.HasSHA512 = true + } + + switch extractBits(isar0, 16, 19) { + case 1: + ARM64.HasCRC32 = true + } + + switch extractBits(isar0, 20, 23) { + case 2: + ARM64.HasATOMICS = true + } + + switch extractBits(isar0, 28, 31) { + case 1: + ARM64.HasASIMDRDM = true + } + + switch extractBits(isar0, 32, 35) { + case 1: + ARM64.HasSHA3 = true + } + + switch extractBits(isar0, 36, 39) { + case 1: + ARM64.HasSM3 = true + } + + switch extractBits(isar0, 40, 43) { + case 1: + ARM64.HasSM4 = true + } + + switch extractBits(isar0, 44, 47) { + case 1: + ARM64.HasASIMDDP = true + } + + // ID_AA64ISAR1_EL1 + switch extractBits(isar1, 0, 3) { + case 1: + ARM64.HasDCPOP = true + } + + switch extractBits(isar1, 12, 15) { + case 1: + ARM64.HasJSCVT = true + } + + switch extractBits(isar1, 16, 19) { + case 1: + ARM64.HasFCMA = true + } + + switch extractBits(isar1, 20, 23) { + case 1: + ARM64.HasLRCPC = true + } + + switch extractBits(isar1, 52, 55) { + case 1: + ARM64.HasI8MM = true + } + + // ID_AA64PFR0_EL1 + switch extractBits(pfr0, 16, 19) { + case 0: + ARM64.HasFP = true + case 1: + ARM64.HasFP = true + ARM64.HasFPHP = true + } + + switch extractBits(pfr0, 20, 23) { + case 0: + ARM64.HasASIMD = true + case 1: + ARM64.HasASIMD = true + ARM64.HasASIMDHP = true + } + + switch extractBits(pfr0, 32, 35) { + case 1: + ARM64.HasSVE = true + + parseARM64SVERegister(getzfr0()) + } + + switch extractBits(pfr0, 48, 51) { + case 1: + ARM64.HasDIT = true + } +} + +func parseARM64SVERegister(zfr0 uint64) { + switch extractBits(zfr0, 0, 3) { + case 1: + ARM64.HasSVE2 = true + } +} + +func extractBits(data uint64, start, end uint) uint { + return (uint)(data>>start) & ((1 << (end - start + 1)) - 1) +} diff --git a/mcp/dsx-exchange-mcp/vendor/golang.org/x/sys/cpu/cpu_arm64.s b/mcp/dsx-exchange-mcp/vendor/golang.org/x/sys/cpu/cpu_arm64.s new file mode 100644 index 0000000..3b0450a --- /dev/null +++ b/mcp/dsx-exchange-mcp/vendor/golang.org/x/sys/cpu/cpu_arm64.s @@ -0,0 +1,35 @@ +// Copyright 2019 The Go Authors. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +//go:build gc + +#include "textflag.h" + +// func getisar0() uint64 +TEXT ·getisar0(SB),NOSPLIT,$0-8 + // get Instruction Set Attributes 0 into x0 + MRS ID_AA64ISAR0_EL1, R0 + MOVD R0, ret+0(FP) + RET + +// func getisar1() uint64 +TEXT ·getisar1(SB),NOSPLIT,$0-8 + // get Instruction Set Attributes 1 into x0 + MRS ID_AA64ISAR1_EL1, R0 + MOVD R0, ret+0(FP) + RET + +// func getpfr0() uint64 +TEXT ·getpfr0(SB),NOSPLIT,$0-8 + // get Processor Feature Register 0 into x0 + MRS ID_AA64PFR0_EL1, R0 + MOVD R0, ret+0(FP) + RET + +// func getzfr0() uint64 +TEXT ·getzfr0(SB),NOSPLIT,$0-8 + // get SVE Feature Register 0 into x0 + MRS ID_AA64ZFR0_EL1, R0 + MOVD R0, ret+0(FP) + RET diff --git a/mcp/dsx-exchange-mcp/vendor/golang.org/x/sys/cpu/cpu_darwin_x86.go b/mcp/dsx-exchange-mcp/vendor/golang.org/x/sys/cpu/cpu_darwin_x86.go new file mode 100644 index 0000000..b838cb9 --- /dev/null +++ b/mcp/dsx-exchange-mcp/vendor/golang.org/x/sys/cpu/cpu_darwin_x86.go @@ -0,0 +1,61 @@ +// Copyright 2024 The Go Authors. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +//go:build darwin && amd64 && gc + +package cpu + +// darwinSupportsAVX512 checks Darwin kernel for AVX512 support via sysctl +// call (see issue 43089). It also restricts AVX512 support for Darwin to +// kernel version 21.3.0 (MacOS 12.2.0) or later (see issue 49233). +// +// Background: +// Darwin implements a special mechanism to economize on thread state when +// AVX512 specific registers are not in use. This scheme minimizes state when +// preempting threads that haven't yet used any AVX512 instructions, but adds +// special requirements to check for AVX512 hardware support at runtime (e.g. +// via sysctl call or commpage inspection). See issue 43089 and link below for +// full background: +// https://github.com/apple-oss-distributions/xnu/blob/xnu-11215.1.10/osfmk/i386/fpu.c#L214-L240 +// +// Additionally, all versions of the Darwin kernel from 19.6.0 through 21.2.0 +// (corresponding to MacOS 10.15.6 - 12.1) have a bug that can cause corruption +// of the AVX512 mask registers (K0-K7) upon signal return. For this reason +// AVX512 is considered unsafe to use on Darwin for kernel versions prior to +// 21.3.0, where a fix has been confirmed. See issue 49233 for full background. +func darwinSupportsAVX512() bool { + return darwinSysctlEnabled([]byte("hw.optional.avx512f\x00")) && darwinKernelVersionCheck(21, 3, 0) +} + +// Ensure Darwin kernel version is at least major.minor.patch, avoiding dependencies +func darwinKernelVersionCheck(major, minor, patch int) bool { + var release [256]byte + err := darwinOSRelease(&release) + if err != nil { + return false + } + + var mmp [3]int + c := 0 +Loop: + for _, b := range release[:] { + switch { + case b >= '0' && b <= '9': + mmp[c] = 10*mmp[c] + int(b-'0') + case b == '.': + c++ + if c > 2 { + return false + } + case b == 0: + break Loop + default: + return false + } + } + if c != 2 { + return false + } + return mmp[0] > major || mmp[0] == major && (mmp[1] > minor || mmp[1] == minor && mmp[2] >= patch) +} diff --git a/mcp/dsx-exchange-mcp/vendor/golang.org/x/sys/cpu/cpu_gc_arm64.go b/mcp/dsx-exchange-mcp/vendor/golang.org/x/sys/cpu/cpu_gc_arm64.go new file mode 100644 index 0000000..6ac6e1e --- /dev/null +++ b/mcp/dsx-exchange-mcp/vendor/golang.org/x/sys/cpu/cpu_gc_arm64.go @@ -0,0 +1,12 @@ +// Copyright 2019 The Go Authors. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +//go:build gc + +package cpu + +func getisar0() uint64 +func getisar1() uint64 +func getpfr0() uint64 +func getzfr0() uint64 diff --git a/mcp/dsx-exchange-mcp/vendor/golang.org/x/sys/cpu/cpu_gc_s390x.go b/mcp/dsx-exchange-mcp/vendor/golang.org/x/sys/cpu/cpu_gc_s390x.go new file mode 100644 index 0000000..c8ae6dd --- /dev/null +++ b/mcp/dsx-exchange-mcp/vendor/golang.org/x/sys/cpu/cpu_gc_s390x.go @@ -0,0 +1,21 @@ +// Copyright 2019 The Go Authors. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +//go:build gc + +package cpu + +// haveAsmFunctions reports whether the other functions in this file can +// be safely called. +func haveAsmFunctions() bool { return true } + +// The following feature detection functions are defined in cpu_s390x.s. +// They are likely to be expensive to call so the results should be cached. +func stfle() facilityList +func kmQuery() queryResult +func kmcQuery() queryResult +func kmctrQuery() queryResult +func kmaQuery() queryResult +func kimdQuery() queryResult +func klmdQuery() queryResult diff --git a/mcp/dsx-exchange-mcp/vendor/golang.org/x/sys/cpu/cpu_gc_x86.go b/mcp/dsx-exchange-mcp/vendor/golang.org/x/sys/cpu/cpu_gc_x86.go new file mode 100644 index 0000000..32a4451 --- /dev/null +++ b/mcp/dsx-exchange-mcp/vendor/golang.org/x/sys/cpu/cpu_gc_x86.go @@ -0,0 +1,15 @@ +// Copyright 2018 The Go Authors. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +//go:build (386 || amd64 || amd64p32) && gc + +package cpu + +// cpuid is implemented in cpu_gc_x86.s for gc compiler +// and in cpu_gccgo.c for gccgo. +func cpuid(eaxArg, ecxArg uint32) (eax, ebx, ecx, edx uint32) + +// xgetbv with ecx = 0 is implemented in cpu_gc_x86.s for gc compiler +// and in cpu_gccgo.c for gccgo. +func xgetbv() (eax, edx uint32) diff --git a/mcp/dsx-exchange-mcp/vendor/golang.org/x/sys/cpu/cpu_gc_x86.s b/mcp/dsx-exchange-mcp/vendor/golang.org/x/sys/cpu/cpu_gc_x86.s new file mode 100644 index 0000000..ce208ce --- /dev/null +++ b/mcp/dsx-exchange-mcp/vendor/golang.org/x/sys/cpu/cpu_gc_x86.s @@ -0,0 +1,26 @@ +// Copyright 2018 The Go Authors. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +//go:build (386 || amd64 || amd64p32) && gc + +#include "textflag.h" + +// func cpuid(eaxArg, ecxArg uint32) (eax, ebx, ecx, edx uint32) +TEXT ·cpuid(SB), NOSPLIT, $0-24 + MOVL eaxArg+0(FP), AX + MOVL ecxArg+4(FP), CX + CPUID + MOVL AX, eax+8(FP) + MOVL BX, ebx+12(FP) + MOVL CX, ecx+16(FP) + MOVL DX, edx+20(FP) + RET + +// func xgetbv() (eax, edx uint32) +TEXT ·xgetbv(SB), NOSPLIT, $0-8 + MOVL $0, CX + XGETBV + MOVL AX, eax+0(FP) + MOVL DX, edx+4(FP) + RET diff --git a/mcp/dsx-exchange-mcp/vendor/golang.org/x/sys/cpu/cpu_gccgo_arm64.go b/mcp/dsx-exchange-mcp/vendor/golang.org/x/sys/cpu/cpu_gccgo_arm64.go new file mode 100644 index 0000000..7f19467 --- /dev/null +++ b/mcp/dsx-exchange-mcp/vendor/golang.org/x/sys/cpu/cpu_gccgo_arm64.go @@ -0,0 +1,11 @@ +// Copyright 2019 The Go Authors. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +//go:build gccgo + +package cpu + +func getisar0() uint64 { return 0 } +func getisar1() uint64 { return 0 } +func getpfr0() uint64 { return 0 } diff --git a/mcp/dsx-exchange-mcp/vendor/golang.org/x/sys/cpu/cpu_gccgo_s390x.go b/mcp/dsx-exchange-mcp/vendor/golang.org/x/sys/cpu/cpu_gccgo_s390x.go new file mode 100644 index 0000000..9526d2c --- /dev/null +++ b/mcp/dsx-exchange-mcp/vendor/golang.org/x/sys/cpu/cpu_gccgo_s390x.go @@ -0,0 +1,22 @@ +// Copyright 2019 The Go Authors. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +//go:build gccgo + +package cpu + +// haveAsmFunctions reports whether the other functions in this file can +// be safely called. +func haveAsmFunctions() bool { return false } + +// TODO(mundaym): the following feature detection functions are currently +// stubs. See https://golang.org/cl/162887 for how to fix this. +// They are likely to be expensive to call so the results should be cached. +func stfle() facilityList { panic("not implemented for gccgo") } +func kmQuery() queryResult { panic("not implemented for gccgo") } +func kmcQuery() queryResult { panic("not implemented for gccgo") } +func kmctrQuery() queryResult { panic("not implemented for gccgo") } +func kmaQuery() queryResult { panic("not implemented for gccgo") } +func kimdQuery() queryResult { panic("not implemented for gccgo") } +func klmdQuery() queryResult { panic("not implemented for gccgo") } diff --git a/mcp/dsx-exchange-mcp/vendor/golang.org/x/sys/cpu/cpu_gccgo_x86.c b/mcp/dsx-exchange-mcp/vendor/golang.org/x/sys/cpu/cpu_gccgo_x86.c new file mode 100644 index 0000000..3f73a05 --- /dev/null +++ b/mcp/dsx-exchange-mcp/vendor/golang.org/x/sys/cpu/cpu_gccgo_x86.c @@ -0,0 +1,37 @@ +// Copyright 2018 The Go Authors. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +//go:build (386 || amd64 || amd64p32) && gccgo + +#include +#include +#include + +// Need to wrap __get_cpuid_count because it's declared as static. +int +gccgoGetCpuidCount(uint32_t leaf, uint32_t subleaf, + uint32_t *eax, uint32_t *ebx, + uint32_t *ecx, uint32_t *edx) +{ + return __get_cpuid_count(leaf, subleaf, eax, ebx, ecx, edx); +} + +#pragma GCC diagnostic ignored "-Wunknown-pragmas" +#pragma GCC push_options +#pragma GCC target("xsave") +#pragma clang attribute push (__attribute__((target("xsave"))), apply_to=function) + +// xgetbv reads the contents of an XCR (Extended Control Register) +// specified in the ECX register into registers EDX:EAX. +// Currently, the only supported value for XCR is 0. +void +gccgoXgetbv(uint32_t *eax, uint32_t *edx) +{ + uint64_t v = _xgetbv(0); + *eax = v & 0xffffffff; + *edx = v >> 32; +} + +#pragma clang attribute pop +#pragma GCC pop_options diff --git a/mcp/dsx-exchange-mcp/vendor/golang.org/x/sys/cpu/cpu_gccgo_x86.go b/mcp/dsx-exchange-mcp/vendor/golang.org/x/sys/cpu/cpu_gccgo_x86.go new file mode 100644 index 0000000..170d21d --- /dev/null +++ b/mcp/dsx-exchange-mcp/vendor/golang.org/x/sys/cpu/cpu_gccgo_x86.go @@ -0,0 +1,25 @@ +// Copyright 2018 The Go Authors. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +//go:build (386 || amd64 || amd64p32) && gccgo + +package cpu + +//extern gccgoGetCpuidCount +func gccgoGetCpuidCount(eaxArg, ecxArg uint32, eax, ebx, ecx, edx *uint32) + +func cpuid(eaxArg, ecxArg uint32) (eax, ebx, ecx, edx uint32) { + var a, b, c, d uint32 + gccgoGetCpuidCount(eaxArg, ecxArg, &a, &b, &c, &d) + return a, b, c, d +} + +//extern gccgoXgetbv +func gccgoXgetbv(eax, edx *uint32) + +func xgetbv() (eax, edx uint32) { + var a, d uint32 + gccgoXgetbv(&a, &d) + return a, d +} diff --git a/mcp/dsx-exchange-mcp/vendor/golang.org/x/sys/cpu/cpu_linux.go b/mcp/dsx-exchange-mcp/vendor/golang.org/x/sys/cpu/cpu_linux.go new file mode 100644 index 0000000..743eb54 --- /dev/null +++ b/mcp/dsx-exchange-mcp/vendor/golang.org/x/sys/cpu/cpu_linux.go @@ -0,0 +1,15 @@ +// Copyright 2018 The Go Authors. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +//go:build !386 && !amd64 && !amd64p32 && !arm64 + +package cpu + +func archInit() { + if err := readHWCAP(); err != nil { + return + } + doinit() + Initialized = true +} diff --git a/mcp/dsx-exchange-mcp/vendor/golang.org/x/sys/cpu/cpu_linux_arm.go b/mcp/dsx-exchange-mcp/vendor/golang.org/x/sys/cpu/cpu_linux_arm.go new file mode 100644 index 0000000..2057006 --- /dev/null +++ b/mcp/dsx-exchange-mcp/vendor/golang.org/x/sys/cpu/cpu_linux_arm.go @@ -0,0 +1,39 @@ +// Copyright 2019 The Go Authors. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +package cpu + +func doinit() { + ARM.HasSWP = isSet(hwCap, hwcap_SWP) + ARM.HasHALF = isSet(hwCap, hwcap_HALF) + ARM.HasTHUMB = isSet(hwCap, hwcap_THUMB) + ARM.Has26BIT = isSet(hwCap, hwcap_26BIT) + ARM.HasFASTMUL = isSet(hwCap, hwcap_FAST_MULT) + ARM.HasFPA = isSet(hwCap, hwcap_FPA) + ARM.HasVFP = isSet(hwCap, hwcap_VFP) + ARM.HasEDSP = isSet(hwCap, hwcap_EDSP) + ARM.HasJAVA = isSet(hwCap, hwcap_JAVA) + ARM.HasIWMMXT = isSet(hwCap, hwcap_IWMMXT) + ARM.HasCRUNCH = isSet(hwCap, hwcap_CRUNCH) + ARM.HasTHUMBEE = isSet(hwCap, hwcap_THUMBEE) + ARM.HasNEON = isSet(hwCap, hwcap_NEON) + ARM.HasVFPv3 = isSet(hwCap, hwcap_VFPv3) + ARM.HasVFPv3D16 = isSet(hwCap, hwcap_VFPv3D16) + ARM.HasTLS = isSet(hwCap, hwcap_TLS) + ARM.HasVFPv4 = isSet(hwCap, hwcap_VFPv4) + ARM.HasIDIVA = isSet(hwCap, hwcap_IDIVA) + ARM.HasIDIVT = isSet(hwCap, hwcap_IDIVT) + ARM.HasVFPD32 = isSet(hwCap, hwcap_VFPD32) + ARM.HasLPAE = isSet(hwCap, hwcap_LPAE) + ARM.HasEVTSTRM = isSet(hwCap, hwcap_EVTSTRM) + ARM.HasAES = isSet(hwCap2, hwcap2_AES) + ARM.HasPMULL = isSet(hwCap2, hwcap2_PMULL) + ARM.HasSHA1 = isSet(hwCap2, hwcap2_SHA1) + ARM.HasSHA2 = isSet(hwCap2, hwcap2_SHA2) + ARM.HasCRC32 = isSet(hwCap2, hwcap2_CRC32) +} + +func isSet(hwc uint, value uint) bool { + return hwc&value != 0 +} diff --git a/mcp/dsx-exchange-mcp/vendor/golang.org/x/sys/cpu/cpu_linux_arm64.go b/mcp/dsx-exchange-mcp/vendor/golang.org/x/sys/cpu/cpu_linux_arm64.go new file mode 100644 index 0000000..f1caf0f --- /dev/null +++ b/mcp/dsx-exchange-mcp/vendor/golang.org/x/sys/cpu/cpu_linux_arm64.go @@ -0,0 +1,120 @@ +// Copyright 2018 The Go Authors. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +package cpu + +import ( + "strings" + "syscall" +) + +// HWCAP/HWCAP2 bits. These are exposed by Linux. +const ( + hwcap_FP = 1 << 0 + hwcap_ASIMD = 1 << 1 + hwcap_EVTSTRM = 1 << 2 + hwcap_AES = 1 << 3 + hwcap_PMULL = 1 << 4 + hwcap_SHA1 = 1 << 5 + hwcap_SHA2 = 1 << 6 + hwcap_CRC32 = 1 << 7 + hwcap_ATOMICS = 1 << 8 + hwcap_FPHP = 1 << 9 + hwcap_ASIMDHP = 1 << 10 + hwcap_CPUID = 1 << 11 + hwcap_ASIMDRDM = 1 << 12 + hwcap_JSCVT = 1 << 13 + hwcap_FCMA = 1 << 14 + hwcap_LRCPC = 1 << 15 + hwcap_DCPOP = 1 << 16 + hwcap_SHA3 = 1 << 17 + hwcap_SM3 = 1 << 18 + hwcap_SM4 = 1 << 19 + hwcap_ASIMDDP = 1 << 20 + hwcap_SHA512 = 1 << 21 + hwcap_SVE = 1 << 22 + hwcap_ASIMDFHM = 1 << 23 + hwcap_DIT = 1 << 24 + + hwcap2_SVE2 = 1 << 1 + hwcap2_I8MM = 1 << 13 +) + +// linuxKernelCanEmulateCPUID reports whether we're running +// on Linux 4.11+. Ideally we'd like to ask the question about +// whether the current kernel contains +// https://git.kernel.org/pub/scm/linux/kernel/git/torvalds/linux.git/commit/?id=77c97b4ee21290f5f083173d957843b615abbff2 +// but the version number will have to do. +func linuxKernelCanEmulateCPUID() bool { + var un syscall.Utsname + syscall.Uname(&un) + var sb strings.Builder + for _, b := range un.Release[:] { + if b == 0 { + break + } + sb.WriteByte(byte(b)) + } + major, minor, _, ok := parseRelease(sb.String()) + return ok && (major > 4 || major == 4 && minor >= 11) +} + +func doinit() { + if err := readHWCAP(); err != nil { + // We failed to read /proc/self/auxv. This can happen if the binary has + // been given extra capabilities(7) with /bin/setcap. + // + // When this happens, we have two options. If the Linux kernel is new + // enough (4.11+), we can read the arm64 registers directly which'll + // trap into the kernel and then return back to userspace. + // + // But on older kernels, such as Linux 4.4.180 as used on many Synology + // devices, calling readARM64Registers (specifically getisar0) will + // cause a SIGILL and we'll die. So for older kernels, parse /proc/cpuinfo + // instead. + // + // See golang/go#57336. + if linuxKernelCanEmulateCPUID() { + readARM64Registers() + } else { + readLinuxProcCPUInfo() + } + return + } + + // HWCAP feature bits + ARM64.HasFP = isSet(hwCap, hwcap_FP) + ARM64.HasASIMD = isSet(hwCap, hwcap_ASIMD) + ARM64.HasEVTSTRM = isSet(hwCap, hwcap_EVTSTRM) + ARM64.HasAES = isSet(hwCap, hwcap_AES) + ARM64.HasPMULL = isSet(hwCap, hwcap_PMULL) + ARM64.HasSHA1 = isSet(hwCap, hwcap_SHA1) + ARM64.HasSHA2 = isSet(hwCap, hwcap_SHA2) + ARM64.HasCRC32 = isSet(hwCap, hwcap_CRC32) + ARM64.HasATOMICS = isSet(hwCap, hwcap_ATOMICS) + ARM64.HasFPHP = isSet(hwCap, hwcap_FPHP) + ARM64.HasASIMDHP = isSet(hwCap, hwcap_ASIMDHP) + ARM64.HasCPUID = isSet(hwCap, hwcap_CPUID) + ARM64.HasASIMDRDM = isSet(hwCap, hwcap_ASIMDRDM) + ARM64.HasJSCVT = isSet(hwCap, hwcap_JSCVT) + ARM64.HasFCMA = isSet(hwCap, hwcap_FCMA) + ARM64.HasLRCPC = isSet(hwCap, hwcap_LRCPC) + ARM64.HasDCPOP = isSet(hwCap, hwcap_DCPOP) + ARM64.HasSHA3 = isSet(hwCap, hwcap_SHA3) + ARM64.HasSM3 = isSet(hwCap, hwcap_SM3) + ARM64.HasSM4 = isSet(hwCap, hwcap_SM4) + ARM64.HasASIMDDP = isSet(hwCap, hwcap_ASIMDDP) + ARM64.HasSHA512 = isSet(hwCap, hwcap_SHA512) + ARM64.HasSVE = isSet(hwCap, hwcap_SVE) + ARM64.HasASIMDFHM = isSet(hwCap, hwcap_ASIMDFHM) + ARM64.HasDIT = isSet(hwCap, hwcap_DIT) + + // HWCAP2 feature bits + ARM64.HasSVE2 = isSet(hwCap2, hwcap2_SVE2) + ARM64.HasI8MM = isSet(hwCap2, hwcap2_I8MM) +} + +func isSet(hwc uint, value uint) bool { + return hwc&value != 0 +} diff --git a/mcp/dsx-exchange-mcp/vendor/golang.org/x/sys/cpu/cpu_linux_loong64.go b/mcp/dsx-exchange-mcp/vendor/golang.org/x/sys/cpu/cpu_linux_loong64.go new file mode 100644 index 0000000..4f34114 --- /dev/null +++ b/mcp/dsx-exchange-mcp/vendor/golang.org/x/sys/cpu/cpu_linux_loong64.go @@ -0,0 +1,22 @@ +// Copyright 2025 The Go Authors. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +package cpu + +// HWCAP bits. These are exposed by the Linux kernel. +const ( + hwcap_LOONGARCH_LSX = 1 << 4 + hwcap_LOONGARCH_LASX = 1 << 5 +) + +func doinit() { + // TODO: Features that require kernel support like LSX and LASX can + // be detected here once needed in std library or by the compiler. + Loong64.HasLSX = hwcIsSet(hwCap, hwcap_LOONGARCH_LSX) + Loong64.HasLASX = hwcIsSet(hwCap, hwcap_LOONGARCH_LASX) +} + +func hwcIsSet(hwc uint, val uint) bool { + return hwc&val != 0 +} diff --git a/mcp/dsx-exchange-mcp/vendor/golang.org/x/sys/cpu/cpu_linux_mips64x.go b/mcp/dsx-exchange-mcp/vendor/golang.org/x/sys/cpu/cpu_linux_mips64x.go new file mode 100644 index 0000000..4686c1d --- /dev/null +++ b/mcp/dsx-exchange-mcp/vendor/golang.org/x/sys/cpu/cpu_linux_mips64x.go @@ -0,0 +1,22 @@ +// Copyright 2020 The Go Authors. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +//go:build linux && (mips64 || mips64le) + +package cpu + +// HWCAP bits. These are exposed by the Linux kernel 5.4. +const ( + // CPU features + hwcap_MIPS_MSA = 1 << 1 +) + +func doinit() { + // HWCAP feature bits + MIPS64X.HasMSA = isSet(hwCap, hwcap_MIPS_MSA) +} + +func isSet(hwc uint, value uint) bool { + return hwc&value != 0 +} diff --git a/mcp/dsx-exchange-mcp/vendor/golang.org/x/sys/cpu/cpu_linux_noinit.go b/mcp/dsx-exchange-mcp/vendor/golang.org/x/sys/cpu/cpu_linux_noinit.go new file mode 100644 index 0000000..a428dec --- /dev/null +++ b/mcp/dsx-exchange-mcp/vendor/golang.org/x/sys/cpu/cpu_linux_noinit.go @@ -0,0 +1,9 @@ +// Copyright 2019 The Go Authors. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +//go:build linux && !arm && !arm64 && !loong64 && !mips64 && !mips64le && !ppc64 && !ppc64le && !s390x && !riscv64 + +package cpu + +func doinit() {} diff --git a/mcp/dsx-exchange-mcp/vendor/golang.org/x/sys/cpu/cpu_linux_ppc64x.go b/mcp/dsx-exchange-mcp/vendor/golang.org/x/sys/cpu/cpu_linux_ppc64x.go new file mode 100644 index 0000000..197188e --- /dev/null +++ b/mcp/dsx-exchange-mcp/vendor/golang.org/x/sys/cpu/cpu_linux_ppc64x.go @@ -0,0 +1,30 @@ +// Copyright 2018 The Go Authors. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +//go:build linux && (ppc64 || ppc64le) + +package cpu + +// HWCAP/HWCAP2 bits. These are exposed by the kernel. +const ( + // ISA Level + _PPC_FEATURE2_ARCH_2_07 = 0x80000000 + _PPC_FEATURE2_ARCH_3_00 = 0x00800000 + + // CPU features + _PPC_FEATURE2_DARN = 0x00200000 + _PPC_FEATURE2_SCV = 0x00100000 +) + +func doinit() { + // HWCAP2 feature bits + PPC64.IsPOWER8 = isSet(hwCap2, _PPC_FEATURE2_ARCH_2_07) + PPC64.IsPOWER9 = isSet(hwCap2, _PPC_FEATURE2_ARCH_3_00) + PPC64.HasDARN = isSet(hwCap2, _PPC_FEATURE2_DARN) + PPC64.HasSCV = isSet(hwCap2, _PPC_FEATURE2_SCV) +} + +func isSet(hwc uint, value uint) bool { + return hwc&value != 0 +} diff --git a/mcp/dsx-exchange-mcp/vendor/golang.org/x/sys/cpu/cpu_linux_riscv64.go b/mcp/dsx-exchange-mcp/vendor/golang.org/x/sys/cpu/cpu_linux_riscv64.go new file mode 100644 index 0000000..ad74153 --- /dev/null +++ b/mcp/dsx-exchange-mcp/vendor/golang.org/x/sys/cpu/cpu_linux_riscv64.go @@ -0,0 +1,160 @@ +// Copyright 2024 The Go Authors. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +package cpu + +import ( + "syscall" + "unsafe" +) + +// RISC-V extension discovery code for Linux. The approach here is to first try the riscv_hwprobe +// syscall falling back to HWCAP to check for the C extension if riscv_hwprobe is not available. +// +// A note on detection of the Vector extension using HWCAP. +// +// Support for the Vector extension version 1.0 was added to the Linux kernel in release 6.5. +// Support for the riscv_hwprobe syscall was added in 6.4. It follows that if the riscv_hwprobe +// syscall is not available then neither is the Vector extension (which needs kernel support). +// The riscv_hwprobe syscall should then be all we need to detect the Vector extension. +// However, some RISC-V board manufacturers ship boards with an older kernel on top of which +// they have back-ported various versions of the Vector extension patches but not the riscv_hwprobe +// patches. These kernels advertise support for the Vector extension using HWCAP. Falling +// back to HWCAP to detect the Vector extension, if riscv_hwprobe is not available, or simply not +// bothering with riscv_hwprobe at all and just using HWCAP may then seem like an attractive option. +// +// Unfortunately, simply checking the 'V' bit in AT_HWCAP will not work as this bit is used by +// RISC-V board and cloud instance providers to mean different things. The Lichee Pi 4A board +// and the Scaleway RV1 cloud instances use the 'V' bit to advertise their support for the unratified +// 0.7.1 version of the Vector Specification. The Banana Pi BPI-F3 and the CanMV-K230 board use +// it to advertise support for 1.0 of the Vector extension. Versions 0.7.1 and 1.0 of the Vector +// extension are binary incompatible. HWCAP can then not be used in isolation to populate the +// HasV field as this field indicates that the underlying CPU is compatible with RVV 1.0. +// +// There is a way at runtime to distinguish between versions 0.7.1 and 1.0 of the Vector +// specification by issuing a RVV 1.0 vsetvli instruction and checking the vill bit of the vtype +// register. This check would allow us to safely detect version 1.0 of the Vector extension +// with HWCAP, if riscv_hwprobe were not available. However, the check cannot +// be added until the assembler supports the Vector instructions. +// +// Note the riscv_hwprobe syscall does not suffer from these ambiguities by design as all of the +// extensions it advertises support for are explicitly versioned. It's also worth noting that +// the riscv_hwprobe syscall is the only way to detect multi-letter RISC-V extensions, e.g., Zba. +// These cannot be detected using HWCAP and so riscv_hwprobe must be used to detect the majority +// of RISC-V extensions. +// +// Please see https://docs.kernel.org/arch/riscv/hwprobe.html for more information. + +// golang.org/x/sys/cpu is not allowed to depend on golang.org/x/sys/unix so we must +// reproduce the constants, types and functions needed to make the riscv_hwprobe syscall +// here. + +const ( + // Copied from golang.org/x/sys/unix/ztypes_linux_riscv64.go. + riscv_HWPROBE_KEY_IMA_EXT_0 = 0x4 + riscv_HWPROBE_IMA_C = 0x2 + riscv_HWPROBE_IMA_V = 0x4 + riscv_HWPROBE_EXT_ZBA = 0x8 + riscv_HWPROBE_EXT_ZBB = 0x10 + riscv_HWPROBE_EXT_ZBS = 0x20 + riscv_HWPROBE_EXT_ZVBB = 0x20000 + riscv_HWPROBE_EXT_ZVBC = 0x40000 + riscv_HWPROBE_EXT_ZVKB = 0x80000 + riscv_HWPROBE_EXT_ZVKG = 0x100000 + riscv_HWPROBE_EXT_ZVKNED = 0x200000 + riscv_HWPROBE_EXT_ZVKNHB = 0x800000 + riscv_HWPROBE_EXT_ZVKSED = 0x1000000 + riscv_HWPROBE_EXT_ZVKSH = 0x2000000 + riscv_HWPROBE_EXT_ZVKT = 0x4000000 + riscv_HWPROBE_KEY_CPUPERF_0 = 0x5 + riscv_HWPROBE_MISALIGNED_FAST = 0x3 + riscv_HWPROBE_MISALIGNED_MASK = 0x7 +) + +const ( + // sys_RISCV_HWPROBE is copied from golang.org/x/sys/unix/zsysnum_linux_riscv64.go. + sys_RISCV_HWPROBE = 258 +) + +// riscvHWProbePairs is copied from golang.org/x/sys/unix/ztypes_linux_riscv64.go. +type riscvHWProbePairs struct { + key int64 + value uint64 +} + +const ( + // CPU features + hwcap_RISCV_ISA_C = 1 << ('C' - 'A') +) + +func doinit() { + // A slice of key/value pair structures is passed to the RISCVHWProbe syscall. The key + // field should be initialised with one of the key constants defined above, e.g., + // RISCV_HWPROBE_KEY_IMA_EXT_0. The syscall will set the value field to the appropriate value. + // If the kernel does not recognise a key it will set the key field to -1 and the value field to 0. + + pairs := []riscvHWProbePairs{ + {riscv_HWPROBE_KEY_IMA_EXT_0, 0}, + {riscv_HWPROBE_KEY_CPUPERF_0, 0}, + } + + // This call only indicates that extensions are supported if they are implemented on all cores. + if riscvHWProbe(pairs, 0) { + if pairs[0].key != -1 { + v := uint(pairs[0].value) + RISCV64.HasC = isSet(v, riscv_HWPROBE_IMA_C) + RISCV64.HasV = isSet(v, riscv_HWPROBE_IMA_V) + RISCV64.HasZba = isSet(v, riscv_HWPROBE_EXT_ZBA) + RISCV64.HasZbb = isSet(v, riscv_HWPROBE_EXT_ZBB) + RISCV64.HasZbs = isSet(v, riscv_HWPROBE_EXT_ZBS) + RISCV64.HasZvbb = isSet(v, riscv_HWPROBE_EXT_ZVBB) + RISCV64.HasZvbc = isSet(v, riscv_HWPROBE_EXT_ZVBC) + RISCV64.HasZvkb = isSet(v, riscv_HWPROBE_EXT_ZVKB) + RISCV64.HasZvkg = isSet(v, riscv_HWPROBE_EXT_ZVKG) + RISCV64.HasZvkt = isSet(v, riscv_HWPROBE_EXT_ZVKT) + // Cryptography shorthand extensions + RISCV64.HasZvkn = isSet(v, riscv_HWPROBE_EXT_ZVKNED) && + isSet(v, riscv_HWPROBE_EXT_ZVKNHB) && RISCV64.HasZvkb && RISCV64.HasZvkt + RISCV64.HasZvknc = RISCV64.HasZvkn && RISCV64.HasZvbc + RISCV64.HasZvkng = RISCV64.HasZvkn && RISCV64.HasZvkg + RISCV64.HasZvks = isSet(v, riscv_HWPROBE_EXT_ZVKSED) && + isSet(v, riscv_HWPROBE_EXT_ZVKSH) && RISCV64.HasZvkb && RISCV64.HasZvkt + RISCV64.HasZvksc = RISCV64.HasZvks && RISCV64.HasZvbc + RISCV64.HasZvksg = RISCV64.HasZvks && RISCV64.HasZvkg + } + if pairs[1].key != -1 { + v := pairs[1].value & riscv_HWPROBE_MISALIGNED_MASK + RISCV64.HasFastMisaligned = v == riscv_HWPROBE_MISALIGNED_FAST + } + } + + // Let's double check with HWCAP if the C extension does not appear to be supported. + // This may happen if we're running on a kernel older than 6.4. + + if !RISCV64.HasC { + RISCV64.HasC = isSet(hwCap, hwcap_RISCV_ISA_C) + } +} + +func isSet(hwc uint, value uint) bool { + return hwc&value != 0 +} + +// riscvHWProbe is a simplified version of the generated wrapper function found in +// golang.org/x/sys/unix/zsyscall_linux_riscv64.go. We simplify it by removing the +// cpuCount and cpus parameters which we do not need. We always want to pass 0 for +// these parameters here so the kernel only reports the extensions that are present +// on all cores. +func riscvHWProbe(pairs []riscvHWProbePairs, flags uint) bool { + var _zero uintptr + var p0 unsafe.Pointer + if len(pairs) > 0 { + p0 = unsafe.Pointer(&pairs[0]) + } else { + p0 = unsafe.Pointer(&_zero) + } + + _, _, e1 := syscall.Syscall6(sys_RISCV_HWPROBE, uintptr(p0), uintptr(len(pairs)), uintptr(0), uintptr(0), uintptr(flags), 0) + return e1 == 0 +} diff --git a/mcp/dsx-exchange-mcp/vendor/golang.org/x/sys/cpu/cpu_linux_s390x.go b/mcp/dsx-exchange-mcp/vendor/golang.org/x/sys/cpu/cpu_linux_s390x.go new file mode 100644 index 0000000..1517ac6 --- /dev/null +++ b/mcp/dsx-exchange-mcp/vendor/golang.org/x/sys/cpu/cpu_linux_s390x.go @@ -0,0 +1,40 @@ +// Copyright 2019 The Go Authors. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +package cpu + +const ( + // bit mask values from /usr/include/bits/hwcap.h + hwcap_ZARCH = 2 + hwcap_STFLE = 4 + hwcap_MSA = 8 + hwcap_LDISP = 16 + hwcap_EIMM = 32 + hwcap_DFP = 64 + hwcap_ETF3EH = 256 + hwcap_VX = 2048 + hwcap_VXE = 8192 +) + +func initS390Xbase() { + // test HWCAP bit vector + has := func(featureMask uint) bool { + return hwCap&featureMask == featureMask + } + + // mandatory + S390X.HasZARCH = has(hwcap_ZARCH) + + // optional + S390X.HasSTFLE = has(hwcap_STFLE) + S390X.HasLDISP = has(hwcap_LDISP) + S390X.HasEIMM = has(hwcap_EIMM) + S390X.HasETF3EH = has(hwcap_ETF3EH) + S390X.HasDFP = has(hwcap_DFP) + S390X.HasMSA = has(hwcap_MSA) + S390X.HasVX = has(hwcap_VX) + if S390X.HasVX { + S390X.HasVXE = has(hwcap_VXE) + } +} diff --git a/mcp/dsx-exchange-mcp/vendor/golang.org/x/sys/cpu/cpu_loong64.go b/mcp/dsx-exchange-mcp/vendor/golang.org/x/sys/cpu/cpu_loong64.go new file mode 100644 index 0000000..45ecb29 --- /dev/null +++ b/mcp/dsx-exchange-mcp/vendor/golang.org/x/sys/cpu/cpu_loong64.go @@ -0,0 +1,50 @@ +// Copyright 2022 The Go Authors. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +//go:build loong64 + +package cpu + +const cacheLineSize = 64 + +// Bit fields for CPUCFG registers, Related reference documents: +// https://loongson.github.io/LoongArch-Documentation/LoongArch-Vol1-EN.html#_cpucfg +const ( + // CPUCFG1 bits + cpucfg1_CRC32 = 1 << 25 + + // CPUCFG2 bits + cpucfg2_LAM_BH = 1 << 27 + cpucfg2_LAMCAS = 1 << 28 +) + +func initOptions() { + options = []option{ + {Name: "lsx", Feature: &Loong64.HasLSX}, + {Name: "lasx", Feature: &Loong64.HasLASX}, + {Name: "crc32", Feature: &Loong64.HasCRC32}, + {Name: "lam_bh", Feature: &Loong64.HasLAM_BH}, + {Name: "lamcas", Feature: &Loong64.HasLAMCAS}, + } + + // The CPUCFG data on Loong64 only reflects the hardware capabilities, + // not the kernel support status, so features such as LSX and LASX that + // require kernel support cannot be obtained from the CPUCFG data. + // + // These features only require hardware capability support and do not + // require kernel specific support, so they can be obtained directly + // through CPUCFG + cfg1 := get_cpucfg(1) + cfg2 := get_cpucfg(2) + + Loong64.HasCRC32 = cfgIsSet(cfg1, cpucfg1_CRC32) + Loong64.HasLAMCAS = cfgIsSet(cfg2, cpucfg2_LAMCAS) + Loong64.HasLAM_BH = cfgIsSet(cfg2, cpucfg2_LAM_BH) +} + +func get_cpucfg(reg uint32) uint32 + +func cfgIsSet(cfg uint32, val uint32) bool { + return cfg&val != 0 +} diff --git a/mcp/dsx-exchange-mcp/vendor/golang.org/x/sys/cpu/cpu_loong64.s b/mcp/dsx-exchange-mcp/vendor/golang.org/x/sys/cpu/cpu_loong64.s new file mode 100644 index 0000000..71cbaf1 --- /dev/null +++ b/mcp/dsx-exchange-mcp/vendor/golang.org/x/sys/cpu/cpu_loong64.s @@ -0,0 +1,13 @@ +// Copyright 2025 The Go Authors. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +#include "textflag.h" + +// func get_cpucfg(reg uint32) uint32 +TEXT ·get_cpucfg(SB), NOSPLIT|NOFRAME, $0 + MOVW reg+0(FP), R5 + // CPUCFG R5, R4 = 0x00006ca4 + WORD $0x00006ca4 + MOVW R4, ret+8(FP) + RET diff --git a/mcp/dsx-exchange-mcp/vendor/golang.org/x/sys/cpu/cpu_mips64x.go b/mcp/dsx-exchange-mcp/vendor/golang.org/x/sys/cpu/cpu_mips64x.go new file mode 100644 index 0000000..fedb00c --- /dev/null +++ b/mcp/dsx-exchange-mcp/vendor/golang.org/x/sys/cpu/cpu_mips64x.go @@ -0,0 +1,15 @@ +// Copyright 2018 The Go Authors. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +//go:build mips64 || mips64le + +package cpu + +const cacheLineSize = 32 + +func initOptions() { + options = []option{ + {Name: "msa", Feature: &MIPS64X.HasMSA}, + } +} diff --git a/mcp/dsx-exchange-mcp/vendor/golang.org/x/sys/cpu/cpu_mipsx.go b/mcp/dsx-exchange-mcp/vendor/golang.org/x/sys/cpu/cpu_mipsx.go new file mode 100644 index 0000000..ffb4ec7 --- /dev/null +++ b/mcp/dsx-exchange-mcp/vendor/golang.org/x/sys/cpu/cpu_mipsx.go @@ -0,0 +1,11 @@ +// Copyright 2018 The Go Authors. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +//go:build mips || mipsle + +package cpu + +const cacheLineSize = 32 + +func initOptions() {} diff --git a/mcp/dsx-exchange-mcp/vendor/golang.org/x/sys/cpu/cpu_netbsd_arm64.go b/mcp/dsx-exchange-mcp/vendor/golang.org/x/sys/cpu/cpu_netbsd_arm64.go new file mode 100644 index 0000000..ebfb3fc --- /dev/null +++ b/mcp/dsx-exchange-mcp/vendor/golang.org/x/sys/cpu/cpu_netbsd_arm64.go @@ -0,0 +1,173 @@ +// Copyright 2020 The Go Authors. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +package cpu + +import ( + "syscall" + "unsafe" +) + +// Minimal copy of functionality from x/sys/unix so the cpu package can call +// sysctl without depending on x/sys/unix. + +const ( + _CTL_QUERY = -2 + + _SYSCTL_VERS_1 = 0x1000000 +) + +var _zero uintptr + +func sysctl(mib []int32, old *byte, oldlen *uintptr, new *byte, newlen uintptr) (err error) { + var _p0 unsafe.Pointer + if len(mib) > 0 { + _p0 = unsafe.Pointer(&mib[0]) + } else { + _p0 = unsafe.Pointer(&_zero) + } + _, _, errno := syscall.Syscall6( + syscall.SYS___SYSCTL, + uintptr(_p0), + uintptr(len(mib)), + uintptr(unsafe.Pointer(old)), + uintptr(unsafe.Pointer(oldlen)), + uintptr(unsafe.Pointer(new)), + uintptr(newlen)) + if errno != 0 { + return errno + } + return nil +} + +type sysctlNode struct { + Flags uint32 + Num int32 + Name [32]int8 + Ver uint32 + __rsvd uint32 + Un [16]byte + _sysctl_size [8]byte + _sysctl_func [8]byte + _sysctl_parent [8]byte + _sysctl_desc [8]byte +} + +func sysctlNodes(mib []int32) ([]sysctlNode, error) { + var olen uintptr + + // Get a list of all sysctl nodes below the given MIB by performing + // a sysctl for the given MIB with CTL_QUERY appended. + mib = append(mib, _CTL_QUERY) + qnode := sysctlNode{Flags: _SYSCTL_VERS_1} + qp := (*byte)(unsafe.Pointer(&qnode)) + sz := unsafe.Sizeof(qnode) + if err := sysctl(mib, nil, &olen, qp, sz); err != nil { + return nil, err + } + + // Now that we know the size, get the actual nodes. + nodes := make([]sysctlNode, olen/sz) + np := (*byte)(unsafe.Pointer(&nodes[0])) + if err := sysctl(mib, np, &olen, qp, sz); err != nil { + return nil, err + } + + return nodes, nil +} + +func nametomib(name string) ([]int32, error) { + // Split name into components. + var parts []string + last := 0 + for i := 0; i < len(name); i++ { + if name[i] == '.' { + parts = append(parts, name[last:i]) + last = i + 1 + } + } + parts = append(parts, name[last:]) + + mib := []int32{} + // Discover the nodes and construct the MIB OID. + for partno, part := range parts { + nodes, err := sysctlNodes(mib) + if err != nil { + return nil, err + } + for _, node := range nodes { + n := make([]byte, 0) + for i := range node.Name { + if node.Name[i] != 0 { + n = append(n, byte(node.Name[i])) + } + } + if string(n) == part { + mib = append(mib, int32(node.Num)) + break + } + } + if len(mib) != partno+1 { + return nil, err + } + } + + return mib, nil +} + +// aarch64SysctlCPUID is struct aarch64_sysctl_cpu_id from NetBSD's +type aarch64SysctlCPUID struct { + midr uint64 /* Main ID Register */ + revidr uint64 /* Revision ID Register */ + mpidr uint64 /* Multiprocessor Affinity Register */ + aa64dfr0 uint64 /* A64 Debug Feature Register 0 */ + aa64dfr1 uint64 /* A64 Debug Feature Register 1 */ + aa64isar0 uint64 /* A64 Instruction Set Attribute Register 0 */ + aa64isar1 uint64 /* A64 Instruction Set Attribute Register 1 */ + aa64mmfr0 uint64 /* A64 Memory Model Feature Register 0 */ + aa64mmfr1 uint64 /* A64 Memory Model Feature Register 1 */ + aa64mmfr2 uint64 /* A64 Memory Model Feature Register 2 */ + aa64pfr0 uint64 /* A64 Processor Feature Register 0 */ + aa64pfr1 uint64 /* A64 Processor Feature Register 1 */ + aa64zfr0 uint64 /* A64 SVE Feature ID Register 0 */ + mvfr0 uint32 /* Media and VFP Feature Register 0 */ + mvfr1 uint32 /* Media and VFP Feature Register 1 */ + mvfr2 uint32 /* Media and VFP Feature Register 2 */ + pad uint32 + clidr uint64 /* Cache Level ID Register */ + ctr uint64 /* Cache Type Register */ +} + +func sysctlCPUID(name string) (*aarch64SysctlCPUID, error) { + mib, err := nametomib(name) + if err != nil { + return nil, err + } + + out := aarch64SysctlCPUID{} + n := unsafe.Sizeof(out) + _, _, errno := syscall.Syscall6( + syscall.SYS___SYSCTL, + uintptr(unsafe.Pointer(&mib[0])), + uintptr(len(mib)), + uintptr(unsafe.Pointer(&out)), + uintptr(unsafe.Pointer(&n)), + uintptr(0), + uintptr(0)) + if errno != 0 { + return nil, errno + } + return &out, nil +} + +func doinit() { + cpuid, err := sysctlCPUID("machdep.cpu0.cpu_id") + if err != nil { + setMinimalFeatures() + return + } + parseARM64SystemRegisters(cpuid.aa64isar0, cpuid.aa64isar1, cpuid.aa64pfr0) + + Initialized = true +} diff --git a/mcp/dsx-exchange-mcp/vendor/golang.org/x/sys/cpu/cpu_openbsd_arm64.go b/mcp/dsx-exchange-mcp/vendor/golang.org/x/sys/cpu/cpu_openbsd_arm64.go new file mode 100644 index 0000000..85b64d5 --- /dev/null +++ b/mcp/dsx-exchange-mcp/vendor/golang.org/x/sys/cpu/cpu_openbsd_arm64.go @@ -0,0 +1,65 @@ +// Copyright 2022 The Go Authors. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +package cpu + +import ( + "syscall" + "unsafe" +) + +// Minimal copy of functionality from x/sys/unix so the cpu package can call +// sysctl without depending on x/sys/unix. + +const ( + // From OpenBSD's sys/sysctl.h. + _CTL_MACHDEP = 7 + + // From OpenBSD's machine/cpu.h. + _CPU_ID_AA64ISAR0 = 2 + _CPU_ID_AA64ISAR1 = 3 +) + +// Implemented in the runtime package (runtime/sys_openbsd3.go) +func syscall_syscall6(fn, a1, a2, a3, a4, a5, a6 uintptr) (r1, r2 uintptr, err syscall.Errno) + +//go:linkname syscall_syscall6 syscall.syscall6 + +func sysctl(mib []uint32, old *byte, oldlen *uintptr, new *byte, newlen uintptr) (err error) { + _, _, errno := syscall_syscall6(libc_sysctl_trampoline_addr, uintptr(unsafe.Pointer(&mib[0])), uintptr(len(mib)), uintptr(unsafe.Pointer(old)), uintptr(unsafe.Pointer(oldlen)), uintptr(unsafe.Pointer(new)), uintptr(newlen)) + if errno != 0 { + return errno + } + return nil +} + +var libc_sysctl_trampoline_addr uintptr + +//go:cgo_import_dynamic libc_sysctl sysctl "libc.so" + +func sysctlUint64(mib []uint32) (uint64, bool) { + var out uint64 + nout := unsafe.Sizeof(out) + if err := sysctl(mib, (*byte)(unsafe.Pointer(&out)), &nout, nil, 0); err != nil { + return 0, false + } + return out, true +} + +func doinit() { + setMinimalFeatures() + + // Get ID_AA64ISAR0 and ID_AA64ISAR1 from sysctl. + isar0, ok := sysctlUint64([]uint32{_CTL_MACHDEP, _CPU_ID_AA64ISAR0}) + if !ok { + return + } + isar1, ok := sysctlUint64([]uint32{_CTL_MACHDEP, _CPU_ID_AA64ISAR1}) + if !ok { + return + } + parseARM64SystemRegisters(isar0, isar1, 0) + + Initialized = true +} diff --git a/mcp/dsx-exchange-mcp/vendor/golang.org/x/sys/cpu/cpu_openbsd_arm64.s b/mcp/dsx-exchange-mcp/vendor/golang.org/x/sys/cpu/cpu_openbsd_arm64.s new file mode 100644 index 0000000..054ba05 --- /dev/null +++ b/mcp/dsx-exchange-mcp/vendor/golang.org/x/sys/cpu/cpu_openbsd_arm64.s @@ -0,0 +1,11 @@ +// Copyright 2022 The Go Authors. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +#include "textflag.h" + +TEXT libc_sysctl_trampoline<>(SB),NOSPLIT,$0-0 + JMP libc_sysctl(SB) + +GLOBL ·libc_sysctl_trampoline_addr(SB), RODATA, $8 +DATA ·libc_sysctl_trampoline_addr(SB)/8, $libc_sysctl_trampoline<>(SB) diff --git a/mcp/dsx-exchange-mcp/vendor/golang.org/x/sys/cpu/cpu_other_arm.go b/mcp/dsx-exchange-mcp/vendor/golang.org/x/sys/cpu/cpu_other_arm.go new file mode 100644 index 0000000..e9ecf2a --- /dev/null +++ b/mcp/dsx-exchange-mcp/vendor/golang.org/x/sys/cpu/cpu_other_arm.go @@ -0,0 +1,9 @@ +// Copyright 2020 The Go Authors. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +//go:build !linux && arm + +package cpu + +func archInit() {} diff --git a/mcp/dsx-exchange-mcp/vendor/golang.org/x/sys/cpu/cpu_other_arm64.go b/mcp/dsx-exchange-mcp/vendor/golang.org/x/sys/cpu/cpu_other_arm64.go new file mode 100644 index 0000000..5341e7f --- /dev/null +++ b/mcp/dsx-exchange-mcp/vendor/golang.org/x/sys/cpu/cpu_other_arm64.go @@ -0,0 +1,9 @@ +// Copyright 2019 The Go Authors. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +//go:build !linux && !netbsd && !openbsd && arm64 + +package cpu + +func doinit() {} diff --git a/mcp/dsx-exchange-mcp/vendor/golang.org/x/sys/cpu/cpu_other_mips64x.go b/mcp/dsx-exchange-mcp/vendor/golang.org/x/sys/cpu/cpu_other_mips64x.go new file mode 100644 index 0000000..5f8f241 --- /dev/null +++ b/mcp/dsx-exchange-mcp/vendor/golang.org/x/sys/cpu/cpu_other_mips64x.go @@ -0,0 +1,11 @@ +// Copyright 2020 The Go Authors. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +//go:build !linux && (mips64 || mips64le) + +package cpu + +func archInit() { + Initialized = true +} diff --git a/mcp/dsx-exchange-mcp/vendor/golang.org/x/sys/cpu/cpu_other_ppc64x.go b/mcp/dsx-exchange-mcp/vendor/golang.org/x/sys/cpu/cpu_other_ppc64x.go new file mode 100644 index 0000000..89608fb --- /dev/null +++ b/mcp/dsx-exchange-mcp/vendor/golang.org/x/sys/cpu/cpu_other_ppc64x.go @@ -0,0 +1,12 @@ +// Copyright 2022 The Go Authors. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +//go:build !aix && !linux && (ppc64 || ppc64le) + +package cpu + +func archInit() { + PPC64.IsPOWER8 = true + Initialized = true +} diff --git a/mcp/dsx-exchange-mcp/vendor/golang.org/x/sys/cpu/cpu_other_riscv64.go b/mcp/dsx-exchange-mcp/vendor/golang.org/x/sys/cpu/cpu_other_riscv64.go new file mode 100644 index 0000000..5ab8780 --- /dev/null +++ b/mcp/dsx-exchange-mcp/vendor/golang.org/x/sys/cpu/cpu_other_riscv64.go @@ -0,0 +1,11 @@ +// Copyright 2022 The Go Authors. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +//go:build !linux && riscv64 + +package cpu + +func archInit() { + Initialized = true +} diff --git a/mcp/dsx-exchange-mcp/vendor/golang.org/x/sys/cpu/cpu_other_x86.go b/mcp/dsx-exchange-mcp/vendor/golang.org/x/sys/cpu/cpu_other_x86.go new file mode 100644 index 0000000..a0fd7e2 --- /dev/null +++ b/mcp/dsx-exchange-mcp/vendor/golang.org/x/sys/cpu/cpu_other_x86.go @@ -0,0 +1,11 @@ +// Copyright 2024 The Go Authors. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +//go:build 386 || amd64p32 || (amd64 && (!darwin || !gc)) + +package cpu + +func darwinSupportsAVX512() bool { + panic("only implemented for gc && amd64 && darwin") +} diff --git a/mcp/dsx-exchange-mcp/vendor/golang.org/x/sys/cpu/cpu_ppc64x.go b/mcp/dsx-exchange-mcp/vendor/golang.org/x/sys/cpu/cpu_ppc64x.go new file mode 100644 index 0000000..c14f12b --- /dev/null +++ b/mcp/dsx-exchange-mcp/vendor/golang.org/x/sys/cpu/cpu_ppc64x.go @@ -0,0 +1,16 @@ +// Copyright 2020 The Go Authors. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +//go:build ppc64 || ppc64le + +package cpu + +const cacheLineSize = 128 + +func initOptions() { + options = []option{ + {Name: "darn", Feature: &PPC64.HasDARN}, + {Name: "scv", Feature: &PPC64.HasSCV}, + } +} diff --git a/mcp/dsx-exchange-mcp/vendor/golang.org/x/sys/cpu/cpu_riscv64.go b/mcp/dsx-exchange-mcp/vendor/golang.org/x/sys/cpu/cpu_riscv64.go new file mode 100644 index 0000000..0f617ae --- /dev/null +++ b/mcp/dsx-exchange-mcp/vendor/golang.org/x/sys/cpu/cpu_riscv64.go @@ -0,0 +1,32 @@ +// Copyright 2019 The Go Authors. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +//go:build riscv64 + +package cpu + +const cacheLineSize = 64 + +func initOptions() { + options = []option{ + {Name: "fastmisaligned", Feature: &RISCV64.HasFastMisaligned}, + {Name: "c", Feature: &RISCV64.HasC}, + {Name: "v", Feature: &RISCV64.HasV}, + {Name: "zba", Feature: &RISCV64.HasZba}, + {Name: "zbb", Feature: &RISCV64.HasZbb}, + {Name: "zbs", Feature: &RISCV64.HasZbs}, + // RISC-V Cryptography Extensions + {Name: "zvbb", Feature: &RISCV64.HasZvbb}, + {Name: "zvbc", Feature: &RISCV64.HasZvbc}, + {Name: "zvkb", Feature: &RISCV64.HasZvkb}, + {Name: "zvkg", Feature: &RISCV64.HasZvkg}, + {Name: "zvkt", Feature: &RISCV64.HasZvkt}, + {Name: "zvkn", Feature: &RISCV64.HasZvkn}, + {Name: "zvknc", Feature: &RISCV64.HasZvknc}, + {Name: "zvkng", Feature: &RISCV64.HasZvkng}, + {Name: "zvks", Feature: &RISCV64.HasZvks}, + {Name: "zvksc", Feature: &RISCV64.HasZvksc}, + {Name: "zvksg", Feature: &RISCV64.HasZvksg}, + } +} diff --git a/mcp/dsx-exchange-mcp/vendor/golang.org/x/sys/cpu/cpu_s390x.go b/mcp/dsx-exchange-mcp/vendor/golang.org/x/sys/cpu/cpu_s390x.go new file mode 100644 index 0000000..5881b88 --- /dev/null +++ b/mcp/dsx-exchange-mcp/vendor/golang.org/x/sys/cpu/cpu_s390x.go @@ -0,0 +1,172 @@ +// Copyright 2020 The Go Authors. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +package cpu + +const cacheLineSize = 256 + +func initOptions() { + options = []option{ + {Name: "zarch", Feature: &S390X.HasZARCH, Required: true}, + {Name: "stfle", Feature: &S390X.HasSTFLE, Required: true}, + {Name: "ldisp", Feature: &S390X.HasLDISP, Required: true}, + {Name: "eimm", Feature: &S390X.HasEIMM, Required: true}, + {Name: "dfp", Feature: &S390X.HasDFP}, + {Name: "etf3eh", Feature: &S390X.HasETF3EH}, + {Name: "msa", Feature: &S390X.HasMSA}, + {Name: "aes", Feature: &S390X.HasAES}, + {Name: "aescbc", Feature: &S390X.HasAESCBC}, + {Name: "aesctr", Feature: &S390X.HasAESCTR}, + {Name: "aesgcm", Feature: &S390X.HasAESGCM}, + {Name: "ghash", Feature: &S390X.HasGHASH}, + {Name: "sha1", Feature: &S390X.HasSHA1}, + {Name: "sha256", Feature: &S390X.HasSHA256}, + {Name: "sha3", Feature: &S390X.HasSHA3}, + {Name: "sha512", Feature: &S390X.HasSHA512}, + {Name: "vx", Feature: &S390X.HasVX}, + {Name: "vxe", Feature: &S390X.HasVXE}, + } +} + +// bitIsSet reports whether the bit at index is set. The bit index +// is in big endian order, so bit index 0 is the leftmost bit. +func bitIsSet(bits []uint64, index uint) bool { + return bits[index/64]&((1<<63)>>(index%64)) != 0 +} + +// facility is a bit index for the named facility. +type facility uint8 + +const ( + // mandatory facilities + zarch facility = 1 // z architecture mode is active + stflef facility = 7 // store-facility-list-extended + ldisp facility = 18 // long-displacement + eimm facility = 21 // extended-immediate + + // miscellaneous facilities + dfp facility = 42 // decimal-floating-point + etf3eh facility = 30 // extended-translation 3 enhancement + + // cryptography facilities + msa facility = 17 // message-security-assist + msa3 facility = 76 // message-security-assist extension 3 + msa4 facility = 77 // message-security-assist extension 4 + msa5 facility = 57 // message-security-assist extension 5 + msa8 facility = 146 // message-security-assist extension 8 + msa9 facility = 155 // message-security-assist extension 9 + + // vector facilities + vx facility = 129 // vector facility + vxe facility = 135 // vector-enhancements 1 + vxe2 facility = 148 // vector-enhancements 2 +) + +// facilityList contains the result of an STFLE call. +// Bits are numbered in big endian order so the +// leftmost bit (the MSB) is at index 0. +type facilityList struct { + bits [4]uint64 +} + +// Has reports whether the given facilities are present. +func (s *facilityList) Has(fs ...facility) bool { + if len(fs) == 0 { + panic("no facility bits provided") + } + for _, f := range fs { + if !bitIsSet(s.bits[:], uint(f)) { + return false + } + } + return true +} + +// function is the code for the named cryptographic function. +type function uint8 + +const ( + // KM{,A,C,CTR} function codes + aes128 function = 18 // AES-128 + aes192 function = 19 // AES-192 + aes256 function = 20 // AES-256 + + // K{I,L}MD function codes + sha1 function = 1 // SHA-1 + sha256 function = 2 // SHA-256 + sha512 function = 3 // SHA-512 + sha3_224 function = 32 // SHA3-224 + sha3_256 function = 33 // SHA3-256 + sha3_384 function = 34 // SHA3-384 + sha3_512 function = 35 // SHA3-512 + shake128 function = 36 // SHAKE-128 + shake256 function = 37 // SHAKE-256 + + // KLMD function codes + ghash function = 65 // GHASH +) + +// queryResult contains the result of a Query function +// call. Bits are numbered in big endian order so the +// leftmost bit (the MSB) is at index 0. +type queryResult struct { + bits [2]uint64 +} + +// Has reports whether the given functions are present. +func (q *queryResult) Has(fns ...function) bool { + if len(fns) == 0 { + panic("no function codes provided") + } + for _, f := range fns { + if !bitIsSet(q.bits[:], uint(f)) { + return false + } + } + return true +} + +func doinit() { + initS390Xbase() + + // We need implementations of stfle, km and so on + // to detect cryptographic features. + if !haveAsmFunctions() { + return + } + + // optional cryptographic functions + if S390X.HasMSA { + aes := []function{aes128, aes192, aes256} + + // cipher message + km, kmc := kmQuery(), kmcQuery() + S390X.HasAES = km.Has(aes...) + S390X.HasAESCBC = kmc.Has(aes...) + if S390X.HasSTFLE { + facilities := stfle() + if facilities.Has(msa4) { + kmctr := kmctrQuery() + S390X.HasAESCTR = kmctr.Has(aes...) + } + if facilities.Has(msa8) { + kma := kmaQuery() + S390X.HasAESGCM = kma.Has(aes...) + } + } + + // compute message digest + kimd := kimdQuery() // intermediate (no padding) + klmd := klmdQuery() // last (padding) + S390X.HasSHA1 = kimd.Has(sha1) && klmd.Has(sha1) + S390X.HasSHA256 = kimd.Has(sha256) && klmd.Has(sha256) + S390X.HasSHA512 = kimd.Has(sha512) && klmd.Has(sha512) + S390X.HasGHASH = kimd.Has(ghash) // KLMD-GHASH does not exist + sha3 := []function{ + sha3_224, sha3_256, sha3_384, sha3_512, + shake128, shake256, + } + S390X.HasSHA3 = kimd.Has(sha3...) && klmd.Has(sha3...) + } +} diff --git a/mcp/dsx-exchange-mcp/vendor/golang.org/x/sys/cpu/cpu_s390x.s b/mcp/dsx-exchange-mcp/vendor/golang.org/x/sys/cpu/cpu_s390x.s new file mode 100644 index 0000000..1fb4b70 --- /dev/null +++ b/mcp/dsx-exchange-mcp/vendor/golang.org/x/sys/cpu/cpu_s390x.s @@ -0,0 +1,57 @@ +// Copyright 2019 The Go Authors. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +//go:build gc + +#include "textflag.h" + +// func stfle() facilityList +TEXT ·stfle(SB), NOSPLIT|NOFRAME, $0-32 + MOVD $ret+0(FP), R1 + MOVD $3, R0 // last doubleword index to store + XC $32, (R1), (R1) // clear 4 doublewords (32 bytes) + WORD $0xb2b01000 // store facility list extended (STFLE) + RET + +// func kmQuery() queryResult +TEXT ·kmQuery(SB), NOSPLIT|NOFRAME, $0-16 + MOVD $0, R0 // set function code to 0 (KM-Query) + MOVD $ret+0(FP), R1 // address of 16-byte return value + WORD $0xB92E0024 // cipher message (KM) + RET + +// func kmcQuery() queryResult +TEXT ·kmcQuery(SB), NOSPLIT|NOFRAME, $0-16 + MOVD $0, R0 // set function code to 0 (KMC-Query) + MOVD $ret+0(FP), R1 // address of 16-byte return value + WORD $0xB92F0024 // cipher message with chaining (KMC) + RET + +// func kmctrQuery() queryResult +TEXT ·kmctrQuery(SB), NOSPLIT|NOFRAME, $0-16 + MOVD $0, R0 // set function code to 0 (KMCTR-Query) + MOVD $ret+0(FP), R1 // address of 16-byte return value + WORD $0xB92D4024 // cipher message with counter (KMCTR) + RET + +// func kmaQuery() queryResult +TEXT ·kmaQuery(SB), NOSPLIT|NOFRAME, $0-16 + MOVD $0, R0 // set function code to 0 (KMA-Query) + MOVD $ret+0(FP), R1 // address of 16-byte return value + WORD $0xb9296024 // cipher message with authentication (KMA) + RET + +// func kimdQuery() queryResult +TEXT ·kimdQuery(SB), NOSPLIT|NOFRAME, $0-16 + MOVD $0, R0 // set function code to 0 (KIMD-Query) + MOVD $ret+0(FP), R1 // address of 16-byte return value + WORD $0xB93E0024 // compute intermediate message digest (KIMD) + RET + +// func klmdQuery() queryResult +TEXT ·klmdQuery(SB), NOSPLIT|NOFRAME, $0-16 + MOVD $0, R0 // set function code to 0 (KLMD-Query) + MOVD $ret+0(FP), R1 // address of 16-byte return value + WORD $0xB93F0024 // compute last message digest (KLMD) + RET diff --git a/mcp/dsx-exchange-mcp/vendor/golang.org/x/sys/cpu/cpu_wasm.go b/mcp/dsx-exchange-mcp/vendor/golang.org/x/sys/cpu/cpu_wasm.go new file mode 100644 index 0000000..384787e --- /dev/null +++ b/mcp/dsx-exchange-mcp/vendor/golang.org/x/sys/cpu/cpu_wasm.go @@ -0,0 +1,17 @@ +// Copyright 2019 The Go Authors. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +//go:build wasm + +package cpu + +// We're compiling the cpu package for an unknown (software-abstracted) CPU. +// Make CacheLinePad an empty struct and hope that the usual struct alignment +// rules are good enough. + +const cacheLineSize = 0 + +func initOptions() {} + +func archInit() {} diff --git a/mcp/dsx-exchange-mcp/vendor/golang.org/x/sys/cpu/cpu_x86.go b/mcp/dsx-exchange-mcp/vendor/golang.org/x/sys/cpu/cpu_x86.go new file mode 100644 index 0000000..f5723d4 --- /dev/null +++ b/mcp/dsx-exchange-mcp/vendor/golang.org/x/sys/cpu/cpu_x86.go @@ -0,0 +1,236 @@ +// Copyright 2018 The Go Authors. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +//go:build 386 || amd64 || amd64p32 + +package cpu + +import "runtime" + +const cacheLineSize = 64 + +func initOptions() { + options = []option{ + {Name: "adx", Feature: &X86.HasADX}, + {Name: "aes", Feature: &X86.HasAES}, + {Name: "avx", Feature: &X86.HasAVX}, + {Name: "avx2", Feature: &X86.HasAVX2}, + {Name: "avx512", Feature: &X86.HasAVX512}, + {Name: "avx512f", Feature: &X86.HasAVX512F}, + {Name: "avx512cd", Feature: &X86.HasAVX512CD}, + {Name: "avx512er", Feature: &X86.HasAVX512ER}, + {Name: "avx512pf", Feature: &X86.HasAVX512PF}, + {Name: "avx512vl", Feature: &X86.HasAVX512VL}, + {Name: "avx512bw", Feature: &X86.HasAVX512BW}, + {Name: "avx512dq", Feature: &X86.HasAVX512DQ}, + {Name: "avx512ifma", Feature: &X86.HasAVX512IFMA}, + {Name: "avx512vbmi", Feature: &X86.HasAVX512VBMI}, + {Name: "avx512vnniw", Feature: &X86.HasAVX5124VNNIW}, + {Name: "avx5124fmaps", Feature: &X86.HasAVX5124FMAPS}, + {Name: "avx512vpopcntdq", Feature: &X86.HasAVX512VPOPCNTDQ}, + {Name: "avx512vpclmulqdq", Feature: &X86.HasAVX512VPCLMULQDQ}, + {Name: "avx512vnni", Feature: &X86.HasAVX512VNNI}, + {Name: "avx512gfni", Feature: &X86.HasAVX512GFNI}, + {Name: "avx512vaes", Feature: &X86.HasAVX512VAES}, + {Name: "avx512vbmi2", Feature: &X86.HasAVX512VBMI2}, + {Name: "avx512bitalg", Feature: &X86.HasAVX512BITALG}, + {Name: "avx512bf16", Feature: &X86.HasAVX512BF16}, + {Name: "amxtile", Feature: &X86.HasAMXTile}, + {Name: "amxint8", Feature: &X86.HasAMXInt8}, + {Name: "amxbf16", Feature: &X86.HasAMXBF16}, + {Name: "bmi1", Feature: &X86.HasBMI1}, + {Name: "bmi2", Feature: &X86.HasBMI2}, + {Name: "cx16", Feature: &X86.HasCX16}, + {Name: "erms", Feature: &X86.HasERMS}, + {Name: "fma", Feature: &X86.HasFMA}, + {Name: "osxsave", Feature: &X86.HasOSXSAVE}, + {Name: "pclmulqdq", Feature: &X86.HasPCLMULQDQ}, + {Name: "popcnt", Feature: &X86.HasPOPCNT}, + {Name: "rdrand", Feature: &X86.HasRDRAND}, + {Name: "rdseed", Feature: &X86.HasRDSEED}, + {Name: "sse3", Feature: &X86.HasSSE3}, + {Name: "sse41", Feature: &X86.HasSSE41}, + {Name: "sse42", Feature: &X86.HasSSE42}, + {Name: "ssse3", Feature: &X86.HasSSSE3}, + {Name: "avxifma", Feature: &X86.HasAVXIFMA}, + {Name: "avxvnni", Feature: &X86.HasAVXVNNI}, + {Name: "avxvnniint8", Feature: &X86.HasAVXVNNIInt8}, + + // These capabilities should always be enabled on amd64: + {Name: "sse2", Feature: &X86.HasSSE2, Required: runtime.GOARCH == "amd64"}, + } +} + +func archInit() { + + // From internal/cpu + const ( + // eax bits + cpuid_AVXVNNI = 1 << 4 + + // ecx bits + cpuid_SSE3 = 1 << 0 + cpuid_PCLMULQDQ = 1 << 1 + cpuid_AVX512VBMI = 1 << 1 + cpuid_AVX512VBMI2 = 1 << 6 + cpuid_SSSE3 = 1 << 9 + cpuid_AVX512GFNI = 1 << 8 + cpuid_AVX512VAES = 1 << 9 + cpuid_AVX512VNNI = 1 << 11 + cpuid_AVX512BITALG = 1 << 12 + cpuid_FMA = 1 << 12 + cpuid_AVX512VPOPCNTDQ = 1 << 14 + cpuid_SSE41 = 1 << 19 + cpuid_SSE42 = 1 << 20 + cpuid_POPCNT = 1 << 23 + cpuid_AES = 1 << 25 + cpuid_OSXSAVE = 1 << 27 + cpuid_AVX = 1 << 28 + + // "Extended Feature Flag" bits returned in EBX for CPUID EAX=0x7 ECX=0x0 + cpuid_BMI1 = 1 << 3 + cpuid_AVX2 = 1 << 5 + cpuid_BMI2 = 1 << 8 + cpuid_ERMS = 1 << 9 + cpuid_AVX512F = 1 << 16 + cpuid_AVX512DQ = 1 << 17 + cpuid_ADX = 1 << 19 + cpuid_AVX512CD = 1 << 28 + cpuid_SHA = 1 << 29 + cpuid_AVX512BW = 1 << 30 + cpuid_AVX512VL = 1 << 31 + + // "Extended Feature Flag" bits returned in ECX for CPUID EAX=0x7 ECX=0x0 + cpuid_AVX512_VBMI = 1 << 1 + cpuid_AVX512_VBMI2 = 1 << 6 + cpuid_GFNI = 1 << 8 + cpuid_AVX512VPCLMULQDQ = 1 << 10 + cpuid_AVX512_BITALG = 1 << 12 + + // edx bits + cpuid_FSRM = 1 << 4 + // edx bits for CPUID 0x80000001 + cpuid_RDTSCP = 1 << 27 + ) + // Additional constants not in internal/cpu + const ( + // eax=1: edx + cpuid_SSE2 = 1 << 26 + // eax=1: ecx + cpuid_CX16 = 1 << 13 + cpuid_RDRAND = 1 << 30 + // eax=7,ecx=0: ebx + cpuid_RDSEED = 1 << 18 + cpuid_AVX512IFMA = 1 << 21 + cpuid_AVX512PF = 1 << 26 + cpuid_AVX512ER = 1 << 27 + // eax=7,ecx=0: edx + cpuid_AVX5124VNNIW = 1 << 2 + cpuid_AVX5124FMAPS = 1 << 3 + cpuid_AMXBF16 = 1 << 22 + cpuid_AMXTile = 1 << 24 + cpuid_AMXInt8 = 1 << 25 + // eax=7,ecx=1: eax + cpuid_AVX512BF16 = 1 << 5 + cpuid_AVXIFMA = 1 << 23 + // eax=7,ecx=1: edx + cpuid_AVXVNNIInt8 = 1 << 4 + ) + + Initialized = true + + maxID, _, _, _ := cpuid(0, 0) + + if maxID < 1 { + return + } + + _, _, ecx1, edx1 := cpuid(1, 0) + X86.HasSSE2 = isSet(edx1, cpuid_SSE2) + + X86.HasSSE3 = isSet(ecx1, cpuid_SSE3) + X86.HasPCLMULQDQ = isSet(ecx1, cpuid_PCLMULQDQ) + X86.HasSSSE3 = isSet(ecx1, cpuid_SSSE3) + X86.HasFMA = isSet(ecx1, cpuid_FMA) + X86.HasCX16 = isSet(ecx1, cpuid_CX16) + X86.HasSSE41 = isSet(ecx1, cpuid_SSE41) + X86.HasSSE42 = isSet(ecx1, cpuid_SSE42) + X86.HasPOPCNT = isSet(ecx1, cpuid_POPCNT) + X86.HasAES = isSet(ecx1, cpuid_AES) + X86.HasOSXSAVE = isSet(ecx1, cpuid_OSXSAVE) + X86.HasRDRAND = isSet(ecx1, cpuid_RDRAND) + + var osSupportsAVX, osSupportsAVX512 bool + // For XGETBV, OSXSAVE bit is required and sufficient. + if X86.HasOSXSAVE { + eax, _ := xgetbv() + // Check if XMM and YMM registers have OS support. + osSupportsAVX = isSet(eax, 1<<1) && isSet(eax, 1<<2) + + if runtime.GOOS == "darwin" { + // Darwin requires special AVX512 checks, see cpu_darwin_x86.go + osSupportsAVX512 = osSupportsAVX && darwinSupportsAVX512() + } else { + // Check if OPMASK and ZMM registers have OS support. + osSupportsAVX512 = osSupportsAVX && isSet(eax, 1<<5) && isSet(eax, 1<<6) && isSet(eax, 1<<7) + } + } + + X86.HasAVX = isSet(ecx1, cpuid_AVX) && osSupportsAVX + + if maxID < 7 { + return + } + + eax7, ebx7, ecx7, edx7 := cpuid(7, 0) + X86.HasBMI1 = isSet(ebx7, cpuid_BMI1) + X86.HasAVX2 = isSet(ebx7, cpuid_AVX2) && osSupportsAVX + X86.HasBMI2 = isSet(ebx7, cpuid_BMI2) + X86.HasERMS = isSet(ebx7, cpuid_ERMS) + X86.HasRDSEED = isSet(ebx7, cpuid_RDSEED) + X86.HasADX = isSet(ebx7, cpuid_ADX) + + X86.HasAVX512 = isSet(ebx7, cpuid_AVX512F) && osSupportsAVX512 // Because avx-512 foundation is the core required extension + if X86.HasAVX512 { + X86.HasAVX512F = true + X86.HasAVX512CD = isSet(ebx7, cpuid_AVX512CD) + X86.HasAVX512ER = isSet(ebx7, cpuid_AVX512ER) + X86.HasAVX512PF = isSet(ebx7, cpuid_AVX512PF) + X86.HasAVX512VL = isSet(ebx7, cpuid_AVX512VL) + X86.HasAVX512BW = isSet(ebx7, cpuid_AVX512BW) + X86.HasAVX512DQ = isSet(ebx7, cpuid_AVX512DQ) + X86.HasAVX512IFMA = isSet(ebx7, cpuid_AVX512IFMA) + X86.HasAVX512VBMI = isSet(ecx7, cpuid_AVX512_VBMI) + X86.HasAVX5124VNNIW = isSet(edx7, cpuid_AVX5124VNNIW) + X86.HasAVX5124FMAPS = isSet(edx7, cpuid_AVX5124FMAPS) + X86.HasAVX512VPOPCNTDQ = isSet(ecx7, cpuid_AVX512VPOPCNTDQ) + X86.HasAVX512VPCLMULQDQ = isSet(ecx7, cpuid_AVX512VPCLMULQDQ) + X86.HasAVX512VNNI = isSet(ecx7, cpuid_AVX512VNNI) + X86.HasAVX512GFNI = isSet(ecx7, cpuid_AVX512GFNI) + X86.HasAVX512VAES = isSet(ecx7, cpuid_AVX512VAES) + X86.HasAVX512VBMI2 = isSet(ecx7, cpuid_AVX512VBMI2) + X86.HasAVX512BITALG = isSet(ecx7, cpuid_AVX512BITALG) + } + + X86.HasAMXTile = isSet(edx7, cpuid_AMXTile) + X86.HasAMXInt8 = isSet(edx7, cpuid_AMXInt8) + X86.HasAMXBF16 = isSet(edx7, cpuid_AMXBF16) + + // These features depend on the second level of extended features. + if eax7 >= 1 { + eax71, _, _, edx71 := cpuid(7, 1) + if X86.HasAVX512 { + X86.HasAVX512BF16 = isSet(eax71, cpuid_AVX512BF16) + } + if X86.HasAVX { + X86.HasAVXIFMA = isSet(eax71, cpuid_AVXIFMA) + X86.HasAVXVNNI = isSet(eax71, cpuid_AVXVNNI) + X86.HasAVXVNNIInt8 = isSet(edx71, cpuid_AVXVNNIInt8) + } + } +} + +func isSet(hwc uint32, value uint32) bool { + return hwc&value != 0 +} diff --git a/mcp/dsx-exchange-mcp/vendor/golang.org/x/sys/cpu/cpu_zos.go b/mcp/dsx-exchange-mcp/vendor/golang.org/x/sys/cpu/cpu_zos.go new file mode 100644 index 0000000..5f54683 --- /dev/null +++ b/mcp/dsx-exchange-mcp/vendor/golang.org/x/sys/cpu/cpu_zos.go @@ -0,0 +1,10 @@ +// Copyright 2020 The Go Authors. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +package cpu + +func archInit() { + doinit() + Initialized = true +} diff --git a/mcp/dsx-exchange-mcp/vendor/golang.org/x/sys/cpu/cpu_zos_s390x.go b/mcp/dsx-exchange-mcp/vendor/golang.org/x/sys/cpu/cpu_zos_s390x.go new file mode 100644 index 0000000..ccb1b70 --- /dev/null +++ b/mcp/dsx-exchange-mcp/vendor/golang.org/x/sys/cpu/cpu_zos_s390x.go @@ -0,0 +1,25 @@ +// Copyright 2020 The Go Authors. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +package cpu + +func initS390Xbase() { + // get the facilities list + facilities := stfle() + + // mandatory + S390X.HasZARCH = facilities.Has(zarch) + S390X.HasSTFLE = facilities.Has(stflef) + S390X.HasLDISP = facilities.Has(ldisp) + S390X.HasEIMM = facilities.Has(eimm) + + // optional + S390X.HasETF3EH = facilities.Has(etf3eh) + S390X.HasDFP = facilities.Has(dfp) + S390X.HasMSA = facilities.Has(msa) + S390X.HasVX = facilities.Has(vx) + if S390X.HasVX { + S390X.HasVXE = facilities.Has(vxe) + } +} diff --git a/mcp/dsx-exchange-mcp/vendor/golang.org/x/sys/cpu/endian_big.go b/mcp/dsx-exchange-mcp/vendor/golang.org/x/sys/cpu/endian_big.go new file mode 100644 index 0000000..7fe04b0 --- /dev/null +++ b/mcp/dsx-exchange-mcp/vendor/golang.org/x/sys/cpu/endian_big.go @@ -0,0 +1,10 @@ +// Copyright 2023 The Go Authors. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +//go:build armbe || arm64be || m68k || mips || mips64 || mips64p32 || ppc || ppc64 || s390 || s390x || shbe || sparc || sparc64 + +package cpu + +// IsBigEndian records whether the GOARCH's byte order is big endian. +const IsBigEndian = true diff --git a/mcp/dsx-exchange-mcp/vendor/golang.org/x/sys/cpu/endian_little.go b/mcp/dsx-exchange-mcp/vendor/golang.org/x/sys/cpu/endian_little.go new file mode 100644 index 0000000..48eccc4 --- /dev/null +++ b/mcp/dsx-exchange-mcp/vendor/golang.org/x/sys/cpu/endian_little.go @@ -0,0 +1,10 @@ +// Copyright 2023 The Go Authors. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +//go:build 386 || amd64 || amd64p32 || alpha || arm || arm64 || loong64 || mipsle || mips64le || mips64p32le || nios2 || ppc64le || riscv || riscv64 || sh || wasm + +package cpu + +// IsBigEndian records whether the GOARCH's byte order is big endian. +const IsBigEndian = false diff --git a/mcp/dsx-exchange-mcp/vendor/golang.org/x/sys/cpu/hwcap_linux.go b/mcp/dsx-exchange-mcp/vendor/golang.org/x/sys/cpu/hwcap_linux.go new file mode 100644 index 0000000..34e49f9 --- /dev/null +++ b/mcp/dsx-exchange-mcp/vendor/golang.org/x/sys/cpu/hwcap_linux.go @@ -0,0 +1,71 @@ +// Copyright 2019 The Go Authors. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +package cpu + +import ( + "os" +) + +const ( + _AT_HWCAP = 16 + _AT_HWCAP2 = 26 + + procAuxv = "/proc/self/auxv" + + uintSize = int(32 << (^uint(0) >> 63)) +) + +// For those platforms don't have a 'cpuid' equivalent we use HWCAP/HWCAP2 +// These are initialized in cpu_$GOARCH.go +// and should not be changed after they are initialized. +var hwCap uint +var hwCap2 uint + +func readHWCAP() error { + // For Go 1.21+, get auxv from the Go runtime. + if a := getAuxv(); len(a) > 0 { + for len(a) >= 2 { + tag, val := a[0], uint(a[1]) + a = a[2:] + switch tag { + case _AT_HWCAP: + hwCap = val + case _AT_HWCAP2: + hwCap2 = val + } + } + return nil + } + + buf, err := os.ReadFile(procAuxv) + if err != nil { + // e.g. on android /proc/self/auxv is not accessible, so silently + // ignore the error and leave Initialized = false. On some + // architectures (e.g. arm64) doinit() implements a fallback + // readout and will set Initialized = true again. + return err + } + bo := hostByteOrder() + for len(buf) >= 2*(uintSize/8) { + var tag, val uint + switch uintSize { + case 32: + tag = uint(bo.Uint32(buf[0:])) + val = uint(bo.Uint32(buf[4:])) + buf = buf[8:] + case 64: + tag = uint(bo.Uint64(buf[0:])) + val = uint(bo.Uint64(buf[8:])) + buf = buf[16:] + } + switch tag { + case _AT_HWCAP: + hwCap = val + case _AT_HWCAP2: + hwCap2 = val + } + } + return nil +} diff --git a/mcp/dsx-exchange-mcp/vendor/golang.org/x/sys/cpu/parse.go b/mcp/dsx-exchange-mcp/vendor/golang.org/x/sys/cpu/parse.go new file mode 100644 index 0000000..56a7e1a --- /dev/null +++ b/mcp/dsx-exchange-mcp/vendor/golang.org/x/sys/cpu/parse.go @@ -0,0 +1,43 @@ +// Copyright 2022 The Go Authors. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +package cpu + +import "strconv" + +// parseRelease parses a dot-separated version number. It follows the semver +// syntax, but allows the minor and patch versions to be elided. +// +// This is a copy of the Go runtime's parseRelease from +// https://golang.org/cl/209597. +func parseRelease(rel string) (major, minor, patch int, ok bool) { + // Strip anything after a dash or plus. + for i := range len(rel) { + if rel[i] == '-' || rel[i] == '+' { + rel = rel[:i] + break + } + } + + next := func() (int, bool) { + for i := range len(rel) { + if rel[i] == '.' { + ver, err := strconv.Atoi(rel[:i]) + rel = rel[i+1:] + return ver, err == nil + } + } + ver, err := strconv.Atoi(rel) + rel = "" + return ver, err == nil + } + if major, ok = next(); !ok || rel == "" { + return + } + if minor, ok = next(); !ok || rel == "" { + return + } + patch, ok = next() + return +} diff --git a/mcp/dsx-exchange-mcp/vendor/golang.org/x/sys/cpu/proc_cpuinfo_linux.go b/mcp/dsx-exchange-mcp/vendor/golang.org/x/sys/cpu/proc_cpuinfo_linux.go new file mode 100644 index 0000000..4cd64c7 --- /dev/null +++ b/mcp/dsx-exchange-mcp/vendor/golang.org/x/sys/cpu/proc_cpuinfo_linux.go @@ -0,0 +1,53 @@ +// Copyright 2022 The Go Authors. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +//go:build linux && arm64 + +package cpu + +import ( + "errors" + "io" + "os" + "strings" +) + +func readLinuxProcCPUInfo() error { + f, err := os.Open("/proc/cpuinfo") + if err != nil { + return err + } + defer f.Close() + + var buf [1 << 10]byte // enough for first CPU + n, err := io.ReadFull(f, buf[:]) + if err != nil && err != io.ErrUnexpectedEOF { + return err + } + in := string(buf[:n]) + const features = "\nFeatures : " + i := strings.Index(in, features) + if i == -1 { + return errors.New("no CPU features found") + } + in = in[i+len(features):] + if i := strings.Index(in, "\n"); i != -1 { + in = in[:i] + } + m := map[string]*bool{} + + initOptions() // need it early here; it's harmless to call twice + for _, o := range options { + m[o.Name] = o.Feature + } + // The EVTSTRM field has alias "evstrm" in Go, but Linux calls it "evtstrm". + m["evtstrm"] = &ARM64.HasEVTSTRM + + for _, f := range strings.Fields(in) { + if p, ok := m[f]; ok { + *p = true + } + } + return nil +} diff --git a/mcp/dsx-exchange-mcp/vendor/golang.org/x/sys/cpu/runtime_auxv.go b/mcp/dsx-exchange-mcp/vendor/golang.org/x/sys/cpu/runtime_auxv.go new file mode 100644 index 0000000..5f92ac9 --- /dev/null +++ b/mcp/dsx-exchange-mcp/vendor/golang.org/x/sys/cpu/runtime_auxv.go @@ -0,0 +1,16 @@ +// Copyright 2023 The Go Authors. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +package cpu + +// getAuxvFn is non-nil on Go 1.21+ (via runtime_auxv_go121.go init) +// on platforms that use auxv. +var getAuxvFn func() []uintptr + +func getAuxv() []uintptr { + if getAuxvFn == nil { + return nil + } + return getAuxvFn() +} diff --git a/mcp/dsx-exchange-mcp/vendor/golang.org/x/sys/cpu/runtime_auxv_go121.go b/mcp/dsx-exchange-mcp/vendor/golang.org/x/sys/cpu/runtime_auxv_go121.go new file mode 100644 index 0000000..4c9788e --- /dev/null +++ b/mcp/dsx-exchange-mcp/vendor/golang.org/x/sys/cpu/runtime_auxv_go121.go @@ -0,0 +1,18 @@ +// Copyright 2023 The Go Authors. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +//go:build go1.21 + +package cpu + +import ( + _ "unsafe" // for linkname +) + +//go:linkname runtime_getAuxv runtime.getAuxv +func runtime_getAuxv() []uintptr + +func init() { + getAuxvFn = runtime_getAuxv +} diff --git a/mcp/dsx-exchange-mcp/vendor/golang.org/x/sys/cpu/syscall_aix_gccgo.go b/mcp/dsx-exchange-mcp/vendor/golang.org/x/sys/cpu/syscall_aix_gccgo.go new file mode 100644 index 0000000..1b9ccb0 --- /dev/null +++ b/mcp/dsx-exchange-mcp/vendor/golang.org/x/sys/cpu/syscall_aix_gccgo.go @@ -0,0 +1,26 @@ +// Copyright 2020 The Go Authors. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +// Recreate a getsystemcfg syscall handler instead of +// using the one provided by x/sys/unix to avoid having +// the dependency between them. (See golang.org/issue/32102) +// Moreover, this file will be used during the building of +// gccgo's libgo and thus must not used a CGo method. + +//go:build aix && gccgo + +package cpu + +import ( + "syscall" +) + +//extern getsystemcfg +func gccgoGetsystemcfg(label uint32) (r uint64) + +func callgetsystemcfg(label int) (r1 uintptr, e1 syscall.Errno) { + r1 = uintptr(gccgoGetsystemcfg(uint32(label))) + e1 = syscall.GetErrno() + return +} diff --git a/mcp/dsx-exchange-mcp/vendor/golang.org/x/sys/cpu/syscall_aix_ppc64_gc.go b/mcp/dsx-exchange-mcp/vendor/golang.org/x/sys/cpu/syscall_aix_ppc64_gc.go new file mode 100644 index 0000000..e8b6cdb --- /dev/null +++ b/mcp/dsx-exchange-mcp/vendor/golang.org/x/sys/cpu/syscall_aix_ppc64_gc.go @@ -0,0 +1,35 @@ +// Copyright 2019 The Go Authors. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +// Minimal copy of x/sys/unix so the cpu package can make a +// system call on AIX without depending on x/sys/unix. +// (See golang.org/issue/32102) + +//go:build aix && ppc64 && gc + +package cpu + +import ( + "syscall" + "unsafe" +) + +//go:cgo_import_dynamic libc_getsystemcfg getsystemcfg "libc.a/shr_64.o" + +//go:linkname libc_getsystemcfg libc_getsystemcfg + +type syscallFunc uintptr + +var libc_getsystemcfg syscallFunc + +type errno = syscall.Errno + +// Implemented in runtime/syscall_aix.go. +func rawSyscall6(trap, nargs, a1, a2, a3, a4, a5, a6 uintptr) (r1, r2 uintptr, err errno) +func syscall6(trap, nargs, a1, a2, a3, a4, a5, a6 uintptr) (r1, r2 uintptr, err errno) + +func callgetsystemcfg(label int) (r1 uintptr, e1 errno) { + r1, _, e1 = syscall6(uintptr(unsafe.Pointer(&libc_getsystemcfg)), 1, uintptr(label), 0, 0, 0, 0, 0) + return +} diff --git a/mcp/dsx-exchange-mcp/vendor/golang.org/x/sys/cpu/syscall_darwin_x86_gc.go b/mcp/dsx-exchange-mcp/vendor/golang.org/x/sys/cpu/syscall_darwin_x86_gc.go new file mode 100644 index 0000000..4d0888b --- /dev/null +++ b/mcp/dsx-exchange-mcp/vendor/golang.org/x/sys/cpu/syscall_darwin_x86_gc.go @@ -0,0 +1,98 @@ +// Copyright 2024 The Go Authors. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +// Minimal copy of x/sys/unix so the cpu package can make a +// system call on Darwin without depending on x/sys/unix. + +//go:build darwin && amd64 && gc + +package cpu + +import ( + "syscall" + "unsafe" +) + +type _C_int int32 + +// adapted from unix.Uname() at x/sys/unix/syscall_darwin.go L419 +func darwinOSRelease(release *[256]byte) error { + // from x/sys/unix/zerrors_openbsd_amd64.go + const ( + CTL_KERN = 0x1 + KERN_OSRELEASE = 0x2 + ) + + mib := []_C_int{CTL_KERN, KERN_OSRELEASE} + n := unsafe.Sizeof(*release) + + return sysctl(mib, &release[0], &n, nil, 0) +} + +type Errno = syscall.Errno + +var _zero uintptr // Single-word zero for use when we need a valid pointer to 0 bytes. + +// from x/sys/unix/zsyscall_darwin_amd64.go L791-807 +func sysctl(mib []_C_int, old *byte, oldlen *uintptr, new *byte, newlen uintptr) error { + var _p0 unsafe.Pointer + if len(mib) > 0 { + _p0 = unsafe.Pointer(&mib[0]) + } else { + _p0 = unsafe.Pointer(&_zero) + } + if _, _, err := syscall_syscall6( + libc_sysctl_trampoline_addr, + uintptr(_p0), + uintptr(len(mib)), + uintptr(unsafe.Pointer(old)), + uintptr(unsafe.Pointer(oldlen)), + uintptr(unsafe.Pointer(new)), + uintptr(newlen), + ); err != 0 { + return err + } + + return nil +} + +var libc_sysctl_trampoline_addr uintptr + +// adapted from internal/cpu/cpu_arm64_darwin.go +func darwinSysctlEnabled(name []byte) bool { + out := int32(0) + nout := unsafe.Sizeof(out) + if ret := sysctlbyname(&name[0], (*byte)(unsafe.Pointer(&out)), &nout, nil, 0); ret != nil { + return false + } + return out > 0 +} + +//go:cgo_import_dynamic libc_sysctl sysctl "/usr/lib/libSystem.B.dylib" + +var libc_sysctlbyname_trampoline_addr uintptr + +// adapted from runtime/sys_darwin.go in the pattern of sysctl() above, as defined in x/sys/unix +func sysctlbyname(name *byte, old *byte, oldlen *uintptr, new *byte, newlen uintptr) error { + if _, _, err := syscall_syscall6( + libc_sysctlbyname_trampoline_addr, + uintptr(unsafe.Pointer(name)), + uintptr(unsafe.Pointer(old)), + uintptr(unsafe.Pointer(oldlen)), + uintptr(unsafe.Pointer(new)), + uintptr(newlen), + 0, + ); err != 0 { + return err + } + + return nil +} + +//go:cgo_import_dynamic libc_sysctlbyname sysctlbyname "/usr/lib/libSystem.B.dylib" + +// Implemented in the runtime package (runtime/sys_darwin.go) +func syscall_syscall6(fn, a1, a2, a3, a4, a5, a6 uintptr) (r1, r2 uintptr, err Errno) + +//go:linkname syscall_syscall6 syscall.syscall6 diff --git a/mcp/dsx-exchange-mcp/vendor/gopkg.in/yaml.v3/LICENSE b/mcp/dsx-exchange-mcp/vendor/gopkg.in/yaml.v3/LICENSE new file mode 100644 index 0000000..2683e4b --- /dev/null +++ b/mcp/dsx-exchange-mcp/vendor/gopkg.in/yaml.v3/LICENSE @@ -0,0 +1,50 @@ + +This project is covered by two different licenses: MIT and Apache. + +#### MIT License #### + +The following files were ported to Go from C files of libyaml, and thus +are still covered by their original MIT license, with the additional +copyright staring in 2011 when the project was ported over: + + apic.go emitterc.go parserc.go readerc.go scannerc.go + writerc.go yamlh.go yamlprivateh.go + +Copyright (c) 2006-2010 Kirill Simonov +Copyright (c) 2006-2011 Kirill Simonov + +Permission is hereby granted, free of charge, to any person obtaining a copy of +this software and associated documentation files (the "Software"), to deal in +the Software without restriction, including without limitation the rights to +use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies +of the Software, and to permit persons to whom the Software is furnished to do +so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + +### Apache License ### + +All the remaining project files are covered by the Apache license: + +Copyright (c) 2011-2019 Canonical Ltd + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. diff --git a/mcp/dsx-exchange-mcp/vendor/gopkg.in/yaml.v3/NOTICE b/mcp/dsx-exchange-mcp/vendor/gopkg.in/yaml.v3/NOTICE new file mode 100644 index 0000000..866d74a --- /dev/null +++ b/mcp/dsx-exchange-mcp/vendor/gopkg.in/yaml.v3/NOTICE @@ -0,0 +1,13 @@ +Copyright 2011-2016 Canonical Ltd. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. diff --git a/mcp/dsx-exchange-mcp/vendor/gopkg.in/yaml.v3/README.md b/mcp/dsx-exchange-mcp/vendor/gopkg.in/yaml.v3/README.md new file mode 100644 index 0000000..08eb1ba --- /dev/null +++ b/mcp/dsx-exchange-mcp/vendor/gopkg.in/yaml.v3/README.md @@ -0,0 +1,150 @@ +# YAML support for the Go language + +Introduction +------------ + +The yaml package enables Go programs to comfortably encode and decode YAML +values. It was developed within [Canonical](https://www.canonical.com) as +part of the [juju](https://juju.ubuntu.com) project, and is based on a +pure Go port of the well-known [libyaml](http://pyyaml.org/wiki/LibYAML) +C library to parse and generate YAML data quickly and reliably. + +Compatibility +------------- + +The yaml package supports most of YAML 1.2, but preserves some behavior +from 1.1 for backwards compatibility. + +Specifically, as of v3 of the yaml package: + + - YAML 1.1 bools (_yes/no, on/off_) are supported as long as they are being + decoded into a typed bool value. Otherwise they behave as a string. Booleans + in YAML 1.2 are _true/false_ only. + - Octals encode and decode as _0777_ per YAML 1.1, rather than _0o777_ + as specified in YAML 1.2, because most parsers still use the old format. + Octals in the _0o777_ format are supported though, so new files work. + - Does not support base-60 floats. These are gone from YAML 1.2, and were + actually never supported by this package as it's clearly a poor choice. + +and offers backwards +compatibility with YAML 1.1 in some cases. +1.2, including support for +anchors, tags, map merging, etc. Multi-document unmarshalling is not yet +implemented, and base-60 floats from YAML 1.1 are purposefully not +supported since they're a poor design and are gone in YAML 1.2. + +Installation and usage +---------------------- + +The import path for the package is *gopkg.in/yaml.v3*. + +To install it, run: + + go get gopkg.in/yaml.v3 + +API documentation +----------------- + +If opened in a browser, the import path itself leads to the API documentation: + + - [https://gopkg.in/yaml.v3](https://gopkg.in/yaml.v3) + +API stability +------------- + +The package API for yaml v3 will remain stable as described in [gopkg.in](https://gopkg.in). + + +License +------- + +The yaml package is licensed under the MIT and Apache License 2.0 licenses. +Please see the LICENSE file for details. + + +Example +------- + +```Go +package main + +import ( + "fmt" + "log" + + "gopkg.in/yaml.v3" +) + +var data = ` +a: Easy! +b: + c: 2 + d: [3, 4] +` + +// Note: struct fields must be public in order for unmarshal to +// correctly populate the data. +type T struct { + A string + B struct { + RenamedC int `yaml:"c"` + D []int `yaml:",flow"` + } +} + +func main() { + t := T{} + + err := yaml.Unmarshal([]byte(data), &t) + if err != nil { + log.Fatalf("error: %v", err) + } + fmt.Printf("--- t:\n%v\n\n", t) + + d, err := yaml.Marshal(&t) + if err != nil { + log.Fatalf("error: %v", err) + } + fmt.Printf("--- t dump:\n%s\n\n", string(d)) + + m := make(map[interface{}]interface{}) + + err = yaml.Unmarshal([]byte(data), &m) + if err != nil { + log.Fatalf("error: %v", err) + } + fmt.Printf("--- m:\n%v\n\n", m) + + d, err = yaml.Marshal(&m) + if err != nil { + log.Fatalf("error: %v", err) + } + fmt.Printf("--- m dump:\n%s\n\n", string(d)) +} +``` + +This example will generate the following output: + +``` +--- t: +{Easy! {2 [3 4]}} + +--- t dump: +a: Easy! +b: + c: 2 + d: [3, 4] + + +--- m: +map[a:Easy! b:map[c:2 d:[3 4]]] + +--- m dump: +a: Easy! +b: + c: 2 + d: + - 3 + - 4 +``` + diff --git a/mcp/dsx-exchange-mcp/vendor/gopkg.in/yaml.v3/apic.go b/mcp/dsx-exchange-mcp/vendor/gopkg.in/yaml.v3/apic.go new file mode 100644 index 0000000..ae7d049 --- /dev/null +++ b/mcp/dsx-exchange-mcp/vendor/gopkg.in/yaml.v3/apic.go @@ -0,0 +1,747 @@ +// +// Copyright (c) 2011-2019 Canonical Ltd +// Copyright (c) 2006-2010 Kirill Simonov +// +// Permission is hereby granted, free of charge, to any person obtaining a copy of +// this software and associated documentation files (the "Software"), to deal in +// the Software without restriction, including without limitation the rights to +// use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies +// of the Software, and to permit persons to whom the Software is furnished to do +// so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in all +// copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. + +package yaml + +import ( + "io" +) + +func yaml_insert_token(parser *yaml_parser_t, pos int, token *yaml_token_t) { + //fmt.Println("yaml_insert_token", "pos:", pos, "typ:", token.typ, "head:", parser.tokens_head, "len:", len(parser.tokens)) + + // Check if we can move the queue at the beginning of the buffer. + if parser.tokens_head > 0 && len(parser.tokens) == cap(parser.tokens) { + if parser.tokens_head != len(parser.tokens) { + copy(parser.tokens, parser.tokens[parser.tokens_head:]) + } + parser.tokens = parser.tokens[:len(parser.tokens)-parser.tokens_head] + parser.tokens_head = 0 + } + parser.tokens = append(parser.tokens, *token) + if pos < 0 { + return + } + copy(parser.tokens[parser.tokens_head+pos+1:], parser.tokens[parser.tokens_head+pos:]) + parser.tokens[parser.tokens_head+pos] = *token +} + +// Create a new parser object. +func yaml_parser_initialize(parser *yaml_parser_t) bool { + *parser = yaml_parser_t{ + raw_buffer: make([]byte, 0, input_raw_buffer_size), + buffer: make([]byte, 0, input_buffer_size), + } + return true +} + +// Destroy a parser object. +func yaml_parser_delete(parser *yaml_parser_t) { + *parser = yaml_parser_t{} +} + +// String read handler. +func yaml_string_read_handler(parser *yaml_parser_t, buffer []byte) (n int, err error) { + if parser.input_pos == len(parser.input) { + return 0, io.EOF + } + n = copy(buffer, parser.input[parser.input_pos:]) + parser.input_pos += n + return n, nil +} + +// Reader read handler. +func yaml_reader_read_handler(parser *yaml_parser_t, buffer []byte) (n int, err error) { + return parser.input_reader.Read(buffer) +} + +// Set a string input. +func yaml_parser_set_input_string(parser *yaml_parser_t, input []byte) { + if parser.read_handler != nil { + panic("must set the input source only once") + } + parser.read_handler = yaml_string_read_handler + parser.input = input + parser.input_pos = 0 +} + +// Set a file input. +func yaml_parser_set_input_reader(parser *yaml_parser_t, r io.Reader) { + if parser.read_handler != nil { + panic("must set the input source only once") + } + parser.read_handler = yaml_reader_read_handler + parser.input_reader = r +} + +// Set the source encoding. +func yaml_parser_set_encoding(parser *yaml_parser_t, encoding yaml_encoding_t) { + if parser.encoding != yaml_ANY_ENCODING { + panic("must set the encoding only once") + } + parser.encoding = encoding +} + +// Create a new emitter object. +func yaml_emitter_initialize(emitter *yaml_emitter_t) { + *emitter = yaml_emitter_t{ + buffer: make([]byte, output_buffer_size), + raw_buffer: make([]byte, 0, output_raw_buffer_size), + states: make([]yaml_emitter_state_t, 0, initial_stack_size), + events: make([]yaml_event_t, 0, initial_queue_size), + best_width: -1, + } +} + +// Destroy an emitter object. +func yaml_emitter_delete(emitter *yaml_emitter_t) { + *emitter = yaml_emitter_t{} +} + +// String write handler. +func yaml_string_write_handler(emitter *yaml_emitter_t, buffer []byte) error { + *emitter.output_buffer = append(*emitter.output_buffer, buffer...) + return nil +} + +// yaml_writer_write_handler uses emitter.output_writer to write the +// emitted text. +func yaml_writer_write_handler(emitter *yaml_emitter_t, buffer []byte) error { + _, err := emitter.output_writer.Write(buffer) + return err +} + +// Set a string output. +func yaml_emitter_set_output_string(emitter *yaml_emitter_t, output_buffer *[]byte) { + if emitter.write_handler != nil { + panic("must set the output target only once") + } + emitter.write_handler = yaml_string_write_handler + emitter.output_buffer = output_buffer +} + +// Set a file output. +func yaml_emitter_set_output_writer(emitter *yaml_emitter_t, w io.Writer) { + if emitter.write_handler != nil { + panic("must set the output target only once") + } + emitter.write_handler = yaml_writer_write_handler + emitter.output_writer = w +} + +// Set the output encoding. +func yaml_emitter_set_encoding(emitter *yaml_emitter_t, encoding yaml_encoding_t) { + if emitter.encoding != yaml_ANY_ENCODING { + panic("must set the output encoding only once") + } + emitter.encoding = encoding +} + +// Set the canonical output style. +func yaml_emitter_set_canonical(emitter *yaml_emitter_t, canonical bool) { + emitter.canonical = canonical +} + +// Set the indentation increment. +func yaml_emitter_set_indent(emitter *yaml_emitter_t, indent int) { + if indent < 2 || indent > 9 { + indent = 2 + } + emitter.best_indent = indent +} + +// Set the preferred line width. +func yaml_emitter_set_width(emitter *yaml_emitter_t, width int) { + if width < 0 { + width = -1 + } + emitter.best_width = width +} + +// Set if unescaped non-ASCII characters are allowed. +func yaml_emitter_set_unicode(emitter *yaml_emitter_t, unicode bool) { + emitter.unicode = unicode +} + +// Set the preferred line break character. +func yaml_emitter_set_break(emitter *yaml_emitter_t, line_break yaml_break_t) { + emitter.line_break = line_break +} + +///* +// * Destroy a token object. +// */ +// +//YAML_DECLARE(void) +//yaml_token_delete(yaml_token_t *token) +//{ +// assert(token); // Non-NULL token object expected. +// +// switch (token.type) +// { +// case YAML_TAG_DIRECTIVE_TOKEN: +// yaml_free(token.data.tag_directive.handle); +// yaml_free(token.data.tag_directive.prefix); +// break; +// +// case YAML_ALIAS_TOKEN: +// yaml_free(token.data.alias.value); +// break; +// +// case YAML_ANCHOR_TOKEN: +// yaml_free(token.data.anchor.value); +// break; +// +// case YAML_TAG_TOKEN: +// yaml_free(token.data.tag.handle); +// yaml_free(token.data.tag.suffix); +// break; +// +// case YAML_SCALAR_TOKEN: +// yaml_free(token.data.scalar.value); +// break; +// +// default: +// break; +// } +// +// memset(token, 0, sizeof(yaml_token_t)); +//} +// +///* +// * Check if a string is a valid UTF-8 sequence. +// * +// * Check 'reader.c' for more details on UTF-8 encoding. +// */ +// +//static int +//yaml_check_utf8(yaml_char_t *start, size_t length) +//{ +// yaml_char_t *end = start+length; +// yaml_char_t *pointer = start; +// +// while (pointer < end) { +// unsigned char octet; +// unsigned int width; +// unsigned int value; +// size_t k; +// +// octet = pointer[0]; +// width = (octet & 0x80) == 0x00 ? 1 : +// (octet & 0xE0) == 0xC0 ? 2 : +// (octet & 0xF0) == 0xE0 ? 3 : +// (octet & 0xF8) == 0xF0 ? 4 : 0; +// value = (octet & 0x80) == 0x00 ? octet & 0x7F : +// (octet & 0xE0) == 0xC0 ? octet & 0x1F : +// (octet & 0xF0) == 0xE0 ? octet & 0x0F : +// (octet & 0xF8) == 0xF0 ? octet & 0x07 : 0; +// if (!width) return 0; +// if (pointer+width > end) return 0; +// for (k = 1; k < width; k ++) { +// octet = pointer[k]; +// if ((octet & 0xC0) != 0x80) return 0; +// value = (value << 6) + (octet & 0x3F); +// } +// if (!((width == 1) || +// (width == 2 && value >= 0x80) || +// (width == 3 && value >= 0x800) || +// (width == 4 && value >= 0x10000))) return 0; +// +// pointer += width; +// } +// +// return 1; +//} +// + +// Create STREAM-START. +func yaml_stream_start_event_initialize(event *yaml_event_t, encoding yaml_encoding_t) { + *event = yaml_event_t{ + typ: yaml_STREAM_START_EVENT, + encoding: encoding, + } +} + +// Create STREAM-END. +func yaml_stream_end_event_initialize(event *yaml_event_t) { + *event = yaml_event_t{ + typ: yaml_STREAM_END_EVENT, + } +} + +// Create DOCUMENT-START. +func yaml_document_start_event_initialize( + event *yaml_event_t, + version_directive *yaml_version_directive_t, + tag_directives []yaml_tag_directive_t, + implicit bool, +) { + *event = yaml_event_t{ + typ: yaml_DOCUMENT_START_EVENT, + version_directive: version_directive, + tag_directives: tag_directives, + implicit: implicit, + } +} + +// Create DOCUMENT-END. +func yaml_document_end_event_initialize(event *yaml_event_t, implicit bool) { + *event = yaml_event_t{ + typ: yaml_DOCUMENT_END_EVENT, + implicit: implicit, + } +} + +// Create ALIAS. +func yaml_alias_event_initialize(event *yaml_event_t, anchor []byte) bool { + *event = yaml_event_t{ + typ: yaml_ALIAS_EVENT, + anchor: anchor, + } + return true +} + +// Create SCALAR. +func yaml_scalar_event_initialize(event *yaml_event_t, anchor, tag, value []byte, plain_implicit, quoted_implicit bool, style yaml_scalar_style_t) bool { + *event = yaml_event_t{ + typ: yaml_SCALAR_EVENT, + anchor: anchor, + tag: tag, + value: value, + implicit: plain_implicit, + quoted_implicit: quoted_implicit, + style: yaml_style_t(style), + } + return true +} + +// Create SEQUENCE-START. +func yaml_sequence_start_event_initialize(event *yaml_event_t, anchor, tag []byte, implicit bool, style yaml_sequence_style_t) bool { + *event = yaml_event_t{ + typ: yaml_SEQUENCE_START_EVENT, + anchor: anchor, + tag: tag, + implicit: implicit, + style: yaml_style_t(style), + } + return true +} + +// Create SEQUENCE-END. +func yaml_sequence_end_event_initialize(event *yaml_event_t) bool { + *event = yaml_event_t{ + typ: yaml_SEQUENCE_END_EVENT, + } + return true +} + +// Create MAPPING-START. +func yaml_mapping_start_event_initialize(event *yaml_event_t, anchor, tag []byte, implicit bool, style yaml_mapping_style_t) { + *event = yaml_event_t{ + typ: yaml_MAPPING_START_EVENT, + anchor: anchor, + tag: tag, + implicit: implicit, + style: yaml_style_t(style), + } +} + +// Create MAPPING-END. +func yaml_mapping_end_event_initialize(event *yaml_event_t) { + *event = yaml_event_t{ + typ: yaml_MAPPING_END_EVENT, + } +} + +// Destroy an event object. +func yaml_event_delete(event *yaml_event_t) { + *event = yaml_event_t{} +} + +///* +// * Create a document object. +// */ +// +//YAML_DECLARE(int) +//yaml_document_initialize(document *yaml_document_t, +// version_directive *yaml_version_directive_t, +// tag_directives_start *yaml_tag_directive_t, +// tag_directives_end *yaml_tag_directive_t, +// start_implicit int, end_implicit int) +//{ +// struct { +// error yaml_error_type_t +// } context +// struct { +// start *yaml_node_t +// end *yaml_node_t +// top *yaml_node_t +// } nodes = { NULL, NULL, NULL } +// version_directive_copy *yaml_version_directive_t = NULL +// struct { +// start *yaml_tag_directive_t +// end *yaml_tag_directive_t +// top *yaml_tag_directive_t +// } tag_directives_copy = { NULL, NULL, NULL } +// value yaml_tag_directive_t = { NULL, NULL } +// mark yaml_mark_t = { 0, 0, 0 } +// +// assert(document) // Non-NULL document object is expected. +// assert((tag_directives_start && tag_directives_end) || +// (tag_directives_start == tag_directives_end)) +// // Valid tag directives are expected. +// +// if (!STACK_INIT(&context, nodes, INITIAL_STACK_SIZE)) goto error +// +// if (version_directive) { +// version_directive_copy = yaml_malloc(sizeof(yaml_version_directive_t)) +// if (!version_directive_copy) goto error +// version_directive_copy.major = version_directive.major +// version_directive_copy.minor = version_directive.minor +// } +// +// if (tag_directives_start != tag_directives_end) { +// tag_directive *yaml_tag_directive_t +// if (!STACK_INIT(&context, tag_directives_copy, INITIAL_STACK_SIZE)) +// goto error +// for (tag_directive = tag_directives_start +// tag_directive != tag_directives_end; tag_directive ++) { +// assert(tag_directive.handle) +// assert(tag_directive.prefix) +// if (!yaml_check_utf8(tag_directive.handle, +// strlen((char *)tag_directive.handle))) +// goto error +// if (!yaml_check_utf8(tag_directive.prefix, +// strlen((char *)tag_directive.prefix))) +// goto error +// value.handle = yaml_strdup(tag_directive.handle) +// value.prefix = yaml_strdup(tag_directive.prefix) +// if (!value.handle || !value.prefix) goto error +// if (!PUSH(&context, tag_directives_copy, value)) +// goto error +// value.handle = NULL +// value.prefix = NULL +// } +// } +// +// DOCUMENT_INIT(*document, nodes.start, nodes.end, version_directive_copy, +// tag_directives_copy.start, tag_directives_copy.top, +// start_implicit, end_implicit, mark, mark) +// +// return 1 +// +//error: +// STACK_DEL(&context, nodes) +// yaml_free(version_directive_copy) +// while (!STACK_EMPTY(&context, tag_directives_copy)) { +// value yaml_tag_directive_t = POP(&context, tag_directives_copy) +// yaml_free(value.handle) +// yaml_free(value.prefix) +// } +// STACK_DEL(&context, tag_directives_copy) +// yaml_free(value.handle) +// yaml_free(value.prefix) +// +// return 0 +//} +// +///* +// * Destroy a document object. +// */ +// +//YAML_DECLARE(void) +//yaml_document_delete(document *yaml_document_t) +//{ +// struct { +// error yaml_error_type_t +// } context +// tag_directive *yaml_tag_directive_t +// +// context.error = YAML_NO_ERROR // Eliminate a compiler warning. +// +// assert(document) // Non-NULL document object is expected. +// +// while (!STACK_EMPTY(&context, document.nodes)) { +// node yaml_node_t = POP(&context, document.nodes) +// yaml_free(node.tag) +// switch (node.type) { +// case YAML_SCALAR_NODE: +// yaml_free(node.data.scalar.value) +// break +// case YAML_SEQUENCE_NODE: +// STACK_DEL(&context, node.data.sequence.items) +// break +// case YAML_MAPPING_NODE: +// STACK_DEL(&context, node.data.mapping.pairs) +// break +// default: +// assert(0) // Should not happen. +// } +// } +// STACK_DEL(&context, document.nodes) +// +// yaml_free(document.version_directive) +// for (tag_directive = document.tag_directives.start +// tag_directive != document.tag_directives.end +// tag_directive++) { +// yaml_free(tag_directive.handle) +// yaml_free(tag_directive.prefix) +// } +// yaml_free(document.tag_directives.start) +// +// memset(document, 0, sizeof(yaml_document_t)) +//} +// +///** +// * Get a document node. +// */ +// +//YAML_DECLARE(yaml_node_t *) +//yaml_document_get_node(document *yaml_document_t, index int) +//{ +// assert(document) // Non-NULL document object is expected. +// +// if (index > 0 && document.nodes.start + index <= document.nodes.top) { +// return document.nodes.start + index - 1 +// } +// return NULL +//} +// +///** +// * Get the root object. +// */ +// +//YAML_DECLARE(yaml_node_t *) +//yaml_document_get_root_node(document *yaml_document_t) +//{ +// assert(document) // Non-NULL document object is expected. +// +// if (document.nodes.top != document.nodes.start) { +// return document.nodes.start +// } +// return NULL +//} +// +///* +// * Add a scalar node to a document. +// */ +// +//YAML_DECLARE(int) +//yaml_document_add_scalar(document *yaml_document_t, +// tag *yaml_char_t, value *yaml_char_t, length int, +// style yaml_scalar_style_t) +//{ +// struct { +// error yaml_error_type_t +// } context +// mark yaml_mark_t = { 0, 0, 0 } +// tag_copy *yaml_char_t = NULL +// value_copy *yaml_char_t = NULL +// node yaml_node_t +// +// assert(document) // Non-NULL document object is expected. +// assert(value) // Non-NULL value is expected. +// +// if (!tag) { +// tag = (yaml_char_t *)YAML_DEFAULT_SCALAR_TAG +// } +// +// if (!yaml_check_utf8(tag, strlen((char *)tag))) goto error +// tag_copy = yaml_strdup(tag) +// if (!tag_copy) goto error +// +// if (length < 0) { +// length = strlen((char *)value) +// } +// +// if (!yaml_check_utf8(value, length)) goto error +// value_copy = yaml_malloc(length+1) +// if (!value_copy) goto error +// memcpy(value_copy, value, length) +// value_copy[length] = '\0' +// +// SCALAR_NODE_INIT(node, tag_copy, value_copy, length, style, mark, mark) +// if (!PUSH(&context, document.nodes, node)) goto error +// +// return document.nodes.top - document.nodes.start +// +//error: +// yaml_free(tag_copy) +// yaml_free(value_copy) +// +// return 0 +//} +// +///* +// * Add a sequence node to a document. +// */ +// +//YAML_DECLARE(int) +//yaml_document_add_sequence(document *yaml_document_t, +// tag *yaml_char_t, style yaml_sequence_style_t) +//{ +// struct { +// error yaml_error_type_t +// } context +// mark yaml_mark_t = { 0, 0, 0 } +// tag_copy *yaml_char_t = NULL +// struct { +// start *yaml_node_item_t +// end *yaml_node_item_t +// top *yaml_node_item_t +// } items = { NULL, NULL, NULL } +// node yaml_node_t +// +// assert(document) // Non-NULL document object is expected. +// +// if (!tag) { +// tag = (yaml_char_t *)YAML_DEFAULT_SEQUENCE_TAG +// } +// +// if (!yaml_check_utf8(tag, strlen((char *)tag))) goto error +// tag_copy = yaml_strdup(tag) +// if (!tag_copy) goto error +// +// if (!STACK_INIT(&context, items, INITIAL_STACK_SIZE)) goto error +// +// SEQUENCE_NODE_INIT(node, tag_copy, items.start, items.end, +// style, mark, mark) +// if (!PUSH(&context, document.nodes, node)) goto error +// +// return document.nodes.top - document.nodes.start +// +//error: +// STACK_DEL(&context, items) +// yaml_free(tag_copy) +// +// return 0 +//} +// +///* +// * Add a mapping node to a document. +// */ +// +//YAML_DECLARE(int) +//yaml_document_add_mapping(document *yaml_document_t, +// tag *yaml_char_t, style yaml_mapping_style_t) +//{ +// struct { +// error yaml_error_type_t +// } context +// mark yaml_mark_t = { 0, 0, 0 } +// tag_copy *yaml_char_t = NULL +// struct { +// start *yaml_node_pair_t +// end *yaml_node_pair_t +// top *yaml_node_pair_t +// } pairs = { NULL, NULL, NULL } +// node yaml_node_t +// +// assert(document) // Non-NULL document object is expected. +// +// if (!tag) { +// tag = (yaml_char_t *)YAML_DEFAULT_MAPPING_TAG +// } +// +// if (!yaml_check_utf8(tag, strlen((char *)tag))) goto error +// tag_copy = yaml_strdup(tag) +// if (!tag_copy) goto error +// +// if (!STACK_INIT(&context, pairs, INITIAL_STACK_SIZE)) goto error +// +// MAPPING_NODE_INIT(node, tag_copy, pairs.start, pairs.end, +// style, mark, mark) +// if (!PUSH(&context, document.nodes, node)) goto error +// +// return document.nodes.top - document.nodes.start +// +//error: +// STACK_DEL(&context, pairs) +// yaml_free(tag_copy) +// +// return 0 +//} +// +///* +// * Append an item to a sequence node. +// */ +// +//YAML_DECLARE(int) +//yaml_document_append_sequence_item(document *yaml_document_t, +// sequence int, item int) +//{ +// struct { +// error yaml_error_type_t +// } context +// +// assert(document) // Non-NULL document is required. +// assert(sequence > 0 +// && document.nodes.start + sequence <= document.nodes.top) +// // Valid sequence id is required. +// assert(document.nodes.start[sequence-1].type == YAML_SEQUENCE_NODE) +// // A sequence node is required. +// assert(item > 0 && document.nodes.start + item <= document.nodes.top) +// // Valid item id is required. +// +// if (!PUSH(&context, +// document.nodes.start[sequence-1].data.sequence.items, item)) +// return 0 +// +// return 1 +//} +// +///* +// * Append a pair of a key and a value to a mapping node. +// */ +// +//YAML_DECLARE(int) +//yaml_document_append_mapping_pair(document *yaml_document_t, +// mapping int, key int, value int) +//{ +// struct { +// error yaml_error_type_t +// } context +// +// pair yaml_node_pair_t +// +// assert(document) // Non-NULL document is required. +// assert(mapping > 0 +// && document.nodes.start + mapping <= document.nodes.top) +// // Valid mapping id is required. +// assert(document.nodes.start[mapping-1].type == YAML_MAPPING_NODE) +// // A mapping node is required. +// assert(key > 0 && document.nodes.start + key <= document.nodes.top) +// // Valid key id is required. +// assert(value > 0 && document.nodes.start + value <= document.nodes.top) +// // Valid value id is required. +// +// pair.key = key +// pair.value = value +// +// if (!PUSH(&context, +// document.nodes.start[mapping-1].data.mapping.pairs, pair)) +// return 0 +// +// return 1 +//} +// +// diff --git a/mcp/dsx-exchange-mcp/vendor/gopkg.in/yaml.v3/decode.go b/mcp/dsx-exchange-mcp/vendor/gopkg.in/yaml.v3/decode.go new file mode 100644 index 0000000..0173b69 --- /dev/null +++ b/mcp/dsx-exchange-mcp/vendor/gopkg.in/yaml.v3/decode.go @@ -0,0 +1,1000 @@ +// +// Copyright (c) 2011-2019 Canonical Ltd +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package yaml + +import ( + "encoding" + "encoding/base64" + "fmt" + "io" + "math" + "reflect" + "strconv" + "time" +) + +// ---------------------------------------------------------------------------- +// Parser, produces a node tree out of a libyaml event stream. + +type parser struct { + parser yaml_parser_t + event yaml_event_t + doc *Node + anchors map[string]*Node + doneInit bool + textless bool +} + +func newParser(b []byte) *parser { + p := parser{} + if !yaml_parser_initialize(&p.parser) { + panic("failed to initialize YAML emitter") + } + if len(b) == 0 { + b = []byte{'\n'} + } + yaml_parser_set_input_string(&p.parser, b) + return &p +} + +func newParserFromReader(r io.Reader) *parser { + p := parser{} + if !yaml_parser_initialize(&p.parser) { + panic("failed to initialize YAML emitter") + } + yaml_parser_set_input_reader(&p.parser, r) + return &p +} + +func (p *parser) init() { + if p.doneInit { + return + } + p.anchors = make(map[string]*Node) + p.expect(yaml_STREAM_START_EVENT) + p.doneInit = true +} + +func (p *parser) destroy() { + if p.event.typ != yaml_NO_EVENT { + yaml_event_delete(&p.event) + } + yaml_parser_delete(&p.parser) +} + +// expect consumes an event from the event stream and +// checks that it's of the expected type. +func (p *parser) expect(e yaml_event_type_t) { + if p.event.typ == yaml_NO_EVENT { + if !yaml_parser_parse(&p.parser, &p.event) { + p.fail() + } + } + if p.event.typ == yaml_STREAM_END_EVENT { + failf("attempted to go past the end of stream; corrupted value?") + } + if p.event.typ != e { + p.parser.problem = fmt.Sprintf("expected %s event but got %s", e, p.event.typ) + p.fail() + } + yaml_event_delete(&p.event) + p.event.typ = yaml_NO_EVENT +} + +// peek peeks at the next event in the event stream, +// puts the results into p.event and returns the event type. +func (p *parser) peek() yaml_event_type_t { + if p.event.typ != yaml_NO_EVENT { + return p.event.typ + } + // It's curious choice from the underlying API to generally return a + // positive result on success, but on this case return true in an error + // scenario. This was the source of bugs in the past (issue #666). + if !yaml_parser_parse(&p.parser, &p.event) || p.parser.error != yaml_NO_ERROR { + p.fail() + } + return p.event.typ +} + +func (p *parser) fail() { + var where string + var line int + if p.parser.context_mark.line != 0 { + line = p.parser.context_mark.line + // Scanner errors don't iterate line before returning error + if p.parser.error == yaml_SCANNER_ERROR { + line++ + } + } else if p.parser.problem_mark.line != 0 { + line = p.parser.problem_mark.line + // Scanner errors don't iterate line before returning error + if p.parser.error == yaml_SCANNER_ERROR { + line++ + } + } + if line != 0 { + where = "line " + strconv.Itoa(line) + ": " + } + var msg string + if len(p.parser.problem) > 0 { + msg = p.parser.problem + } else { + msg = "unknown problem parsing YAML content" + } + failf("%s%s", where, msg) +} + +func (p *parser) anchor(n *Node, anchor []byte) { + if anchor != nil { + n.Anchor = string(anchor) + p.anchors[n.Anchor] = n + } +} + +func (p *parser) parse() *Node { + p.init() + switch p.peek() { + case yaml_SCALAR_EVENT: + return p.scalar() + case yaml_ALIAS_EVENT: + return p.alias() + case yaml_MAPPING_START_EVENT: + return p.mapping() + case yaml_SEQUENCE_START_EVENT: + return p.sequence() + case yaml_DOCUMENT_START_EVENT: + return p.document() + case yaml_STREAM_END_EVENT: + // Happens when attempting to decode an empty buffer. + return nil + case yaml_TAIL_COMMENT_EVENT: + panic("internal error: unexpected tail comment event (please report)") + default: + panic("internal error: attempted to parse unknown event (please report): " + p.event.typ.String()) + } +} + +func (p *parser) node(kind Kind, defaultTag, tag, value string) *Node { + var style Style + if tag != "" && tag != "!" { + tag = shortTag(tag) + style = TaggedStyle + } else if defaultTag != "" { + tag = defaultTag + } else if kind == ScalarNode { + tag, _ = resolve("", value) + } + n := &Node{ + Kind: kind, + Tag: tag, + Value: value, + Style: style, + } + if !p.textless { + n.Line = p.event.start_mark.line + 1 + n.Column = p.event.start_mark.column + 1 + n.HeadComment = string(p.event.head_comment) + n.LineComment = string(p.event.line_comment) + n.FootComment = string(p.event.foot_comment) + } + return n +} + +func (p *parser) parseChild(parent *Node) *Node { + child := p.parse() + parent.Content = append(parent.Content, child) + return child +} + +func (p *parser) document() *Node { + n := p.node(DocumentNode, "", "", "") + p.doc = n + p.expect(yaml_DOCUMENT_START_EVENT) + p.parseChild(n) + if p.peek() == yaml_DOCUMENT_END_EVENT { + n.FootComment = string(p.event.foot_comment) + } + p.expect(yaml_DOCUMENT_END_EVENT) + return n +} + +func (p *parser) alias() *Node { + n := p.node(AliasNode, "", "", string(p.event.anchor)) + n.Alias = p.anchors[n.Value] + if n.Alias == nil { + failf("unknown anchor '%s' referenced", n.Value) + } + p.expect(yaml_ALIAS_EVENT) + return n +} + +func (p *parser) scalar() *Node { + var parsedStyle = p.event.scalar_style() + var nodeStyle Style + switch { + case parsedStyle&yaml_DOUBLE_QUOTED_SCALAR_STYLE != 0: + nodeStyle = DoubleQuotedStyle + case parsedStyle&yaml_SINGLE_QUOTED_SCALAR_STYLE != 0: + nodeStyle = SingleQuotedStyle + case parsedStyle&yaml_LITERAL_SCALAR_STYLE != 0: + nodeStyle = LiteralStyle + case parsedStyle&yaml_FOLDED_SCALAR_STYLE != 0: + nodeStyle = FoldedStyle + } + var nodeValue = string(p.event.value) + var nodeTag = string(p.event.tag) + var defaultTag string + if nodeStyle == 0 { + if nodeValue == "<<" { + defaultTag = mergeTag + } + } else { + defaultTag = strTag + } + n := p.node(ScalarNode, defaultTag, nodeTag, nodeValue) + n.Style |= nodeStyle + p.anchor(n, p.event.anchor) + p.expect(yaml_SCALAR_EVENT) + return n +} + +func (p *parser) sequence() *Node { + n := p.node(SequenceNode, seqTag, string(p.event.tag), "") + if p.event.sequence_style()&yaml_FLOW_SEQUENCE_STYLE != 0 { + n.Style |= FlowStyle + } + p.anchor(n, p.event.anchor) + p.expect(yaml_SEQUENCE_START_EVENT) + for p.peek() != yaml_SEQUENCE_END_EVENT { + p.parseChild(n) + } + n.LineComment = string(p.event.line_comment) + n.FootComment = string(p.event.foot_comment) + p.expect(yaml_SEQUENCE_END_EVENT) + return n +} + +func (p *parser) mapping() *Node { + n := p.node(MappingNode, mapTag, string(p.event.tag), "") + block := true + if p.event.mapping_style()&yaml_FLOW_MAPPING_STYLE != 0 { + block = false + n.Style |= FlowStyle + } + p.anchor(n, p.event.anchor) + p.expect(yaml_MAPPING_START_EVENT) + for p.peek() != yaml_MAPPING_END_EVENT { + k := p.parseChild(n) + if block && k.FootComment != "" { + // Must be a foot comment for the prior value when being dedented. + if len(n.Content) > 2 { + n.Content[len(n.Content)-3].FootComment = k.FootComment + k.FootComment = "" + } + } + v := p.parseChild(n) + if k.FootComment == "" && v.FootComment != "" { + k.FootComment = v.FootComment + v.FootComment = "" + } + if p.peek() == yaml_TAIL_COMMENT_EVENT { + if k.FootComment == "" { + k.FootComment = string(p.event.foot_comment) + } + p.expect(yaml_TAIL_COMMENT_EVENT) + } + } + n.LineComment = string(p.event.line_comment) + n.FootComment = string(p.event.foot_comment) + if n.Style&FlowStyle == 0 && n.FootComment != "" && len(n.Content) > 1 { + n.Content[len(n.Content)-2].FootComment = n.FootComment + n.FootComment = "" + } + p.expect(yaml_MAPPING_END_EVENT) + return n +} + +// ---------------------------------------------------------------------------- +// Decoder, unmarshals a node into a provided value. + +type decoder struct { + doc *Node + aliases map[*Node]bool + terrors []string + + stringMapType reflect.Type + generalMapType reflect.Type + + knownFields bool + uniqueKeys bool + decodeCount int + aliasCount int + aliasDepth int + + mergedFields map[interface{}]bool +} + +var ( + nodeType = reflect.TypeOf(Node{}) + durationType = reflect.TypeOf(time.Duration(0)) + stringMapType = reflect.TypeOf(map[string]interface{}{}) + generalMapType = reflect.TypeOf(map[interface{}]interface{}{}) + ifaceType = generalMapType.Elem() + timeType = reflect.TypeOf(time.Time{}) + ptrTimeType = reflect.TypeOf(&time.Time{}) +) + +func newDecoder() *decoder { + d := &decoder{ + stringMapType: stringMapType, + generalMapType: generalMapType, + uniqueKeys: true, + } + d.aliases = make(map[*Node]bool) + return d +} + +func (d *decoder) terror(n *Node, tag string, out reflect.Value) { + if n.Tag != "" { + tag = n.Tag + } + value := n.Value + if tag != seqTag && tag != mapTag { + if len(value) > 10 { + value = " `" + value[:7] + "...`" + } else { + value = " `" + value + "`" + } + } + d.terrors = append(d.terrors, fmt.Sprintf("line %d: cannot unmarshal %s%s into %s", n.Line, shortTag(tag), value, out.Type())) +} + +func (d *decoder) callUnmarshaler(n *Node, u Unmarshaler) (good bool) { + err := u.UnmarshalYAML(n) + if e, ok := err.(*TypeError); ok { + d.terrors = append(d.terrors, e.Errors...) + return false + } + if err != nil { + fail(err) + } + return true +} + +func (d *decoder) callObsoleteUnmarshaler(n *Node, u obsoleteUnmarshaler) (good bool) { + terrlen := len(d.terrors) + err := u.UnmarshalYAML(func(v interface{}) (err error) { + defer handleErr(&err) + d.unmarshal(n, reflect.ValueOf(v)) + if len(d.terrors) > terrlen { + issues := d.terrors[terrlen:] + d.terrors = d.terrors[:terrlen] + return &TypeError{issues} + } + return nil + }) + if e, ok := err.(*TypeError); ok { + d.terrors = append(d.terrors, e.Errors...) + return false + } + if err != nil { + fail(err) + } + return true +} + +// d.prepare initializes and dereferences pointers and calls UnmarshalYAML +// if a value is found to implement it. +// It returns the initialized and dereferenced out value, whether +// unmarshalling was already done by UnmarshalYAML, and if so whether +// its types unmarshalled appropriately. +// +// If n holds a null value, prepare returns before doing anything. +func (d *decoder) prepare(n *Node, out reflect.Value) (newout reflect.Value, unmarshaled, good bool) { + if n.ShortTag() == nullTag { + return out, false, false + } + again := true + for again { + again = false + if out.Kind() == reflect.Ptr { + if out.IsNil() { + out.Set(reflect.New(out.Type().Elem())) + } + out = out.Elem() + again = true + } + if out.CanAddr() { + outi := out.Addr().Interface() + if u, ok := outi.(Unmarshaler); ok { + good = d.callUnmarshaler(n, u) + return out, true, good + } + if u, ok := outi.(obsoleteUnmarshaler); ok { + good = d.callObsoleteUnmarshaler(n, u) + return out, true, good + } + } + } + return out, false, false +} + +func (d *decoder) fieldByIndex(n *Node, v reflect.Value, index []int) (field reflect.Value) { + if n.ShortTag() == nullTag { + return reflect.Value{} + } + for _, num := range index { + for { + if v.Kind() == reflect.Ptr { + if v.IsNil() { + v.Set(reflect.New(v.Type().Elem())) + } + v = v.Elem() + continue + } + break + } + v = v.Field(num) + } + return v +} + +const ( + // 400,000 decode operations is ~500kb of dense object declarations, or + // ~5kb of dense object declarations with 10000% alias expansion + alias_ratio_range_low = 400000 + + // 4,000,000 decode operations is ~5MB of dense object declarations, or + // ~4.5MB of dense object declarations with 10% alias expansion + alias_ratio_range_high = 4000000 + + // alias_ratio_range is the range over which we scale allowed alias ratios + alias_ratio_range = float64(alias_ratio_range_high - alias_ratio_range_low) +) + +func allowedAliasRatio(decodeCount int) float64 { + switch { + case decodeCount <= alias_ratio_range_low: + // allow 99% to come from alias expansion for small-to-medium documents + return 0.99 + case decodeCount >= alias_ratio_range_high: + // allow 10% to come from alias expansion for very large documents + return 0.10 + default: + // scale smoothly from 99% down to 10% over the range. + // this maps to 396,000 - 400,000 allowed alias-driven decodes over the range. + // 400,000 decode operations is ~100MB of allocations in worst-case scenarios (single-item maps). + return 0.99 - 0.89*(float64(decodeCount-alias_ratio_range_low)/alias_ratio_range) + } +} + +func (d *decoder) unmarshal(n *Node, out reflect.Value) (good bool) { + d.decodeCount++ + if d.aliasDepth > 0 { + d.aliasCount++ + } + if d.aliasCount > 100 && d.decodeCount > 1000 && float64(d.aliasCount)/float64(d.decodeCount) > allowedAliasRatio(d.decodeCount) { + failf("document contains excessive aliasing") + } + if out.Type() == nodeType { + out.Set(reflect.ValueOf(n).Elem()) + return true + } + switch n.Kind { + case DocumentNode: + return d.document(n, out) + case AliasNode: + return d.alias(n, out) + } + out, unmarshaled, good := d.prepare(n, out) + if unmarshaled { + return good + } + switch n.Kind { + case ScalarNode: + good = d.scalar(n, out) + case MappingNode: + good = d.mapping(n, out) + case SequenceNode: + good = d.sequence(n, out) + case 0: + if n.IsZero() { + return d.null(out) + } + fallthrough + default: + failf("cannot decode node with unknown kind %d", n.Kind) + } + return good +} + +func (d *decoder) document(n *Node, out reflect.Value) (good bool) { + if len(n.Content) == 1 { + d.doc = n + d.unmarshal(n.Content[0], out) + return true + } + return false +} + +func (d *decoder) alias(n *Node, out reflect.Value) (good bool) { + if d.aliases[n] { + // TODO this could actually be allowed in some circumstances. + failf("anchor '%s' value contains itself", n.Value) + } + d.aliases[n] = true + d.aliasDepth++ + good = d.unmarshal(n.Alias, out) + d.aliasDepth-- + delete(d.aliases, n) + return good +} + +var zeroValue reflect.Value + +func resetMap(out reflect.Value) { + for _, k := range out.MapKeys() { + out.SetMapIndex(k, zeroValue) + } +} + +func (d *decoder) null(out reflect.Value) bool { + if out.CanAddr() { + switch out.Kind() { + case reflect.Interface, reflect.Ptr, reflect.Map, reflect.Slice: + out.Set(reflect.Zero(out.Type())) + return true + } + } + return false +} + +func (d *decoder) scalar(n *Node, out reflect.Value) bool { + var tag string + var resolved interface{} + if n.indicatedString() { + tag = strTag + resolved = n.Value + } else { + tag, resolved = resolve(n.Tag, n.Value) + if tag == binaryTag { + data, err := base64.StdEncoding.DecodeString(resolved.(string)) + if err != nil { + failf("!!binary value contains invalid base64 data") + } + resolved = string(data) + } + } + if resolved == nil { + return d.null(out) + } + if resolvedv := reflect.ValueOf(resolved); out.Type() == resolvedv.Type() { + // We've resolved to exactly the type we want, so use that. + out.Set(resolvedv) + return true + } + // Perhaps we can use the value as a TextUnmarshaler to + // set its value. + if out.CanAddr() { + u, ok := out.Addr().Interface().(encoding.TextUnmarshaler) + if ok { + var text []byte + if tag == binaryTag { + text = []byte(resolved.(string)) + } else { + // We let any value be unmarshaled into TextUnmarshaler. + // That might be more lax than we'd like, but the + // TextUnmarshaler itself should bowl out any dubious values. + text = []byte(n.Value) + } + err := u.UnmarshalText(text) + if err != nil { + fail(err) + } + return true + } + } + switch out.Kind() { + case reflect.String: + if tag == binaryTag { + out.SetString(resolved.(string)) + return true + } + out.SetString(n.Value) + return true + case reflect.Interface: + out.Set(reflect.ValueOf(resolved)) + return true + case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64: + // This used to work in v2, but it's very unfriendly. + isDuration := out.Type() == durationType + + switch resolved := resolved.(type) { + case int: + if !isDuration && !out.OverflowInt(int64(resolved)) { + out.SetInt(int64(resolved)) + return true + } + case int64: + if !isDuration && !out.OverflowInt(resolved) { + out.SetInt(resolved) + return true + } + case uint64: + if !isDuration && resolved <= math.MaxInt64 && !out.OverflowInt(int64(resolved)) { + out.SetInt(int64(resolved)) + return true + } + case float64: + if !isDuration && resolved <= math.MaxInt64 && !out.OverflowInt(int64(resolved)) { + out.SetInt(int64(resolved)) + return true + } + case string: + if out.Type() == durationType { + d, err := time.ParseDuration(resolved) + if err == nil { + out.SetInt(int64(d)) + return true + } + } + } + case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64, reflect.Uintptr: + switch resolved := resolved.(type) { + case int: + if resolved >= 0 && !out.OverflowUint(uint64(resolved)) { + out.SetUint(uint64(resolved)) + return true + } + case int64: + if resolved >= 0 && !out.OverflowUint(uint64(resolved)) { + out.SetUint(uint64(resolved)) + return true + } + case uint64: + if !out.OverflowUint(uint64(resolved)) { + out.SetUint(uint64(resolved)) + return true + } + case float64: + if resolved <= math.MaxUint64 && !out.OverflowUint(uint64(resolved)) { + out.SetUint(uint64(resolved)) + return true + } + } + case reflect.Bool: + switch resolved := resolved.(type) { + case bool: + out.SetBool(resolved) + return true + case string: + // This offers some compatibility with the 1.1 spec (https://yaml.org/type/bool.html). + // It only works if explicitly attempting to unmarshal into a typed bool value. + switch resolved { + case "y", "Y", "yes", "Yes", "YES", "on", "On", "ON": + out.SetBool(true) + return true + case "n", "N", "no", "No", "NO", "off", "Off", "OFF": + out.SetBool(false) + return true + } + } + case reflect.Float32, reflect.Float64: + switch resolved := resolved.(type) { + case int: + out.SetFloat(float64(resolved)) + return true + case int64: + out.SetFloat(float64(resolved)) + return true + case uint64: + out.SetFloat(float64(resolved)) + return true + case float64: + out.SetFloat(resolved) + return true + } + case reflect.Struct: + if resolvedv := reflect.ValueOf(resolved); out.Type() == resolvedv.Type() { + out.Set(resolvedv) + return true + } + case reflect.Ptr: + panic("yaml internal error: please report the issue") + } + d.terror(n, tag, out) + return false +} + +func settableValueOf(i interface{}) reflect.Value { + v := reflect.ValueOf(i) + sv := reflect.New(v.Type()).Elem() + sv.Set(v) + return sv +} + +func (d *decoder) sequence(n *Node, out reflect.Value) (good bool) { + l := len(n.Content) + + var iface reflect.Value + switch out.Kind() { + case reflect.Slice: + out.Set(reflect.MakeSlice(out.Type(), l, l)) + case reflect.Array: + if l != out.Len() { + failf("invalid array: want %d elements but got %d", out.Len(), l) + } + case reflect.Interface: + // No type hints. Will have to use a generic sequence. + iface = out + out = settableValueOf(make([]interface{}, l)) + default: + d.terror(n, seqTag, out) + return false + } + et := out.Type().Elem() + + j := 0 + for i := 0; i < l; i++ { + e := reflect.New(et).Elem() + if ok := d.unmarshal(n.Content[i], e); ok { + out.Index(j).Set(e) + j++ + } + } + if out.Kind() != reflect.Array { + out.Set(out.Slice(0, j)) + } + if iface.IsValid() { + iface.Set(out) + } + return true +} + +func (d *decoder) mapping(n *Node, out reflect.Value) (good bool) { + l := len(n.Content) + if d.uniqueKeys { + nerrs := len(d.terrors) + for i := 0; i < l; i += 2 { + ni := n.Content[i] + for j := i + 2; j < l; j += 2 { + nj := n.Content[j] + if ni.Kind == nj.Kind && ni.Value == nj.Value { + d.terrors = append(d.terrors, fmt.Sprintf("line %d: mapping key %#v already defined at line %d", nj.Line, nj.Value, ni.Line)) + } + } + } + if len(d.terrors) > nerrs { + return false + } + } + switch out.Kind() { + case reflect.Struct: + return d.mappingStruct(n, out) + case reflect.Map: + // okay + case reflect.Interface: + iface := out + if isStringMap(n) { + out = reflect.MakeMap(d.stringMapType) + } else { + out = reflect.MakeMap(d.generalMapType) + } + iface.Set(out) + default: + d.terror(n, mapTag, out) + return false + } + + outt := out.Type() + kt := outt.Key() + et := outt.Elem() + + stringMapType := d.stringMapType + generalMapType := d.generalMapType + if outt.Elem() == ifaceType { + if outt.Key().Kind() == reflect.String { + d.stringMapType = outt + } else if outt.Key() == ifaceType { + d.generalMapType = outt + } + } + + mergedFields := d.mergedFields + d.mergedFields = nil + + var mergeNode *Node + + mapIsNew := false + if out.IsNil() { + out.Set(reflect.MakeMap(outt)) + mapIsNew = true + } + for i := 0; i < l; i += 2 { + if isMerge(n.Content[i]) { + mergeNode = n.Content[i+1] + continue + } + k := reflect.New(kt).Elem() + if d.unmarshal(n.Content[i], k) { + if mergedFields != nil { + ki := k.Interface() + if mergedFields[ki] { + continue + } + mergedFields[ki] = true + } + kkind := k.Kind() + if kkind == reflect.Interface { + kkind = k.Elem().Kind() + } + if kkind == reflect.Map || kkind == reflect.Slice { + failf("invalid map key: %#v", k.Interface()) + } + e := reflect.New(et).Elem() + if d.unmarshal(n.Content[i+1], e) || n.Content[i+1].ShortTag() == nullTag && (mapIsNew || !out.MapIndex(k).IsValid()) { + out.SetMapIndex(k, e) + } + } + } + + d.mergedFields = mergedFields + if mergeNode != nil { + d.merge(n, mergeNode, out) + } + + d.stringMapType = stringMapType + d.generalMapType = generalMapType + return true +} + +func isStringMap(n *Node) bool { + if n.Kind != MappingNode { + return false + } + l := len(n.Content) + for i := 0; i < l; i += 2 { + shortTag := n.Content[i].ShortTag() + if shortTag != strTag && shortTag != mergeTag { + return false + } + } + return true +} + +func (d *decoder) mappingStruct(n *Node, out reflect.Value) (good bool) { + sinfo, err := getStructInfo(out.Type()) + if err != nil { + panic(err) + } + + var inlineMap reflect.Value + var elemType reflect.Type + if sinfo.InlineMap != -1 { + inlineMap = out.Field(sinfo.InlineMap) + elemType = inlineMap.Type().Elem() + } + + for _, index := range sinfo.InlineUnmarshalers { + field := d.fieldByIndex(n, out, index) + d.prepare(n, field) + } + + mergedFields := d.mergedFields + d.mergedFields = nil + var mergeNode *Node + var doneFields []bool + if d.uniqueKeys { + doneFields = make([]bool, len(sinfo.FieldsList)) + } + name := settableValueOf("") + l := len(n.Content) + for i := 0; i < l; i += 2 { + ni := n.Content[i] + if isMerge(ni) { + mergeNode = n.Content[i+1] + continue + } + if !d.unmarshal(ni, name) { + continue + } + sname := name.String() + if mergedFields != nil { + if mergedFields[sname] { + continue + } + mergedFields[sname] = true + } + if info, ok := sinfo.FieldsMap[sname]; ok { + if d.uniqueKeys { + if doneFields[info.Id] { + d.terrors = append(d.terrors, fmt.Sprintf("line %d: field %s already set in type %s", ni.Line, name.String(), out.Type())) + continue + } + doneFields[info.Id] = true + } + var field reflect.Value + if info.Inline == nil { + field = out.Field(info.Num) + } else { + field = d.fieldByIndex(n, out, info.Inline) + } + d.unmarshal(n.Content[i+1], field) + } else if sinfo.InlineMap != -1 { + if inlineMap.IsNil() { + inlineMap.Set(reflect.MakeMap(inlineMap.Type())) + } + value := reflect.New(elemType).Elem() + d.unmarshal(n.Content[i+1], value) + inlineMap.SetMapIndex(name, value) + } else if d.knownFields { + d.terrors = append(d.terrors, fmt.Sprintf("line %d: field %s not found in type %s", ni.Line, name.String(), out.Type())) + } + } + + d.mergedFields = mergedFields + if mergeNode != nil { + d.merge(n, mergeNode, out) + } + return true +} + +func failWantMap() { + failf("map merge requires map or sequence of maps as the value") +} + +func (d *decoder) merge(parent *Node, merge *Node, out reflect.Value) { + mergedFields := d.mergedFields + if mergedFields == nil { + d.mergedFields = make(map[interface{}]bool) + for i := 0; i < len(parent.Content); i += 2 { + k := reflect.New(ifaceType).Elem() + if d.unmarshal(parent.Content[i], k) { + d.mergedFields[k.Interface()] = true + } + } + } + + switch merge.Kind { + case MappingNode: + d.unmarshal(merge, out) + case AliasNode: + if merge.Alias != nil && merge.Alias.Kind != MappingNode { + failWantMap() + } + d.unmarshal(merge, out) + case SequenceNode: + for i := 0; i < len(merge.Content); i++ { + ni := merge.Content[i] + if ni.Kind == AliasNode { + if ni.Alias != nil && ni.Alias.Kind != MappingNode { + failWantMap() + } + } else if ni.Kind != MappingNode { + failWantMap() + } + d.unmarshal(ni, out) + } + default: + failWantMap() + } + + d.mergedFields = mergedFields +} + +func isMerge(n *Node) bool { + return n.Kind == ScalarNode && n.Value == "<<" && (n.Tag == "" || n.Tag == "!" || shortTag(n.Tag) == mergeTag) +} diff --git a/mcp/dsx-exchange-mcp/vendor/gopkg.in/yaml.v3/emitterc.go b/mcp/dsx-exchange-mcp/vendor/gopkg.in/yaml.v3/emitterc.go new file mode 100644 index 0000000..0f47c9c --- /dev/null +++ b/mcp/dsx-exchange-mcp/vendor/gopkg.in/yaml.v3/emitterc.go @@ -0,0 +1,2020 @@ +// +// Copyright (c) 2011-2019 Canonical Ltd +// Copyright (c) 2006-2010 Kirill Simonov +// +// Permission is hereby granted, free of charge, to any person obtaining a copy of +// this software and associated documentation files (the "Software"), to deal in +// the Software without restriction, including without limitation the rights to +// use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies +// of the Software, and to permit persons to whom the Software is furnished to do +// so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in all +// copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. + +package yaml + +import ( + "bytes" + "fmt" +) + +// Flush the buffer if needed. +func flush(emitter *yaml_emitter_t) bool { + if emitter.buffer_pos+5 >= len(emitter.buffer) { + return yaml_emitter_flush(emitter) + } + return true +} + +// Put a character to the output buffer. +func put(emitter *yaml_emitter_t, value byte) bool { + if emitter.buffer_pos+5 >= len(emitter.buffer) && !yaml_emitter_flush(emitter) { + return false + } + emitter.buffer[emitter.buffer_pos] = value + emitter.buffer_pos++ + emitter.column++ + return true +} + +// Put a line break to the output buffer. +func put_break(emitter *yaml_emitter_t) bool { + if emitter.buffer_pos+5 >= len(emitter.buffer) && !yaml_emitter_flush(emitter) { + return false + } + switch emitter.line_break { + case yaml_CR_BREAK: + emitter.buffer[emitter.buffer_pos] = '\r' + emitter.buffer_pos += 1 + case yaml_LN_BREAK: + emitter.buffer[emitter.buffer_pos] = '\n' + emitter.buffer_pos += 1 + case yaml_CRLN_BREAK: + emitter.buffer[emitter.buffer_pos+0] = '\r' + emitter.buffer[emitter.buffer_pos+1] = '\n' + emitter.buffer_pos += 2 + default: + panic("unknown line break setting") + } + if emitter.column == 0 { + emitter.space_above = true + } + emitter.column = 0 + emitter.line++ + // [Go] Do this here and below and drop from everywhere else (see commented lines). + emitter.indention = true + return true +} + +// Copy a character from a string into buffer. +func write(emitter *yaml_emitter_t, s []byte, i *int) bool { + if emitter.buffer_pos+5 >= len(emitter.buffer) && !yaml_emitter_flush(emitter) { + return false + } + p := emitter.buffer_pos + w := width(s[*i]) + switch w { + case 4: + emitter.buffer[p+3] = s[*i+3] + fallthrough + case 3: + emitter.buffer[p+2] = s[*i+2] + fallthrough + case 2: + emitter.buffer[p+1] = s[*i+1] + fallthrough + case 1: + emitter.buffer[p+0] = s[*i+0] + default: + panic("unknown character width") + } + emitter.column++ + emitter.buffer_pos += w + *i += w + return true +} + +// Write a whole string into buffer. +func write_all(emitter *yaml_emitter_t, s []byte) bool { + for i := 0; i < len(s); { + if !write(emitter, s, &i) { + return false + } + } + return true +} + +// Copy a line break character from a string into buffer. +func write_break(emitter *yaml_emitter_t, s []byte, i *int) bool { + if s[*i] == '\n' { + if !put_break(emitter) { + return false + } + *i++ + } else { + if !write(emitter, s, i) { + return false + } + if emitter.column == 0 { + emitter.space_above = true + } + emitter.column = 0 + emitter.line++ + // [Go] Do this here and above and drop from everywhere else (see commented lines). + emitter.indention = true + } + return true +} + +// Set an emitter error and return false. +func yaml_emitter_set_emitter_error(emitter *yaml_emitter_t, problem string) bool { + emitter.error = yaml_EMITTER_ERROR + emitter.problem = problem + return false +} + +// Emit an event. +func yaml_emitter_emit(emitter *yaml_emitter_t, event *yaml_event_t) bool { + emitter.events = append(emitter.events, *event) + for !yaml_emitter_need_more_events(emitter) { + event := &emitter.events[emitter.events_head] + if !yaml_emitter_analyze_event(emitter, event) { + return false + } + if !yaml_emitter_state_machine(emitter, event) { + return false + } + yaml_event_delete(event) + emitter.events_head++ + } + return true +} + +// Check if we need to accumulate more events before emitting. +// +// We accumulate extra +// - 1 event for DOCUMENT-START +// - 2 events for SEQUENCE-START +// - 3 events for MAPPING-START +// +func yaml_emitter_need_more_events(emitter *yaml_emitter_t) bool { + if emitter.events_head == len(emitter.events) { + return true + } + var accumulate int + switch emitter.events[emitter.events_head].typ { + case yaml_DOCUMENT_START_EVENT: + accumulate = 1 + break + case yaml_SEQUENCE_START_EVENT: + accumulate = 2 + break + case yaml_MAPPING_START_EVENT: + accumulate = 3 + break + default: + return false + } + if len(emitter.events)-emitter.events_head > accumulate { + return false + } + var level int + for i := emitter.events_head; i < len(emitter.events); i++ { + switch emitter.events[i].typ { + case yaml_STREAM_START_EVENT, yaml_DOCUMENT_START_EVENT, yaml_SEQUENCE_START_EVENT, yaml_MAPPING_START_EVENT: + level++ + case yaml_STREAM_END_EVENT, yaml_DOCUMENT_END_EVENT, yaml_SEQUENCE_END_EVENT, yaml_MAPPING_END_EVENT: + level-- + } + if level == 0 { + return false + } + } + return true +} + +// Append a directive to the directives stack. +func yaml_emitter_append_tag_directive(emitter *yaml_emitter_t, value *yaml_tag_directive_t, allow_duplicates bool) bool { + for i := 0; i < len(emitter.tag_directives); i++ { + if bytes.Equal(value.handle, emitter.tag_directives[i].handle) { + if allow_duplicates { + return true + } + return yaml_emitter_set_emitter_error(emitter, "duplicate %TAG directive") + } + } + + // [Go] Do we actually need to copy this given garbage collection + // and the lack of deallocating destructors? + tag_copy := yaml_tag_directive_t{ + handle: make([]byte, len(value.handle)), + prefix: make([]byte, len(value.prefix)), + } + copy(tag_copy.handle, value.handle) + copy(tag_copy.prefix, value.prefix) + emitter.tag_directives = append(emitter.tag_directives, tag_copy) + return true +} + +// Increase the indentation level. +func yaml_emitter_increase_indent(emitter *yaml_emitter_t, flow, indentless bool) bool { + emitter.indents = append(emitter.indents, emitter.indent) + if emitter.indent < 0 { + if flow { + emitter.indent = emitter.best_indent + } else { + emitter.indent = 0 + } + } else if !indentless { + // [Go] This was changed so that indentations are more regular. + if emitter.states[len(emitter.states)-1] == yaml_EMIT_BLOCK_SEQUENCE_ITEM_STATE { + // The first indent inside a sequence will just skip the "- " indicator. + emitter.indent += 2 + } else { + // Everything else aligns to the chosen indentation. + emitter.indent = emitter.best_indent*((emitter.indent+emitter.best_indent)/emitter.best_indent) + } + } + return true +} + +// State dispatcher. +func yaml_emitter_state_machine(emitter *yaml_emitter_t, event *yaml_event_t) bool { + switch emitter.state { + default: + case yaml_EMIT_STREAM_START_STATE: + return yaml_emitter_emit_stream_start(emitter, event) + + case yaml_EMIT_FIRST_DOCUMENT_START_STATE: + return yaml_emitter_emit_document_start(emitter, event, true) + + case yaml_EMIT_DOCUMENT_START_STATE: + return yaml_emitter_emit_document_start(emitter, event, false) + + case yaml_EMIT_DOCUMENT_CONTENT_STATE: + return yaml_emitter_emit_document_content(emitter, event) + + case yaml_EMIT_DOCUMENT_END_STATE: + return yaml_emitter_emit_document_end(emitter, event) + + case yaml_EMIT_FLOW_SEQUENCE_FIRST_ITEM_STATE: + return yaml_emitter_emit_flow_sequence_item(emitter, event, true, false) + + case yaml_EMIT_FLOW_SEQUENCE_TRAIL_ITEM_STATE: + return yaml_emitter_emit_flow_sequence_item(emitter, event, false, true) + + case yaml_EMIT_FLOW_SEQUENCE_ITEM_STATE: + return yaml_emitter_emit_flow_sequence_item(emitter, event, false, false) + + case yaml_EMIT_FLOW_MAPPING_FIRST_KEY_STATE: + return yaml_emitter_emit_flow_mapping_key(emitter, event, true, false) + + case yaml_EMIT_FLOW_MAPPING_TRAIL_KEY_STATE: + return yaml_emitter_emit_flow_mapping_key(emitter, event, false, true) + + case yaml_EMIT_FLOW_MAPPING_KEY_STATE: + return yaml_emitter_emit_flow_mapping_key(emitter, event, false, false) + + case yaml_EMIT_FLOW_MAPPING_SIMPLE_VALUE_STATE: + return yaml_emitter_emit_flow_mapping_value(emitter, event, true) + + case yaml_EMIT_FLOW_MAPPING_VALUE_STATE: + return yaml_emitter_emit_flow_mapping_value(emitter, event, false) + + case yaml_EMIT_BLOCK_SEQUENCE_FIRST_ITEM_STATE: + return yaml_emitter_emit_block_sequence_item(emitter, event, true) + + case yaml_EMIT_BLOCK_SEQUENCE_ITEM_STATE: + return yaml_emitter_emit_block_sequence_item(emitter, event, false) + + case yaml_EMIT_BLOCK_MAPPING_FIRST_KEY_STATE: + return yaml_emitter_emit_block_mapping_key(emitter, event, true) + + case yaml_EMIT_BLOCK_MAPPING_KEY_STATE: + return yaml_emitter_emit_block_mapping_key(emitter, event, false) + + case yaml_EMIT_BLOCK_MAPPING_SIMPLE_VALUE_STATE: + return yaml_emitter_emit_block_mapping_value(emitter, event, true) + + case yaml_EMIT_BLOCK_MAPPING_VALUE_STATE: + return yaml_emitter_emit_block_mapping_value(emitter, event, false) + + case yaml_EMIT_END_STATE: + return yaml_emitter_set_emitter_error(emitter, "expected nothing after STREAM-END") + } + panic("invalid emitter state") +} + +// Expect STREAM-START. +func yaml_emitter_emit_stream_start(emitter *yaml_emitter_t, event *yaml_event_t) bool { + if event.typ != yaml_STREAM_START_EVENT { + return yaml_emitter_set_emitter_error(emitter, "expected STREAM-START") + } + if emitter.encoding == yaml_ANY_ENCODING { + emitter.encoding = event.encoding + if emitter.encoding == yaml_ANY_ENCODING { + emitter.encoding = yaml_UTF8_ENCODING + } + } + if emitter.best_indent < 2 || emitter.best_indent > 9 { + emitter.best_indent = 2 + } + if emitter.best_width >= 0 && emitter.best_width <= emitter.best_indent*2 { + emitter.best_width = 80 + } + if emitter.best_width < 0 { + emitter.best_width = 1<<31 - 1 + } + if emitter.line_break == yaml_ANY_BREAK { + emitter.line_break = yaml_LN_BREAK + } + + emitter.indent = -1 + emitter.line = 0 + emitter.column = 0 + emitter.whitespace = true + emitter.indention = true + emitter.space_above = true + emitter.foot_indent = -1 + + if emitter.encoding != yaml_UTF8_ENCODING { + if !yaml_emitter_write_bom(emitter) { + return false + } + } + emitter.state = yaml_EMIT_FIRST_DOCUMENT_START_STATE + return true +} + +// Expect DOCUMENT-START or STREAM-END. +func yaml_emitter_emit_document_start(emitter *yaml_emitter_t, event *yaml_event_t, first bool) bool { + + if event.typ == yaml_DOCUMENT_START_EVENT { + + if event.version_directive != nil { + if !yaml_emitter_analyze_version_directive(emitter, event.version_directive) { + return false + } + } + + for i := 0; i < len(event.tag_directives); i++ { + tag_directive := &event.tag_directives[i] + if !yaml_emitter_analyze_tag_directive(emitter, tag_directive) { + return false + } + if !yaml_emitter_append_tag_directive(emitter, tag_directive, false) { + return false + } + } + + for i := 0; i < len(default_tag_directives); i++ { + tag_directive := &default_tag_directives[i] + if !yaml_emitter_append_tag_directive(emitter, tag_directive, true) { + return false + } + } + + implicit := event.implicit + if !first || emitter.canonical { + implicit = false + } + + if emitter.open_ended && (event.version_directive != nil || len(event.tag_directives) > 0) { + if !yaml_emitter_write_indicator(emitter, []byte("..."), true, false, false) { + return false + } + if !yaml_emitter_write_indent(emitter) { + return false + } + } + + if event.version_directive != nil { + implicit = false + if !yaml_emitter_write_indicator(emitter, []byte("%YAML"), true, false, false) { + return false + } + if !yaml_emitter_write_indicator(emitter, []byte("1.1"), true, false, false) { + return false + } + if !yaml_emitter_write_indent(emitter) { + return false + } + } + + if len(event.tag_directives) > 0 { + implicit = false + for i := 0; i < len(event.tag_directives); i++ { + tag_directive := &event.tag_directives[i] + if !yaml_emitter_write_indicator(emitter, []byte("%TAG"), true, false, false) { + return false + } + if !yaml_emitter_write_tag_handle(emitter, tag_directive.handle) { + return false + } + if !yaml_emitter_write_tag_content(emitter, tag_directive.prefix, true) { + return false + } + if !yaml_emitter_write_indent(emitter) { + return false + } + } + } + + if yaml_emitter_check_empty_document(emitter) { + implicit = false + } + if !implicit { + if !yaml_emitter_write_indent(emitter) { + return false + } + if !yaml_emitter_write_indicator(emitter, []byte("---"), true, false, false) { + return false + } + if emitter.canonical || true { + if !yaml_emitter_write_indent(emitter) { + return false + } + } + } + + if len(emitter.head_comment) > 0 { + if !yaml_emitter_process_head_comment(emitter) { + return false + } + if !put_break(emitter) { + return false + } + } + + emitter.state = yaml_EMIT_DOCUMENT_CONTENT_STATE + return true + } + + if event.typ == yaml_STREAM_END_EVENT { + if emitter.open_ended { + if !yaml_emitter_write_indicator(emitter, []byte("..."), true, false, false) { + return false + } + if !yaml_emitter_write_indent(emitter) { + return false + } + } + if !yaml_emitter_flush(emitter) { + return false + } + emitter.state = yaml_EMIT_END_STATE + return true + } + + return yaml_emitter_set_emitter_error(emitter, "expected DOCUMENT-START or STREAM-END") +} + +// Expect the root node. +func yaml_emitter_emit_document_content(emitter *yaml_emitter_t, event *yaml_event_t) bool { + emitter.states = append(emitter.states, yaml_EMIT_DOCUMENT_END_STATE) + + if !yaml_emitter_process_head_comment(emitter) { + return false + } + if !yaml_emitter_emit_node(emitter, event, true, false, false, false) { + return false + } + if !yaml_emitter_process_line_comment(emitter) { + return false + } + if !yaml_emitter_process_foot_comment(emitter) { + return false + } + return true +} + +// Expect DOCUMENT-END. +func yaml_emitter_emit_document_end(emitter *yaml_emitter_t, event *yaml_event_t) bool { + if event.typ != yaml_DOCUMENT_END_EVENT { + return yaml_emitter_set_emitter_error(emitter, "expected DOCUMENT-END") + } + // [Go] Force document foot separation. + emitter.foot_indent = 0 + if !yaml_emitter_process_foot_comment(emitter) { + return false + } + emitter.foot_indent = -1 + if !yaml_emitter_write_indent(emitter) { + return false + } + if !event.implicit { + // [Go] Allocate the slice elsewhere. + if !yaml_emitter_write_indicator(emitter, []byte("..."), true, false, false) { + return false + } + if !yaml_emitter_write_indent(emitter) { + return false + } + } + if !yaml_emitter_flush(emitter) { + return false + } + emitter.state = yaml_EMIT_DOCUMENT_START_STATE + emitter.tag_directives = emitter.tag_directives[:0] + return true +} + +// Expect a flow item node. +func yaml_emitter_emit_flow_sequence_item(emitter *yaml_emitter_t, event *yaml_event_t, first, trail bool) bool { + if first { + if !yaml_emitter_write_indicator(emitter, []byte{'['}, true, true, false) { + return false + } + if !yaml_emitter_increase_indent(emitter, true, false) { + return false + } + emitter.flow_level++ + } + + if event.typ == yaml_SEQUENCE_END_EVENT { + if emitter.canonical && !first && !trail { + if !yaml_emitter_write_indicator(emitter, []byte{','}, false, false, false) { + return false + } + } + emitter.flow_level-- + emitter.indent = emitter.indents[len(emitter.indents)-1] + emitter.indents = emitter.indents[:len(emitter.indents)-1] + if emitter.column == 0 || emitter.canonical && !first { + if !yaml_emitter_write_indent(emitter) { + return false + } + } + if !yaml_emitter_write_indicator(emitter, []byte{']'}, false, false, false) { + return false + } + if !yaml_emitter_process_line_comment(emitter) { + return false + } + if !yaml_emitter_process_foot_comment(emitter) { + return false + } + emitter.state = emitter.states[len(emitter.states)-1] + emitter.states = emitter.states[:len(emitter.states)-1] + + return true + } + + if !first && !trail { + if !yaml_emitter_write_indicator(emitter, []byte{','}, false, false, false) { + return false + } + } + + if !yaml_emitter_process_head_comment(emitter) { + return false + } + if emitter.column == 0 { + if !yaml_emitter_write_indent(emitter) { + return false + } + } + + if emitter.canonical || emitter.column > emitter.best_width { + if !yaml_emitter_write_indent(emitter) { + return false + } + } + if len(emitter.line_comment)+len(emitter.foot_comment)+len(emitter.tail_comment) > 0 { + emitter.states = append(emitter.states, yaml_EMIT_FLOW_SEQUENCE_TRAIL_ITEM_STATE) + } else { + emitter.states = append(emitter.states, yaml_EMIT_FLOW_SEQUENCE_ITEM_STATE) + } + if !yaml_emitter_emit_node(emitter, event, false, true, false, false) { + return false + } + if len(emitter.line_comment)+len(emitter.foot_comment)+len(emitter.tail_comment) > 0 { + if !yaml_emitter_write_indicator(emitter, []byte{','}, false, false, false) { + return false + } + } + if !yaml_emitter_process_line_comment(emitter) { + return false + } + if !yaml_emitter_process_foot_comment(emitter) { + return false + } + return true +} + +// Expect a flow key node. +func yaml_emitter_emit_flow_mapping_key(emitter *yaml_emitter_t, event *yaml_event_t, first, trail bool) bool { + if first { + if !yaml_emitter_write_indicator(emitter, []byte{'{'}, true, true, false) { + return false + } + if !yaml_emitter_increase_indent(emitter, true, false) { + return false + } + emitter.flow_level++ + } + + if event.typ == yaml_MAPPING_END_EVENT { + if (emitter.canonical || len(emitter.head_comment)+len(emitter.foot_comment)+len(emitter.tail_comment) > 0) && !first && !trail { + if !yaml_emitter_write_indicator(emitter, []byte{','}, false, false, false) { + return false + } + } + if !yaml_emitter_process_head_comment(emitter) { + return false + } + emitter.flow_level-- + emitter.indent = emitter.indents[len(emitter.indents)-1] + emitter.indents = emitter.indents[:len(emitter.indents)-1] + if emitter.canonical && !first { + if !yaml_emitter_write_indent(emitter) { + return false + } + } + if !yaml_emitter_write_indicator(emitter, []byte{'}'}, false, false, false) { + return false + } + if !yaml_emitter_process_line_comment(emitter) { + return false + } + if !yaml_emitter_process_foot_comment(emitter) { + return false + } + emitter.state = emitter.states[len(emitter.states)-1] + emitter.states = emitter.states[:len(emitter.states)-1] + return true + } + + if !first && !trail { + if !yaml_emitter_write_indicator(emitter, []byte{','}, false, false, false) { + return false + } + } + + if !yaml_emitter_process_head_comment(emitter) { + return false + } + + if emitter.column == 0 { + if !yaml_emitter_write_indent(emitter) { + return false + } + } + + if emitter.canonical || emitter.column > emitter.best_width { + if !yaml_emitter_write_indent(emitter) { + return false + } + } + + if !emitter.canonical && yaml_emitter_check_simple_key(emitter) { + emitter.states = append(emitter.states, yaml_EMIT_FLOW_MAPPING_SIMPLE_VALUE_STATE) + return yaml_emitter_emit_node(emitter, event, false, false, true, true) + } + if !yaml_emitter_write_indicator(emitter, []byte{'?'}, true, false, false) { + return false + } + emitter.states = append(emitter.states, yaml_EMIT_FLOW_MAPPING_VALUE_STATE) + return yaml_emitter_emit_node(emitter, event, false, false, true, false) +} + +// Expect a flow value node. +func yaml_emitter_emit_flow_mapping_value(emitter *yaml_emitter_t, event *yaml_event_t, simple bool) bool { + if simple { + if !yaml_emitter_write_indicator(emitter, []byte{':'}, false, false, false) { + return false + } + } else { + if emitter.canonical || emitter.column > emitter.best_width { + if !yaml_emitter_write_indent(emitter) { + return false + } + } + if !yaml_emitter_write_indicator(emitter, []byte{':'}, true, false, false) { + return false + } + } + if len(emitter.line_comment)+len(emitter.foot_comment)+len(emitter.tail_comment) > 0 { + emitter.states = append(emitter.states, yaml_EMIT_FLOW_MAPPING_TRAIL_KEY_STATE) + } else { + emitter.states = append(emitter.states, yaml_EMIT_FLOW_MAPPING_KEY_STATE) + } + if !yaml_emitter_emit_node(emitter, event, false, false, true, false) { + return false + } + if len(emitter.line_comment)+len(emitter.foot_comment)+len(emitter.tail_comment) > 0 { + if !yaml_emitter_write_indicator(emitter, []byte{','}, false, false, false) { + return false + } + } + if !yaml_emitter_process_line_comment(emitter) { + return false + } + if !yaml_emitter_process_foot_comment(emitter) { + return false + } + return true +} + +// Expect a block item node. +func yaml_emitter_emit_block_sequence_item(emitter *yaml_emitter_t, event *yaml_event_t, first bool) bool { + if first { + if !yaml_emitter_increase_indent(emitter, false, false) { + return false + } + } + if event.typ == yaml_SEQUENCE_END_EVENT { + emitter.indent = emitter.indents[len(emitter.indents)-1] + emitter.indents = emitter.indents[:len(emitter.indents)-1] + emitter.state = emitter.states[len(emitter.states)-1] + emitter.states = emitter.states[:len(emitter.states)-1] + return true + } + if !yaml_emitter_process_head_comment(emitter) { + return false + } + if !yaml_emitter_write_indent(emitter) { + return false + } + if !yaml_emitter_write_indicator(emitter, []byte{'-'}, true, false, true) { + return false + } + emitter.states = append(emitter.states, yaml_EMIT_BLOCK_SEQUENCE_ITEM_STATE) + if !yaml_emitter_emit_node(emitter, event, false, true, false, false) { + return false + } + if !yaml_emitter_process_line_comment(emitter) { + return false + } + if !yaml_emitter_process_foot_comment(emitter) { + return false + } + return true +} + +// Expect a block key node. +func yaml_emitter_emit_block_mapping_key(emitter *yaml_emitter_t, event *yaml_event_t, first bool) bool { + if first { + if !yaml_emitter_increase_indent(emitter, false, false) { + return false + } + } + if !yaml_emitter_process_head_comment(emitter) { + return false + } + if event.typ == yaml_MAPPING_END_EVENT { + emitter.indent = emitter.indents[len(emitter.indents)-1] + emitter.indents = emitter.indents[:len(emitter.indents)-1] + emitter.state = emitter.states[len(emitter.states)-1] + emitter.states = emitter.states[:len(emitter.states)-1] + return true + } + if !yaml_emitter_write_indent(emitter) { + return false + } + if len(emitter.line_comment) > 0 { + // [Go] A line comment was provided for the key. That's unusual as the + // scanner associates line comments with the value. Either way, + // save the line comment and render it appropriately later. + emitter.key_line_comment = emitter.line_comment + emitter.line_comment = nil + } + if yaml_emitter_check_simple_key(emitter) { + emitter.states = append(emitter.states, yaml_EMIT_BLOCK_MAPPING_SIMPLE_VALUE_STATE) + return yaml_emitter_emit_node(emitter, event, false, false, true, true) + } + if !yaml_emitter_write_indicator(emitter, []byte{'?'}, true, false, true) { + return false + } + emitter.states = append(emitter.states, yaml_EMIT_BLOCK_MAPPING_VALUE_STATE) + return yaml_emitter_emit_node(emitter, event, false, false, true, false) +} + +// Expect a block value node. +func yaml_emitter_emit_block_mapping_value(emitter *yaml_emitter_t, event *yaml_event_t, simple bool) bool { + if simple { + if !yaml_emitter_write_indicator(emitter, []byte{':'}, false, false, false) { + return false + } + } else { + if !yaml_emitter_write_indent(emitter) { + return false + } + if !yaml_emitter_write_indicator(emitter, []byte{':'}, true, false, true) { + return false + } + } + if len(emitter.key_line_comment) > 0 { + // [Go] Line comments are generally associated with the value, but when there's + // no value on the same line as a mapping key they end up attached to the + // key itself. + if event.typ == yaml_SCALAR_EVENT { + if len(emitter.line_comment) == 0 { + // A scalar is coming and it has no line comments by itself yet, + // so just let it handle the line comment as usual. If it has a + // line comment, we can't have both so the one from the key is lost. + emitter.line_comment = emitter.key_line_comment + emitter.key_line_comment = nil + } + } else if event.sequence_style() != yaml_FLOW_SEQUENCE_STYLE && (event.typ == yaml_MAPPING_START_EVENT || event.typ == yaml_SEQUENCE_START_EVENT) { + // An indented block follows, so write the comment right now. + emitter.line_comment, emitter.key_line_comment = emitter.key_line_comment, emitter.line_comment + if !yaml_emitter_process_line_comment(emitter) { + return false + } + emitter.line_comment, emitter.key_line_comment = emitter.key_line_comment, emitter.line_comment + } + } + emitter.states = append(emitter.states, yaml_EMIT_BLOCK_MAPPING_KEY_STATE) + if !yaml_emitter_emit_node(emitter, event, false, false, true, false) { + return false + } + if !yaml_emitter_process_line_comment(emitter) { + return false + } + if !yaml_emitter_process_foot_comment(emitter) { + return false + } + return true +} + +func yaml_emitter_silent_nil_event(emitter *yaml_emitter_t, event *yaml_event_t) bool { + return event.typ == yaml_SCALAR_EVENT && event.implicit && !emitter.canonical && len(emitter.scalar_data.value) == 0 +} + +// Expect a node. +func yaml_emitter_emit_node(emitter *yaml_emitter_t, event *yaml_event_t, + root bool, sequence bool, mapping bool, simple_key bool) bool { + + emitter.root_context = root + emitter.sequence_context = sequence + emitter.mapping_context = mapping + emitter.simple_key_context = simple_key + + switch event.typ { + case yaml_ALIAS_EVENT: + return yaml_emitter_emit_alias(emitter, event) + case yaml_SCALAR_EVENT: + return yaml_emitter_emit_scalar(emitter, event) + case yaml_SEQUENCE_START_EVENT: + return yaml_emitter_emit_sequence_start(emitter, event) + case yaml_MAPPING_START_EVENT: + return yaml_emitter_emit_mapping_start(emitter, event) + default: + return yaml_emitter_set_emitter_error(emitter, + fmt.Sprintf("expected SCALAR, SEQUENCE-START, MAPPING-START, or ALIAS, but got %v", event.typ)) + } +} + +// Expect ALIAS. +func yaml_emitter_emit_alias(emitter *yaml_emitter_t, event *yaml_event_t) bool { + if !yaml_emitter_process_anchor(emitter) { + return false + } + emitter.state = emitter.states[len(emitter.states)-1] + emitter.states = emitter.states[:len(emitter.states)-1] + return true +} + +// Expect SCALAR. +func yaml_emitter_emit_scalar(emitter *yaml_emitter_t, event *yaml_event_t) bool { + if !yaml_emitter_select_scalar_style(emitter, event) { + return false + } + if !yaml_emitter_process_anchor(emitter) { + return false + } + if !yaml_emitter_process_tag(emitter) { + return false + } + if !yaml_emitter_increase_indent(emitter, true, false) { + return false + } + if !yaml_emitter_process_scalar(emitter) { + return false + } + emitter.indent = emitter.indents[len(emitter.indents)-1] + emitter.indents = emitter.indents[:len(emitter.indents)-1] + emitter.state = emitter.states[len(emitter.states)-1] + emitter.states = emitter.states[:len(emitter.states)-1] + return true +} + +// Expect SEQUENCE-START. +func yaml_emitter_emit_sequence_start(emitter *yaml_emitter_t, event *yaml_event_t) bool { + if !yaml_emitter_process_anchor(emitter) { + return false + } + if !yaml_emitter_process_tag(emitter) { + return false + } + if emitter.flow_level > 0 || emitter.canonical || event.sequence_style() == yaml_FLOW_SEQUENCE_STYLE || + yaml_emitter_check_empty_sequence(emitter) { + emitter.state = yaml_EMIT_FLOW_SEQUENCE_FIRST_ITEM_STATE + } else { + emitter.state = yaml_EMIT_BLOCK_SEQUENCE_FIRST_ITEM_STATE + } + return true +} + +// Expect MAPPING-START. +func yaml_emitter_emit_mapping_start(emitter *yaml_emitter_t, event *yaml_event_t) bool { + if !yaml_emitter_process_anchor(emitter) { + return false + } + if !yaml_emitter_process_tag(emitter) { + return false + } + if emitter.flow_level > 0 || emitter.canonical || event.mapping_style() == yaml_FLOW_MAPPING_STYLE || + yaml_emitter_check_empty_mapping(emitter) { + emitter.state = yaml_EMIT_FLOW_MAPPING_FIRST_KEY_STATE + } else { + emitter.state = yaml_EMIT_BLOCK_MAPPING_FIRST_KEY_STATE + } + return true +} + +// Check if the document content is an empty scalar. +func yaml_emitter_check_empty_document(emitter *yaml_emitter_t) bool { + return false // [Go] Huh? +} + +// Check if the next events represent an empty sequence. +func yaml_emitter_check_empty_sequence(emitter *yaml_emitter_t) bool { + if len(emitter.events)-emitter.events_head < 2 { + return false + } + return emitter.events[emitter.events_head].typ == yaml_SEQUENCE_START_EVENT && + emitter.events[emitter.events_head+1].typ == yaml_SEQUENCE_END_EVENT +} + +// Check if the next events represent an empty mapping. +func yaml_emitter_check_empty_mapping(emitter *yaml_emitter_t) bool { + if len(emitter.events)-emitter.events_head < 2 { + return false + } + return emitter.events[emitter.events_head].typ == yaml_MAPPING_START_EVENT && + emitter.events[emitter.events_head+1].typ == yaml_MAPPING_END_EVENT +} + +// Check if the next node can be expressed as a simple key. +func yaml_emitter_check_simple_key(emitter *yaml_emitter_t) bool { + length := 0 + switch emitter.events[emitter.events_head].typ { + case yaml_ALIAS_EVENT: + length += len(emitter.anchor_data.anchor) + case yaml_SCALAR_EVENT: + if emitter.scalar_data.multiline { + return false + } + length += len(emitter.anchor_data.anchor) + + len(emitter.tag_data.handle) + + len(emitter.tag_data.suffix) + + len(emitter.scalar_data.value) + case yaml_SEQUENCE_START_EVENT: + if !yaml_emitter_check_empty_sequence(emitter) { + return false + } + length += len(emitter.anchor_data.anchor) + + len(emitter.tag_data.handle) + + len(emitter.tag_data.suffix) + case yaml_MAPPING_START_EVENT: + if !yaml_emitter_check_empty_mapping(emitter) { + return false + } + length += len(emitter.anchor_data.anchor) + + len(emitter.tag_data.handle) + + len(emitter.tag_data.suffix) + default: + return false + } + return length <= 128 +} + +// Determine an acceptable scalar style. +func yaml_emitter_select_scalar_style(emitter *yaml_emitter_t, event *yaml_event_t) bool { + + no_tag := len(emitter.tag_data.handle) == 0 && len(emitter.tag_data.suffix) == 0 + if no_tag && !event.implicit && !event.quoted_implicit { + return yaml_emitter_set_emitter_error(emitter, "neither tag nor implicit flags are specified") + } + + style := event.scalar_style() + if style == yaml_ANY_SCALAR_STYLE { + style = yaml_PLAIN_SCALAR_STYLE + } + if emitter.canonical { + style = yaml_DOUBLE_QUOTED_SCALAR_STYLE + } + if emitter.simple_key_context && emitter.scalar_data.multiline { + style = yaml_DOUBLE_QUOTED_SCALAR_STYLE + } + + if style == yaml_PLAIN_SCALAR_STYLE { + if emitter.flow_level > 0 && !emitter.scalar_data.flow_plain_allowed || + emitter.flow_level == 0 && !emitter.scalar_data.block_plain_allowed { + style = yaml_SINGLE_QUOTED_SCALAR_STYLE + } + if len(emitter.scalar_data.value) == 0 && (emitter.flow_level > 0 || emitter.simple_key_context) { + style = yaml_SINGLE_QUOTED_SCALAR_STYLE + } + if no_tag && !event.implicit { + style = yaml_SINGLE_QUOTED_SCALAR_STYLE + } + } + if style == yaml_SINGLE_QUOTED_SCALAR_STYLE { + if !emitter.scalar_data.single_quoted_allowed { + style = yaml_DOUBLE_QUOTED_SCALAR_STYLE + } + } + if style == yaml_LITERAL_SCALAR_STYLE || style == yaml_FOLDED_SCALAR_STYLE { + if !emitter.scalar_data.block_allowed || emitter.flow_level > 0 || emitter.simple_key_context { + style = yaml_DOUBLE_QUOTED_SCALAR_STYLE + } + } + + if no_tag && !event.quoted_implicit && style != yaml_PLAIN_SCALAR_STYLE { + emitter.tag_data.handle = []byte{'!'} + } + emitter.scalar_data.style = style + return true +} + +// Write an anchor. +func yaml_emitter_process_anchor(emitter *yaml_emitter_t) bool { + if emitter.anchor_data.anchor == nil { + return true + } + c := []byte{'&'} + if emitter.anchor_data.alias { + c[0] = '*' + } + if !yaml_emitter_write_indicator(emitter, c, true, false, false) { + return false + } + return yaml_emitter_write_anchor(emitter, emitter.anchor_data.anchor) +} + +// Write a tag. +func yaml_emitter_process_tag(emitter *yaml_emitter_t) bool { + if len(emitter.tag_data.handle) == 0 && len(emitter.tag_data.suffix) == 0 { + return true + } + if len(emitter.tag_data.handle) > 0 { + if !yaml_emitter_write_tag_handle(emitter, emitter.tag_data.handle) { + return false + } + if len(emitter.tag_data.suffix) > 0 { + if !yaml_emitter_write_tag_content(emitter, emitter.tag_data.suffix, false) { + return false + } + } + } else { + // [Go] Allocate these slices elsewhere. + if !yaml_emitter_write_indicator(emitter, []byte("!<"), true, false, false) { + return false + } + if !yaml_emitter_write_tag_content(emitter, emitter.tag_data.suffix, false) { + return false + } + if !yaml_emitter_write_indicator(emitter, []byte{'>'}, false, false, false) { + return false + } + } + return true +} + +// Write a scalar. +func yaml_emitter_process_scalar(emitter *yaml_emitter_t) bool { + switch emitter.scalar_data.style { + case yaml_PLAIN_SCALAR_STYLE: + return yaml_emitter_write_plain_scalar(emitter, emitter.scalar_data.value, !emitter.simple_key_context) + + case yaml_SINGLE_QUOTED_SCALAR_STYLE: + return yaml_emitter_write_single_quoted_scalar(emitter, emitter.scalar_data.value, !emitter.simple_key_context) + + case yaml_DOUBLE_QUOTED_SCALAR_STYLE: + return yaml_emitter_write_double_quoted_scalar(emitter, emitter.scalar_data.value, !emitter.simple_key_context) + + case yaml_LITERAL_SCALAR_STYLE: + return yaml_emitter_write_literal_scalar(emitter, emitter.scalar_data.value) + + case yaml_FOLDED_SCALAR_STYLE: + return yaml_emitter_write_folded_scalar(emitter, emitter.scalar_data.value) + } + panic("unknown scalar style") +} + +// Write a head comment. +func yaml_emitter_process_head_comment(emitter *yaml_emitter_t) bool { + if len(emitter.tail_comment) > 0 { + if !yaml_emitter_write_indent(emitter) { + return false + } + if !yaml_emitter_write_comment(emitter, emitter.tail_comment) { + return false + } + emitter.tail_comment = emitter.tail_comment[:0] + emitter.foot_indent = emitter.indent + if emitter.foot_indent < 0 { + emitter.foot_indent = 0 + } + } + + if len(emitter.head_comment) == 0 { + return true + } + if !yaml_emitter_write_indent(emitter) { + return false + } + if !yaml_emitter_write_comment(emitter, emitter.head_comment) { + return false + } + emitter.head_comment = emitter.head_comment[:0] + return true +} + +// Write an line comment. +func yaml_emitter_process_line_comment(emitter *yaml_emitter_t) bool { + if len(emitter.line_comment) == 0 { + return true + } + if !emitter.whitespace { + if !put(emitter, ' ') { + return false + } + } + if !yaml_emitter_write_comment(emitter, emitter.line_comment) { + return false + } + emitter.line_comment = emitter.line_comment[:0] + return true +} + +// Write a foot comment. +func yaml_emitter_process_foot_comment(emitter *yaml_emitter_t) bool { + if len(emitter.foot_comment) == 0 { + return true + } + if !yaml_emitter_write_indent(emitter) { + return false + } + if !yaml_emitter_write_comment(emitter, emitter.foot_comment) { + return false + } + emitter.foot_comment = emitter.foot_comment[:0] + emitter.foot_indent = emitter.indent + if emitter.foot_indent < 0 { + emitter.foot_indent = 0 + } + return true +} + +// Check if a %YAML directive is valid. +func yaml_emitter_analyze_version_directive(emitter *yaml_emitter_t, version_directive *yaml_version_directive_t) bool { + if version_directive.major != 1 || version_directive.minor != 1 { + return yaml_emitter_set_emitter_error(emitter, "incompatible %YAML directive") + } + return true +} + +// Check if a %TAG directive is valid. +func yaml_emitter_analyze_tag_directive(emitter *yaml_emitter_t, tag_directive *yaml_tag_directive_t) bool { + handle := tag_directive.handle + prefix := tag_directive.prefix + if len(handle) == 0 { + return yaml_emitter_set_emitter_error(emitter, "tag handle must not be empty") + } + if handle[0] != '!' { + return yaml_emitter_set_emitter_error(emitter, "tag handle must start with '!'") + } + if handle[len(handle)-1] != '!' { + return yaml_emitter_set_emitter_error(emitter, "tag handle must end with '!'") + } + for i := 1; i < len(handle)-1; i += width(handle[i]) { + if !is_alpha(handle, i) { + return yaml_emitter_set_emitter_error(emitter, "tag handle must contain alphanumerical characters only") + } + } + if len(prefix) == 0 { + return yaml_emitter_set_emitter_error(emitter, "tag prefix must not be empty") + } + return true +} + +// Check if an anchor is valid. +func yaml_emitter_analyze_anchor(emitter *yaml_emitter_t, anchor []byte, alias bool) bool { + if len(anchor) == 0 { + problem := "anchor value must not be empty" + if alias { + problem = "alias value must not be empty" + } + return yaml_emitter_set_emitter_error(emitter, problem) + } + for i := 0; i < len(anchor); i += width(anchor[i]) { + if !is_alpha(anchor, i) { + problem := "anchor value must contain alphanumerical characters only" + if alias { + problem = "alias value must contain alphanumerical characters only" + } + return yaml_emitter_set_emitter_error(emitter, problem) + } + } + emitter.anchor_data.anchor = anchor + emitter.anchor_data.alias = alias + return true +} + +// Check if a tag is valid. +func yaml_emitter_analyze_tag(emitter *yaml_emitter_t, tag []byte) bool { + if len(tag) == 0 { + return yaml_emitter_set_emitter_error(emitter, "tag value must not be empty") + } + for i := 0; i < len(emitter.tag_directives); i++ { + tag_directive := &emitter.tag_directives[i] + if bytes.HasPrefix(tag, tag_directive.prefix) { + emitter.tag_data.handle = tag_directive.handle + emitter.tag_data.suffix = tag[len(tag_directive.prefix):] + return true + } + } + emitter.tag_data.suffix = tag + return true +} + +// Check if a scalar is valid. +func yaml_emitter_analyze_scalar(emitter *yaml_emitter_t, value []byte) bool { + var ( + block_indicators = false + flow_indicators = false + line_breaks = false + special_characters = false + tab_characters = false + + leading_space = false + leading_break = false + trailing_space = false + trailing_break = false + break_space = false + space_break = false + + preceded_by_whitespace = false + followed_by_whitespace = false + previous_space = false + previous_break = false + ) + + emitter.scalar_data.value = value + + if len(value) == 0 { + emitter.scalar_data.multiline = false + emitter.scalar_data.flow_plain_allowed = false + emitter.scalar_data.block_plain_allowed = true + emitter.scalar_data.single_quoted_allowed = true + emitter.scalar_data.block_allowed = false + return true + } + + if len(value) >= 3 && ((value[0] == '-' && value[1] == '-' && value[2] == '-') || (value[0] == '.' && value[1] == '.' && value[2] == '.')) { + block_indicators = true + flow_indicators = true + } + + preceded_by_whitespace = true + for i, w := 0, 0; i < len(value); i += w { + w = width(value[i]) + followed_by_whitespace = i+w >= len(value) || is_blank(value, i+w) + + if i == 0 { + switch value[i] { + case '#', ',', '[', ']', '{', '}', '&', '*', '!', '|', '>', '\'', '"', '%', '@', '`': + flow_indicators = true + block_indicators = true + case '?', ':': + flow_indicators = true + if followed_by_whitespace { + block_indicators = true + } + case '-': + if followed_by_whitespace { + flow_indicators = true + block_indicators = true + } + } + } else { + switch value[i] { + case ',', '?', '[', ']', '{', '}': + flow_indicators = true + case ':': + flow_indicators = true + if followed_by_whitespace { + block_indicators = true + } + case '#': + if preceded_by_whitespace { + flow_indicators = true + block_indicators = true + } + } + } + + if value[i] == '\t' { + tab_characters = true + } else if !is_printable(value, i) || !is_ascii(value, i) && !emitter.unicode { + special_characters = true + } + if is_space(value, i) { + if i == 0 { + leading_space = true + } + if i+width(value[i]) == len(value) { + trailing_space = true + } + if previous_break { + break_space = true + } + previous_space = true + previous_break = false + } else if is_break(value, i) { + line_breaks = true + if i == 0 { + leading_break = true + } + if i+width(value[i]) == len(value) { + trailing_break = true + } + if previous_space { + space_break = true + } + previous_space = false + previous_break = true + } else { + previous_space = false + previous_break = false + } + + // [Go]: Why 'z'? Couldn't be the end of the string as that's the loop condition. + preceded_by_whitespace = is_blankz(value, i) + } + + emitter.scalar_data.multiline = line_breaks + emitter.scalar_data.flow_plain_allowed = true + emitter.scalar_data.block_plain_allowed = true + emitter.scalar_data.single_quoted_allowed = true + emitter.scalar_data.block_allowed = true + + if leading_space || leading_break || trailing_space || trailing_break { + emitter.scalar_data.flow_plain_allowed = false + emitter.scalar_data.block_plain_allowed = false + } + if trailing_space { + emitter.scalar_data.block_allowed = false + } + if break_space { + emitter.scalar_data.flow_plain_allowed = false + emitter.scalar_data.block_plain_allowed = false + emitter.scalar_data.single_quoted_allowed = false + } + if space_break || tab_characters || special_characters { + emitter.scalar_data.flow_plain_allowed = false + emitter.scalar_data.block_plain_allowed = false + emitter.scalar_data.single_quoted_allowed = false + } + if space_break || special_characters { + emitter.scalar_data.block_allowed = false + } + if line_breaks { + emitter.scalar_data.flow_plain_allowed = false + emitter.scalar_data.block_plain_allowed = false + } + if flow_indicators { + emitter.scalar_data.flow_plain_allowed = false + } + if block_indicators { + emitter.scalar_data.block_plain_allowed = false + } + return true +} + +// Check if the event data is valid. +func yaml_emitter_analyze_event(emitter *yaml_emitter_t, event *yaml_event_t) bool { + + emitter.anchor_data.anchor = nil + emitter.tag_data.handle = nil + emitter.tag_data.suffix = nil + emitter.scalar_data.value = nil + + if len(event.head_comment) > 0 { + emitter.head_comment = event.head_comment + } + if len(event.line_comment) > 0 { + emitter.line_comment = event.line_comment + } + if len(event.foot_comment) > 0 { + emitter.foot_comment = event.foot_comment + } + if len(event.tail_comment) > 0 { + emitter.tail_comment = event.tail_comment + } + + switch event.typ { + case yaml_ALIAS_EVENT: + if !yaml_emitter_analyze_anchor(emitter, event.anchor, true) { + return false + } + + case yaml_SCALAR_EVENT: + if len(event.anchor) > 0 { + if !yaml_emitter_analyze_anchor(emitter, event.anchor, false) { + return false + } + } + if len(event.tag) > 0 && (emitter.canonical || (!event.implicit && !event.quoted_implicit)) { + if !yaml_emitter_analyze_tag(emitter, event.tag) { + return false + } + } + if !yaml_emitter_analyze_scalar(emitter, event.value) { + return false + } + + case yaml_SEQUENCE_START_EVENT: + if len(event.anchor) > 0 { + if !yaml_emitter_analyze_anchor(emitter, event.anchor, false) { + return false + } + } + if len(event.tag) > 0 && (emitter.canonical || !event.implicit) { + if !yaml_emitter_analyze_tag(emitter, event.tag) { + return false + } + } + + case yaml_MAPPING_START_EVENT: + if len(event.anchor) > 0 { + if !yaml_emitter_analyze_anchor(emitter, event.anchor, false) { + return false + } + } + if len(event.tag) > 0 && (emitter.canonical || !event.implicit) { + if !yaml_emitter_analyze_tag(emitter, event.tag) { + return false + } + } + } + return true +} + +// Write the BOM character. +func yaml_emitter_write_bom(emitter *yaml_emitter_t) bool { + if !flush(emitter) { + return false + } + pos := emitter.buffer_pos + emitter.buffer[pos+0] = '\xEF' + emitter.buffer[pos+1] = '\xBB' + emitter.buffer[pos+2] = '\xBF' + emitter.buffer_pos += 3 + return true +} + +func yaml_emitter_write_indent(emitter *yaml_emitter_t) bool { + indent := emitter.indent + if indent < 0 { + indent = 0 + } + if !emitter.indention || emitter.column > indent || (emitter.column == indent && !emitter.whitespace) { + if !put_break(emitter) { + return false + } + } + if emitter.foot_indent == indent { + if !put_break(emitter) { + return false + } + } + for emitter.column < indent { + if !put(emitter, ' ') { + return false + } + } + emitter.whitespace = true + //emitter.indention = true + emitter.space_above = false + emitter.foot_indent = -1 + return true +} + +func yaml_emitter_write_indicator(emitter *yaml_emitter_t, indicator []byte, need_whitespace, is_whitespace, is_indention bool) bool { + if need_whitespace && !emitter.whitespace { + if !put(emitter, ' ') { + return false + } + } + if !write_all(emitter, indicator) { + return false + } + emitter.whitespace = is_whitespace + emitter.indention = (emitter.indention && is_indention) + emitter.open_ended = false + return true +} + +func yaml_emitter_write_anchor(emitter *yaml_emitter_t, value []byte) bool { + if !write_all(emitter, value) { + return false + } + emitter.whitespace = false + emitter.indention = false + return true +} + +func yaml_emitter_write_tag_handle(emitter *yaml_emitter_t, value []byte) bool { + if !emitter.whitespace { + if !put(emitter, ' ') { + return false + } + } + if !write_all(emitter, value) { + return false + } + emitter.whitespace = false + emitter.indention = false + return true +} + +func yaml_emitter_write_tag_content(emitter *yaml_emitter_t, value []byte, need_whitespace bool) bool { + if need_whitespace && !emitter.whitespace { + if !put(emitter, ' ') { + return false + } + } + for i := 0; i < len(value); { + var must_write bool + switch value[i] { + case ';', '/', '?', ':', '@', '&', '=', '+', '$', ',', '_', '.', '~', '*', '\'', '(', ')', '[', ']': + must_write = true + default: + must_write = is_alpha(value, i) + } + if must_write { + if !write(emitter, value, &i) { + return false + } + } else { + w := width(value[i]) + for k := 0; k < w; k++ { + octet := value[i] + i++ + if !put(emitter, '%') { + return false + } + + c := octet >> 4 + if c < 10 { + c += '0' + } else { + c += 'A' - 10 + } + if !put(emitter, c) { + return false + } + + c = octet & 0x0f + if c < 10 { + c += '0' + } else { + c += 'A' - 10 + } + if !put(emitter, c) { + return false + } + } + } + } + emitter.whitespace = false + emitter.indention = false + return true +} + +func yaml_emitter_write_plain_scalar(emitter *yaml_emitter_t, value []byte, allow_breaks bool) bool { + if len(value) > 0 && !emitter.whitespace { + if !put(emitter, ' ') { + return false + } + } + + spaces := false + breaks := false + for i := 0; i < len(value); { + if is_space(value, i) { + if allow_breaks && !spaces && emitter.column > emitter.best_width && !is_space(value, i+1) { + if !yaml_emitter_write_indent(emitter) { + return false + } + i += width(value[i]) + } else { + if !write(emitter, value, &i) { + return false + } + } + spaces = true + } else if is_break(value, i) { + if !breaks && value[i] == '\n' { + if !put_break(emitter) { + return false + } + } + if !write_break(emitter, value, &i) { + return false + } + //emitter.indention = true + breaks = true + } else { + if breaks { + if !yaml_emitter_write_indent(emitter) { + return false + } + } + if !write(emitter, value, &i) { + return false + } + emitter.indention = false + spaces = false + breaks = false + } + } + + if len(value) > 0 { + emitter.whitespace = false + } + emitter.indention = false + if emitter.root_context { + emitter.open_ended = true + } + + return true +} + +func yaml_emitter_write_single_quoted_scalar(emitter *yaml_emitter_t, value []byte, allow_breaks bool) bool { + + if !yaml_emitter_write_indicator(emitter, []byte{'\''}, true, false, false) { + return false + } + + spaces := false + breaks := false + for i := 0; i < len(value); { + if is_space(value, i) { + if allow_breaks && !spaces && emitter.column > emitter.best_width && i > 0 && i < len(value)-1 && !is_space(value, i+1) { + if !yaml_emitter_write_indent(emitter) { + return false + } + i += width(value[i]) + } else { + if !write(emitter, value, &i) { + return false + } + } + spaces = true + } else if is_break(value, i) { + if !breaks && value[i] == '\n' { + if !put_break(emitter) { + return false + } + } + if !write_break(emitter, value, &i) { + return false + } + //emitter.indention = true + breaks = true + } else { + if breaks { + if !yaml_emitter_write_indent(emitter) { + return false + } + } + if value[i] == '\'' { + if !put(emitter, '\'') { + return false + } + } + if !write(emitter, value, &i) { + return false + } + emitter.indention = false + spaces = false + breaks = false + } + } + if !yaml_emitter_write_indicator(emitter, []byte{'\''}, false, false, false) { + return false + } + emitter.whitespace = false + emitter.indention = false + return true +} + +func yaml_emitter_write_double_quoted_scalar(emitter *yaml_emitter_t, value []byte, allow_breaks bool) bool { + spaces := false + if !yaml_emitter_write_indicator(emitter, []byte{'"'}, true, false, false) { + return false + } + + for i := 0; i < len(value); { + if !is_printable(value, i) || (!emitter.unicode && !is_ascii(value, i)) || + is_bom(value, i) || is_break(value, i) || + value[i] == '"' || value[i] == '\\' { + + octet := value[i] + + var w int + var v rune + switch { + case octet&0x80 == 0x00: + w, v = 1, rune(octet&0x7F) + case octet&0xE0 == 0xC0: + w, v = 2, rune(octet&0x1F) + case octet&0xF0 == 0xE0: + w, v = 3, rune(octet&0x0F) + case octet&0xF8 == 0xF0: + w, v = 4, rune(octet&0x07) + } + for k := 1; k < w; k++ { + octet = value[i+k] + v = (v << 6) + (rune(octet) & 0x3F) + } + i += w + + if !put(emitter, '\\') { + return false + } + + var ok bool + switch v { + case 0x00: + ok = put(emitter, '0') + case 0x07: + ok = put(emitter, 'a') + case 0x08: + ok = put(emitter, 'b') + case 0x09: + ok = put(emitter, 't') + case 0x0A: + ok = put(emitter, 'n') + case 0x0b: + ok = put(emitter, 'v') + case 0x0c: + ok = put(emitter, 'f') + case 0x0d: + ok = put(emitter, 'r') + case 0x1b: + ok = put(emitter, 'e') + case 0x22: + ok = put(emitter, '"') + case 0x5c: + ok = put(emitter, '\\') + case 0x85: + ok = put(emitter, 'N') + case 0xA0: + ok = put(emitter, '_') + case 0x2028: + ok = put(emitter, 'L') + case 0x2029: + ok = put(emitter, 'P') + default: + if v <= 0xFF { + ok = put(emitter, 'x') + w = 2 + } else if v <= 0xFFFF { + ok = put(emitter, 'u') + w = 4 + } else { + ok = put(emitter, 'U') + w = 8 + } + for k := (w - 1) * 4; ok && k >= 0; k -= 4 { + digit := byte((v >> uint(k)) & 0x0F) + if digit < 10 { + ok = put(emitter, digit+'0') + } else { + ok = put(emitter, digit+'A'-10) + } + } + } + if !ok { + return false + } + spaces = false + } else if is_space(value, i) { + if allow_breaks && !spaces && emitter.column > emitter.best_width && i > 0 && i < len(value)-1 { + if !yaml_emitter_write_indent(emitter) { + return false + } + if is_space(value, i+1) { + if !put(emitter, '\\') { + return false + } + } + i += width(value[i]) + } else if !write(emitter, value, &i) { + return false + } + spaces = true + } else { + if !write(emitter, value, &i) { + return false + } + spaces = false + } + } + if !yaml_emitter_write_indicator(emitter, []byte{'"'}, false, false, false) { + return false + } + emitter.whitespace = false + emitter.indention = false + return true +} + +func yaml_emitter_write_block_scalar_hints(emitter *yaml_emitter_t, value []byte) bool { + if is_space(value, 0) || is_break(value, 0) { + indent_hint := []byte{'0' + byte(emitter.best_indent)} + if !yaml_emitter_write_indicator(emitter, indent_hint, false, false, false) { + return false + } + } + + emitter.open_ended = false + + var chomp_hint [1]byte + if len(value) == 0 { + chomp_hint[0] = '-' + } else { + i := len(value) - 1 + for value[i]&0xC0 == 0x80 { + i-- + } + if !is_break(value, i) { + chomp_hint[0] = '-' + } else if i == 0 { + chomp_hint[0] = '+' + emitter.open_ended = true + } else { + i-- + for value[i]&0xC0 == 0x80 { + i-- + } + if is_break(value, i) { + chomp_hint[0] = '+' + emitter.open_ended = true + } + } + } + if chomp_hint[0] != 0 { + if !yaml_emitter_write_indicator(emitter, chomp_hint[:], false, false, false) { + return false + } + } + return true +} + +func yaml_emitter_write_literal_scalar(emitter *yaml_emitter_t, value []byte) bool { + if !yaml_emitter_write_indicator(emitter, []byte{'|'}, true, false, false) { + return false + } + if !yaml_emitter_write_block_scalar_hints(emitter, value) { + return false + } + if !yaml_emitter_process_line_comment(emitter) { + return false + } + //emitter.indention = true + emitter.whitespace = true + breaks := true + for i := 0; i < len(value); { + if is_break(value, i) { + if !write_break(emitter, value, &i) { + return false + } + //emitter.indention = true + breaks = true + } else { + if breaks { + if !yaml_emitter_write_indent(emitter) { + return false + } + } + if !write(emitter, value, &i) { + return false + } + emitter.indention = false + breaks = false + } + } + + return true +} + +func yaml_emitter_write_folded_scalar(emitter *yaml_emitter_t, value []byte) bool { + if !yaml_emitter_write_indicator(emitter, []byte{'>'}, true, false, false) { + return false + } + if !yaml_emitter_write_block_scalar_hints(emitter, value) { + return false + } + if !yaml_emitter_process_line_comment(emitter) { + return false + } + + //emitter.indention = true + emitter.whitespace = true + + breaks := true + leading_spaces := true + for i := 0; i < len(value); { + if is_break(value, i) { + if !breaks && !leading_spaces && value[i] == '\n' { + k := 0 + for is_break(value, k) { + k += width(value[k]) + } + if !is_blankz(value, k) { + if !put_break(emitter) { + return false + } + } + } + if !write_break(emitter, value, &i) { + return false + } + //emitter.indention = true + breaks = true + } else { + if breaks { + if !yaml_emitter_write_indent(emitter) { + return false + } + leading_spaces = is_blank(value, i) + } + if !breaks && is_space(value, i) && !is_space(value, i+1) && emitter.column > emitter.best_width { + if !yaml_emitter_write_indent(emitter) { + return false + } + i += width(value[i]) + } else { + if !write(emitter, value, &i) { + return false + } + } + emitter.indention = false + breaks = false + } + } + return true +} + +func yaml_emitter_write_comment(emitter *yaml_emitter_t, comment []byte) bool { + breaks := false + pound := false + for i := 0; i < len(comment); { + if is_break(comment, i) { + if !write_break(emitter, comment, &i) { + return false + } + //emitter.indention = true + breaks = true + pound = false + } else { + if breaks && !yaml_emitter_write_indent(emitter) { + return false + } + if !pound { + if comment[i] != '#' && (!put(emitter, '#') || !put(emitter, ' ')) { + return false + } + pound = true + } + if !write(emitter, comment, &i) { + return false + } + emitter.indention = false + breaks = false + } + } + if !breaks && !put_break(emitter) { + return false + } + + emitter.whitespace = true + //emitter.indention = true + return true +} diff --git a/mcp/dsx-exchange-mcp/vendor/gopkg.in/yaml.v3/encode.go b/mcp/dsx-exchange-mcp/vendor/gopkg.in/yaml.v3/encode.go new file mode 100644 index 0000000..de9e72a --- /dev/null +++ b/mcp/dsx-exchange-mcp/vendor/gopkg.in/yaml.v3/encode.go @@ -0,0 +1,577 @@ +// +// Copyright (c) 2011-2019 Canonical Ltd +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package yaml + +import ( + "encoding" + "fmt" + "io" + "reflect" + "regexp" + "sort" + "strconv" + "strings" + "time" + "unicode/utf8" +) + +type encoder struct { + emitter yaml_emitter_t + event yaml_event_t + out []byte + flow bool + indent int + doneInit bool +} + +func newEncoder() *encoder { + e := &encoder{} + yaml_emitter_initialize(&e.emitter) + yaml_emitter_set_output_string(&e.emitter, &e.out) + yaml_emitter_set_unicode(&e.emitter, true) + return e +} + +func newEncoderWithWriter(w io.Writer) *encoder { + e := &encoder{} + yaml_emitter_initialize(&e.emitter) + yaml_emitter_set_output_writer(&e.emitter, w) + yaml_emitter_set_unicode(&e.emitter, true) + return e +} + +func (e *encoder) init() { + if e.doneInit { + return + } + if e.indent == 0 { + e.indent = 4 + } + e.emitter.best_indent = e.indent + yaml_stream_start_event_initialize(&e.event, yaml_UTF8_ENCODING) + e.emit() + e.doneInit = true +} + +func (e *encoder) finish() { + e.emitter.open_ended = false + yaml_stream_end_event_initialize(&e.event) + e.emit() +} + +func (e *encoder) destroy() { + yaml_emitter_delete(&e.emitter) +} + +func (e *encoder) emit() { + // This will internally delete the e.event value. + e.must(yaml_emitter_emit(&e.emitter, &e.event)) +} + +func (e *encoder) must(ok bool) { + if !ok { + msg := e.emitter.problem + if msg == "" { + msg = "unknown problem generating YAML content" + } + failf("%s", msg) + } +} + +func (e *encoder) marshalDoc(tag string, in reflect.Value) { + e.init() + var node *Node + if in.IsValid() { + node, _ = in.Interface().(*Node) + } + if node != nil && node.Kind == DocumentNode { + e.nodev(in) + } else { + yaml_document_start_event_initialize(&e.event, nil, nil, true) + e.emit() + e.marshal(tag, in) + yaml_document_end_event_initialize(&e.event, true) + e.emit() + } +} + +func (e *encoder) marshal(tag string, in reflect.Value) { + tag = shortTag(tag) + if !in.IsValid() || in.Kind() == reflect.Ptr && in.IsNil() { + e.nilv() + return + } + iface := in.Interface() + switch value := iface.(type) { + case *Node: + e.nodev(in) + return + case Node: + if !in.CanAddr() { + var n = reflect.New(in.Type()).Elem() + n.Set(in) + in = n + } + e.nodev(in.Addr()) + return + case time.Time: + e.timev(tag, in) + return + case *time.Time: + e.timev(tag, in.Elem()) + return + case time.Duration: + e.stringv(tag, reflect.ValueOf(value.String())) + return + case Marshaler: + v, err := value.MarshalYAML() + if err != nil { + fail(err) + } + if v == nil { + e.nilv() + return + } + e.marshal(tag, reflect.ValueOf(v)) + return + case encoding.TextMarshaler: + text, err := value.MarshalText() + if err != nil { + fail(err) + } + in = reflect.ValueOf(string(text)) + case nil: + e.nilv() + return + } + switch in.Kind() { + case reflect.Interface: + e.marshal(tag, in.Elem()) + case reflect.Map: + e.mapv(tag, in) + case reflect.Ptr: + e.marshal(tag, in.Elem()) + case reflect.Struct: + e.structv(tag, in) + case reflect.Slice, reflect.Array: + e.slicev(tag, in) + case reflect.String: + e.stringv(tag, in) + case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64: + e.intv(tag, in) + case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64, reflect.Uintptr: + e.uintv(tag, in) + case reflect.Float32, reflect.Float64: + e.floatv(tag, in) + case reflect.Bool: + e.boolv(tag, in) + default: + panic("cannot marshal type: " + in.Type().String()) + } +} + +func (e *encoder) mapv(tag string, in reflect.Value) { + e.mappingv(tag, func() { + keys := keyList(in.MapKeys()) + sort.Sort(keys) + for _, k := range keys { + e.marshal("", k) + e.marshal("", in.MapIndex(k)) + } + }) +} + +func (e *encoder) fieldByIndex(v reflect.Value, index []int) (field reflect.Value) { + for _, num := range index { + for { + if v.Kind() == reflect.Ptr { + if v.IsNil() { + return reflect.Value{} + } + v = v.Elem() + continue + } + break + } + v = v.Field(num) + } + return v +} + +func (e *encoder) structv(tag string, in reflect.Value) { + sinfo, err := getStructInfo(in.Type()) + if err != nil { + panic(err) + } + e.mappingv(tag, func() { + for _, info := range sinfo.FieldsList { + var value reflect.Value + if info.Inline == nil { + value = in.Field(info.Num) + } else { + value = e.fieldByIndex(in, info.Inline) + if !value.IsValid() { + continue + } + } + if info.OmitEmpty && isZero(value) { + continue + } + e.marshal("", reflect.ValueOf(info.Key)) + e.flow = info.Flow + e.marshal("", value) + } + if sinfo.InlineMap >= 0 { + m := in.Field(sinfo.InlineMap) + if m.Len() > 0 { + e.flow = false + keys := keyList(m.MapKeys()) + sort.Sort(keys) + for _, k := range keys { + if _, found := sinfo.FieldsMap[k.String()]; found { + panic(fmt.Sprintf("cannot have key %q in inlined map: conflicts with struct field", k.String())) + } + e.marshal("", k) + e.flow = false + e.marshal("", m.MapIndex(k)) + } + } + } + }) +} + +func (e *encoder) mappingv(tag string, f func()) { + implicit := tag == "" + style := yaml_BLOCK_MAPPING_STYLE + if e.flow { + e.flow = false + style = yaml_FLOW_MAPPING_STYLE + } + yaml_mapping_start_event_initialize(&e.event, nil, []byte(tag), implicit, style) + e.emit() + f() + yaml_mapping_end_event_initialize(&e.event) + e.emit() +} + +func (e *encoder) slicev(tag string, in reflect.Value) { + implicit := tag == "" + style := yaml_BLOCK_SEQUENCE_STYLE + if e.flow { + e.flow = false + style = yaml_FLOW_SEQUENCE_STYLE + } + e.must(yaml_sequence_start_event_initialize(&e.event, nil, []byte(tag), implicit, style)) + e.emit() + n := in.Len() + for i := 0; i < n; i++ { + e.marshal("", in.Index(i)) + } + e.must(yaml_sequence_end_event_initialize(&e.event)) + e.emit() +} + +// isBase60 returns whether s is in base 60 notation as defined in YAML 1.1. +// +// The base 60 float notation in YAML 1.1 is a terrible idea and is unsupported +// in YAML 1.2 and by this package, but these should be marshalled quoted for +// the time being for compatibility with other parsers. +func isBase60Float(s string) (result bool) { + // Fast path. + if s == "" { + return false + } + c := s[0] + if !(c == '+' || c == '-' || c >= '0' && c <= '9') || strings.IndexByte(s, ':') < 0 { + return false + } + // Do the full match. + return base60float.MatchString(s) +} + +// From http://yaml.org/type/float.html, except the regular expression there +// is bogus. In practice parsers do not enforce the "\.[0-9_]*" suffix. +var base60float = regexp.MustCompile(`^[-+]?[0-9][0-9_]*(?::[0-5]?[0-9])+(?:\.[0-9_]*)?$`) + +// isOldBool returns whether s is bool notation as defined in YAML 1.1. +// +// We continue to force strings that YAML 1.1 would interpret as booleans to be +// rendered as quotes strings so that the marshalled output valid for YAML 1.1 +// parsing. +func isOldBool(s string) (result bool) { + switch s { + case "y", "Y", "yes", "Yes", "YES", "on", "On", "ON", + "n", "N", "no", "No", "NO", "off", "Off", "OFF": + return true + default: + return false + } +} + +func (e *encoder) stringv(tag string, in reflect.Value) { + var style yaml_scalar_style_t + s := in.String() + canUsePlain := true + switch { + case !utf8.ValidString(s): + if tag == binaryTag { + failf("explicitly tagged !!binary data must be base64-encoded") + } + if tag != "" { + failf("cannot marshal invalid UTF-8 data as %s", shortTag(tag)) + } + // It can't be encoded directly as YAML so use a binary tag + // and encode it as base64. + tag = binaryTag + s = encodeBase64(s) + case tag == "": + // Check to see if it would resolve to a specific + // tag when encoded unquoted. If it doesn't, + // there's no need to quote it. + rtag, _ := resolve("", s) + canUsePlain = rtag == strTag && !(isBase60Float(s) || isOldBool(s)) + } + // Note: it's possible for user code to emit invalid YAML + // if they explicitly specify a tag and a string containing + // text that's incompatible with that tag. + switch { + case strings.Contains(s, "\n"): + if e.flow { + style = yaml_DOUBLE_QUOTED_SCALAR_STYLE + } else { + style = yaml_LITERAL_SCALAR_STYLE + } + case canUsePlain: + style = yaml_PLAIN_SCALAR_STYLE + default: + style = yaml_DOUBLE_QUOTED_SCALAR_STYLE + } + e.emitScalar(s, "", tag, style, nil, nil, nil, nil) +} + +func (e *encoder) boolv(tag string, in reflect.Value) { + var s string + if in.Bool() { + s = "true" + } else { + s = "false" + } + e.emitScalar(s, "", tag, yaml_PLAIN_SCALAR_STYLE, nil, nil, nil, nil) +} + +func (e *encoder) intv(tag string, in reflect.Value) { + s := strconv.FormatInt(in.Int(), 10) + e.emitScalar(s, "", tag, yaml_PLAIN_SCALAR_STYLE, nil, nil, nil, nil) +} + +func (e *encoder) uintv(tag string, in reflect.Value) { + s := strconv.FormatUint(in.Uint(), 10) + e.emitScalar(s, "", tag, yaml_PLAIN_SCALAR_STYLE, nil, nil, nil, nil) +} + +func (e *encoder) timev(tag string, in reflect.Value) { + t := in.Interface().(time.Time) + s := t.Format(time.RFC3339Nano) + e.emitScalar(s, "", tag, yaml_PLAIN_SCALAR_STYLE, nil, nil, nil, nil) +} + +func (e *encoder) floatv(tag string, in reflect.Value) { + // Issue #352: When formatting, use the precision of the underlying value + precision := 64 + if in.Kind() == reflect.Float32 { + precision = 32 + } + + s := strconv.FormatFloat(in.Float(), 'g', -1, precision) + switch s { + case "+Inf": + s = ".inf" + case "-Inf": + s = "-.inf" + case "NaN": + s = ".nan" + } + e.emitScalar(s, "", tag, yaml_PLAIN_SCALAR_STYLE, nil, nil, nil, nil) +} + +func (e *encoder) nilv() { + e.emitScalar("null", "", "", yaml_PLAIN_SCALAR_STYLE, nil, nil, nil, nil) +} + +func (e *encoder) emitScalar(value, anchor, tag string, style yaml_scalar_style_t, head, line, foot, tail []byte) { + // TODO Kill this function. Replace all initialize calls by their underlining Go literals. + implicit := tag == "" + if !implicit { + tag = longTag(tag) + } + e.must(yaml_scalar_event_initialize(&e.event, []byte(anchor), []byte(tag), []byte(value), implicit, implicit, style)) + e.event.head_comment = head + e.event.line_comment = line + e.event.foot_comment = foot + e.event.tail_comment = tail + e.emit() +} + +func (e *encoder) nodev(in reflect.Value) { + e.node(in.Interface().(*Node), "") +} + +func (e *encoder) node(node *Node, tail string) { + // Zero nodes behave as nil. + if node.Kind == 0 && node.IsZero() { + e.nilv() + return + } + + // If the tag was not explicitly requested, and dropping it won't change the + // implicit tag of the value, don't include it in the presentation. + var tag = node.Tag + var stag = shortTag(tag) + var forceQuoting bool + if tag != "" && node.Style&TaggedStyle == 0 { + if node.Kind == ScalarNode { + if stag == strTag && node.Style&(SingleQuotedStyle|DoubleQuotedStyle|LiteralStyle|FoldedStyle) != 0 { + tag = "" + } else { + rtag, _ := resolve("", node.Value) + if rtag == stag { + tag = "" + } else if stag == strTag { + tag = "" + forceQuoting = true + } + } + } else { + var rtag string + switch node.Kind { + case MappingNode: + rtag = mapTag + case SequenceNode: + rtag = seqTag + } + if rtag == stag { + tag = "" + } + } + } + + switch node.Kind { + case DocumentNode: + yaml_document_start_event_initialize(&e.event, nil, nil, true) + e.event.head_comment = []byte(node.HeadComment) + e.emit() + for _, node := range node.Content { + e.node(node, "") + } + yaml_document_end_event_initialize(&e.event, true) + e.event.foot_comment = []byte(node.FootComment) + e.emit() + + case SequenceNode: + style := yaml_BLOCK_SEQUENCE_STYLE + if node.Style&FlowStyle != 0 { + style = yaml_FLOW_SEQUENCE_STYLE + } + e.must(yaml_sequence_start_event_initialize(&e.event, []byte(node.Anchor), []byte(longTag(tag)), tag == "", style)) + e.event.head_comment = []byte(node.HeadComment) + e.emit() + for _, node := range node.Content { + e.node(node, "") + } + e.must(yaml_sequence_end_event_initialize(&e.event)) + e.event.line_comment = []byte(node.LineComment) + e.event.foot_comment = []byte(node.FootComment) + e.emit() + + case MappingNode: + style := yaml_BLOCK_MAPPING_STYLE + if node.Style&FlowStyle != 0 { + style = yaml_FLOW_MAPPING_STYLE + } + yaml_mapping_start_event_initialize(&e.event, []byte(node.Anchor), []byte(longTag(tag)), tag == "", style) + e.event.tail_comment = []byte(tail) + e.event.head_comment = []byte(node.HeadComment) + e.emit() + + // The tail logic below moves the foot comment of prior keys to the following key, + // since the value for each key may be a nested structure and the foot needs to be + // processed only the entirety of the value is streamed. The last tail is processed + // with the mapping end event. + var tail string + for i := 0; i+1 < len(node.Content); i += 2 { + k := node.Content[i] + foot := k.FootComment + if foot != "" { + kopy := *k + kopy.FootComment = "" + k = &kopy + } + e.node(k, tail) + tail = foot + + v := node.Content[i+1] + e.node(v, "") + } + + yaml_mapping_end_event_initialize(&e.event) + e.event.tail_comment = []byte(tail) + e.event.line_comment = []byte(node.LineComment) + e.event.foot_comment = []byte(node.FootComment) + e.emit() + + case AliasNode: + yaml_alias_event_initialize(&e.event, []byte(node.Value)) + e.event.head_comment = []byte(node.HeadComment) + e.event.line_comment = []byte(node.LineComment) + e.event.foot_comment = []byte(node.FootComment) + e.emit() + + case ScalarNode: + value := node.Value + if !utf8.ValidString(value) { + if stag == binaryTag { + failf("explicitly tagged !!binary data must be base64-encoded") + } + if stag != "" { + failf("cannot marshal invalid UTF-8 data as %s", stag) + } + // It can't be encoded directly as YAML so use a binary tag + // and encode it as base64. + tag = binaryTag + value = encodeBase64(value) + } + + style := yaml_PLAIN_SCALAR_STYLE + switch { + case node.Style&DoubleQuotedStyle != 0: + style = yaml_DOUBLE_QUOTED_SCALAR_STYLE + case node.Style&SingleQuotedStyle != 0: + style = yaml_SINGLE_QUOTED_SCALAR_STYLE + case node.Style&LiteralStyle != 0: + style = yaml_LITERAL_SCALAR_STYLE + case node.Style&FoldedStyle != 0: + style = yaml_FOLDED_SCALAR_STYLE + case strings.Contains(value, "\n"): + style = yaml_LITERAL_SCALAR_STYLE + case forceQuoting: + style = yaml_DOUBLE_QUOTED_SCALAR_STYLE + } + + e.emitScalar(value, node.Anchor, tag, style, []byte(node.HeadComment), []byte(node.LineComment), []byte(node.FootComment), []byte(tail)) + default: + failf("cannot encode node with unknown kind %d", node.Kind) + } +} diff --git a/mcp/dsx-exchange-mcp/vendor/gopkg.in/yaml.v3/parserc.go b/mcp/dsx-exchange-mcp/vendor/gopkg.in/yaml.v3/parserc.go new file mode 100644 index 0000000..268558a --- /dev/null +++ b/mcp/dsx-exchange-mcp/vendor/gopkg.in/yaml.v3/parserc.go @@ -0,0 +1,1258 @@ +// +// Copyright (c) 2011-2019 Canonical Ltd +// Copyright (c) 2006-2010 Kirill Simonov +// +// Permission is hereby granted, free of charge, to any person obtaining a copy of +// this software and associated documentation files (the "Software"), to deal in +// the Software without restriction, including without limitation the rights to +// use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies +// of the Software, and to permit persons to whom the Software is furnished to do +// so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in all +// copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. + +package yaml + +import ( + "bytes" +) + +// The parser implements the following grammar: +// +// stream ::= STREAM-START implicit_document? explicit_document* STREAM-END +// implicit_document ::= block_node DOCUMENT-END* +// explicit_document ::= DIRECTIVE* DOCUMENT-START block_node? DOCUMENT-END* +// block_node_or_indentless_sequence ::= +// ALIAS +// | properties (block_content | indentless_block_sequence)? +// | block_content +// | indentless_block_sequence +// block_node ::= ALIAS +// | properties block_content? +// | block_content +// flow_node ::= ALIAS +// | properties flow_content? +// | flow_content +// properties ::= TAG ANCHOR? | ANCHOR TAG? +// block_content ::= block_collection | flow_collection | SCALAR +// flow_content ::= flow_collection | SCALAR +// block_collection ::= block_sequence | block_mapping +// flow_collection ::= flow_sequence | flow_mapping +// block_sequence ::= BLOCK-SEQUENCE-START (BLOCK-ENTRY block_node?)* BLOCK-END +// indentless_sequence ::= (BLOCK-ENTRY block_node?)+ +// block_mapping ::= BLOCK-MAPPING_START +// ((KEY block_node_or_indentless_sequence?)? +// (VALUE block_node_or_indentless_sequence?)?)* +// BLOCK-END +// flow_sequence ::= FLOW-SEQUENCE-START +// (flow_sequence_entry FLOW-ENTRY)* +// flow_sequence_entry? +// FLOW-SEQUENCE-END +// flow_sequence_entry ::= flow_node | KEY flow_node? (VALUE flow_node?)? +// flow_mapping ::= FLOW-MAPPING-START +// (flow_mapping_entry FLOW-ENTRY)* +// flow_mapping_entry? +// FLOW-MAPPING-END +// flow_mapping_entry ::= flow_node | KEY flow_node? (VALUE flow_node?)? + +// Peek the next token in the token queue. +func peek_token(parser *yaml_parser_t) *yaml_token_t { + if parser.token_available || yaml_parser_fetch_more_tokens(parser) { + token := &parser.tokens[parser.tokens_head] + yaml_parser_unfold_comments(parser, token) + return token + } + return nil +} + +// yaml_parser_unfold_comments walks through the comments queue and joins all +// comments behind the position of the provided token into the respective +// top-level comment slices in the parser. +func yaml_parser_unfold_comments(parser *yaml_parser_t, token *yaml_token_t) { + for parser.comments_head < len(parser.comments) && token.start_mark.index >= parser.comments[parser.comments_head].token_mark.index { + comment := &parser.comments[parser.comments_head] + if len(comment.head) > 0 { + if token.typ == yaml_BLOCK_END_TOKEN { + // No heads on ends, so keep comment.head for a follow up token. + break + } + if len(parser.head_comment) > 0 { + parser.head_comment = append(parser.head_comment, '\n') + } + parser.head_comment = append(parser.head_comment, comment.head...) + } + if len(comment.foot) > 0 { + if len(parser.foot_comment) > 0 { + parser.foot_comment = append(parser.foot_comment, '\n') + } + parser.foot_comment = append(parser.foot_comment, comment.foot...) + } + if len(comment.line) > 0 { + if len(parser.line_comment) > 0 { + parser.line_comment = append(parser.line_comment, '\n') + } + parser.line_comment = append(parser.line_comment, comment.line...) + } + *comment = yaml_comment_t{} + parser.comments_head++ + } +} + +// Remove the next token from the queue (must be called after peek_token). +func skip_token(parser *yaml_parser_t) { + parser.token_available = false + parser.tokens_parsed++ + parser.stream_end_produced = parser.tokens[parser.tokens_head].typ == yaml_STREAM_END_TOKEN + parser.tokens_head++ +} + +// Get the next event. +func yaml_parser_parse(parser *yaml_parser_t, event *yaml_event_t) bool { + // Erase the event object. + *event = yaml_event_t{} + + // No events after the end of the stream or error. + if parser.stream_end_produced || parser.error != yaml_NO_ERROR || parser.state == yaml_PARSE_END_STATE { + return true + } + + // Generate the next event. + return yaml_parser_state_machine(parser, event) +} + +// Set parser error. +func yaml_parser_set_parser_error(parser *yaml_parser_t, problem string, problem_mark yaml_mark_t) bool { + parser.error = yaml_PARSER_ERROR + parser.problem = problem + parser.problem_mark = problem_mark + return false +} + +func yaml_parser_set_parser_error_context(parser *yaml_parser_t, context string, context_mark yaml_mark_t, problem string, problem_mark yaml_mark_t) bool { + parser.error = yaml_PARSER_ERROR + parser.context = context + parser.context_mark = context_mark + parser.problem = problem + parser.problem_mark = problem_mark + return false +} + +// State dispatcher. +func yaml_parser_state_machine(parser *yaml_parser_t, event *yaml_event_t) bool { + //trace("yaml_parser_state_machine", "state:", parser.state.String()) + + switch parser.state { + case yaml_PARSE_STREAM_START_STATE: + return yaml_parser_parse_stream_start(parser, event) + + case yaml_PARSE_IMPLICIT_DOCUMENT_START_STATE: + return yaml_parser_parse_document_start(parser, event, true) + + case yaml_PARSE_DOCUMENT_START_STATE: + return yaml_parser_parse_document_start(parser, event, false) + + case yaml_PARSE_DOCUMENT_CONTENT_STATE: + return yaml_parser_parse_document_content(parser, event) + + case yaml_PARSE_DOCUMENT_END_STATE: + return yaml_parser_parse_document_end(parser, event) + + case yaml_PARSE_BLOCK_NODE_STATE: + return yaml_parser_parse_node(parser, event, true, false) + + case yaml_PARSE_BLOCK_NODE_OR_INDENTLESS_SEQUENCE_STATE: + return yaml_parser_parse_node(parser, event, true, true) + + case yaml_PARSE_FLOW_NODE_STATE: + return yaml_parser_parse_node(parser, event, false, false) + + case yaml_PARSE_BLOCK_SEQUENCE_FIRST_ENTRY_STATE: + return yaml_parser_parse_block_sequence_entry(parser, event, true) + + case yaml_PARSE_BLOCK_SEQUENCE_ENTRY_STATE: + return yaml_parser_parse_block_sequence_entry(parser, event, false) + + case yaml_PARSE_INDENTLESS_SEQUENCE_ENTRY_STATE: + return yaml_parser_parse_indentless_sequence_entry(parser, event) + + case yaml_PARSE_BLOCK_MAPPING_FIRST_KEY_STATE: + return yaml_parser_parse_block_mapping_key(parser, event, true) + + case yaml_PARSE_BLOCK_MAPPING_KEY_STATE: + return yaml_parser_parse_block_mapping_key(parser, event, false) + + case yaml_PARSE_BLOCK_MAPPING_VALUE_STATE: + return yaml_parser_parse_block_mapping_value(parser, event) + + case yaml_PARSE_FLOW_SEQUENCE_FIRST_ENTRY_STATE: + return yaml_parser_parse_flow_sequence_entry(parser, event, true) + + case yaml_PARSE_FLOW_SEQUENCE_ENTRY_STATE: + return yaml_parser_parse_flow_sequence_entry(parser, event, false) + + case yaml_PARSE_FLOW_SEQUENCE_ENTRY_MAPPING_KEY_STATE: + return yaml_parser_parse_flow_sequence_entry_mapping_key(parser, event) + + case yaml_PARSE_FLOW_SEQUENCE_ENTRY_MAPPING_VALUE_STATE: + return yaml_parser_parse_flow_sequence_entry_mapping_value(parser, event) + + case yaml_PARSE_FLOW_SEQUENCE_ENTRY_MAPPING_END_STATE: + return yaml_parser_parse_flow_sequence_entry_mapping_end(parser, event) + + case yaml_PARSE_FLOW_MAPPING_FIRST_KEY_STATE: + return yaml_parser_parse_flow_mapping_key(parser, event, true) + + case yaml_PARSE_FLOW_MAPPING_KEY_STATE: + return yaml_parser_parse_flow_mapping_key(parser, event, false) + + case yaml_PARSE_FLOW_MAPPING_VALUE_STATE: + return yaml_parser_parse_flow_mapping_value(parser, event, false) + + case yaml_PARSE_FLOW_MAPPING_EMPTY_VALUE_STATE: + return yaml_parser_parse_flow_mapping_value(parser, event, true) + + default: + panic("invalid parser state") + } +} + +// Parse the production: +// stream ::= STREAM-START implicit_document? explicit_document* STREAM-END +// ************ +func yaml_parser_parse_stream_start(parser *yaml_parser_t, event *yaml_event_t) bool { + token := peek_token(parser) + if token == nil { + return false + } + if token.typ != yaml_STREAM_START_TOKEN { + return yaml_parser_set_parser_error(parser, "did not find expected ", token.start_mark) + } + parser.state = yaml_PARSE_IMPLICIT_DOCUMENT_START_STATE + *event = yaml_event_t{ + typ: yaml_STREAM_START_EVENT, + start_mark: token.start_mark, + end_mark: token.end_mark, + encoding: token.encoding, + } + skip_token(parser) + return true +} + +// Parse the productions: +// implicit_document ::= block_node DOCUMENT-END* +// * +// explicit_document ::= DIRECTIVE* DOCUMENT-START block_node? DOCUMENT-END* +// ************************* +func yaml_parser_parse_document_start(parser *yaml_parser_t, event *yaml_event_t, implicit bool) bool { + + token := peek_token(parser) + if token == nil { + return false + } + + // Parse extra document end indicators. + if !implicit { + for token.typ == yaml_DOCUMENT_END_TOKEN { + skip_token(parser) + token = peek_token(parser) + if token == nil { + return false + } + } + } + + if implicit && token.typ != yaml_VERSION_DIRECTIVE_TOKEN && + token.typ != yaml_TAG_DIRECTIVE_TOKEN && + token.typ != yaml_DOCUMENT_START_TOKEN && + token.typ != yaml_STREAM_END_TOKEN { + // Parse an implicit document. + if !yaml_parser_process_directives(parser, nil, nil) { + return false + } + parser.states = append(parser.states, yaml_PARSE_DOCUMENT_END_STATE) + parser.state = yaml_PARSE_BLOCK_NODE_STATE + + var head_comment []byte + if len(parser.head_comment) > 0 { + // [Go] Scan the header comment backwards, and if an empty line is found, break + // the header so the part before the last empty line goes into the + // document header, while the bottom of it goes into a follow up event. + for i := len(parser.head_comment) - 1; i > 0; i-- { + if parser.head_comment[i] == '\n' { + if i == len(parser.head_comment)-1 { + head_comment = parser.head_comment[:i] + parser.head_comment = parser.head_comment[i+1:] + break + } else if parser.head_comment[i-1] == '\n' { + head_comment = parser.head_comment[:i-1] + parser.head_comment = parser.head_comment[i+1:] + break + } + } + } + } + + *event = yaml_event_t{ + typ: yaml_DOCUMENT_START_EVENT, + start_mark: token.start_mark, + end_mark: token.end_mark, + + head_comment: head_comment, + } + + } else if token.typ != yaml_STREAM_END_TOKEN { + // Parse an explicit document. + var version_directive *yaml_version_directive_t + var tag_directives []yaml_tag_directive_t + start_mark := token.start_mark + if !yaml_parser_process_directives(parser, &version_directive, &tag_directives) { + return false + } + token = peek_token(parser) + if token == nil { + return false + } + if token.typ != yaml_DOCUMENT_START_TOKEN { + yaml_parser_set_parser_error(parser, + "did not find expected ", token.start_mark) + return false + } + parser.states = append(parser.states, yaml_PARSE_DOCUMENT_END_STATE) + parser.state = yaml_PARSE_DOCUMENT_CONTENT_STATE + end_mark := token.end_mark + + *event = yaml_event_t{ + typ: yaml_DOCUMENT_START_EVENT, + start_mark: start_mark, + end_mark: end_mark, + version_directive: version_directive, + tag_directives: tag_directives, + implicit: false, + } + skip_token(parser) + + } else { + // Parse the stream end. + parser.state = yaml_PARSE_END_STATE + *event = yaml_event_t{ + typ: yaml_STREAM_END_EVENT, + start_mark: token.start_mark, + end_mark: token.end_mark, + } + skip_token(parser) + } + + return true +} + +// Parse the productions: +// explicit_document ::= DIRECTIVE* DOCUMENT-START block_node? DOCUMENT-END* +// *********** +// +func yaml_parser_parse_document_content(parser *yaml_parser_t, event *yaml_event_t) bool { + token := peek_token(parser) + if token == nil { + return false + } + + if token.typ == yaml_VERSION_DIRECTIVE_TOKEN || + token.typ == yaml_TAG_DIRECTIVE_TOKEN || + token.typ == yaml_DOCUMENT_START_TOKEN || + token.typ == yaml_DOCUMENT_END_TOKEN || + token.typ == yaml_STREAM_END_TOKEN { + parser.state = parser.states[len(parser.states)-1] + parser.states = parser.states[:len(parser.states)-1] + return yaml_parser_process_empty_scalar(parser, event, + token.start_mark) + } + return yaml_parser_parse_node(parser, event, true, false) +} + +// Parse the productions: +// implicit_document ::= block_node DOCUMENT-END* +// ************* +// explicit_document ::= DIRECTIVE* DOCUMENT-START block_node? DOCUMENT-END* +// +func yaml_parser_parse_document_end(parser *yaml_parser_t, event *yaml_event_t) bool { + token := peek_token(parser) + if token == nil { + return false + } + + start_mark := token.start_mark + end_mark := token.start_mark + + implicit := true + if token.typ == yaml_DOCUMENT_END_TOKEN { + end_mark = token.end_mark + skip_token(parser) + implicit = false + } + + parser.tag_directives = parser.tag_directives[:0] + + parser.state = yaml_PARSE_DOCUMENT_START_STATE + *event = yaml_event_t{ + typ: yaml_DOCUMENT_END_EVENT, + start_mark: start_mark, + end_mark: end_mark, + implicit: implicit, + } + yaml_parser_set_event_comments(parser, event) + if len(event.head_comment) > 0 && len(event.foot_comment) == 0 { + event.foot_comment = event.head_comment + event.head_comment = nil + } + return true +} + +func yaml_parser_set_event_comments(parser *yaml_parser_t, event *yaml_event_t) { + event.head_comment = parser.head_comment + event.line_comment = parser.line_comment + event.foot_comment = parser.foot_comment + parser.head_comment = nil + parser.line_comment = nil + parser.foot_comment = nil + parser.tail_comment = nil + parser.stem_comment = nil +} + +// Parse the productions: +// block_node_or_indentless_sequence ::= +// ALIAS +// ***** +// | properties (block_content | indentless_block_sequence)? +// ********** * +// | block_content | indentless_block_sequence +// * +// block_node ::= ALIAS +// ***** +// | properties block_content? +// ********** * +// | block_content +// * +// flow_node ::= ALIAS +// ***** +// | properties flow_content? +// ********** * +// | flow_content +// * +// properties ::= TAG ANCHOR? | ANCHOR TAG? +// ************************* +// block_content ::= block_collection | flow_collection | SCALAR +// ****** +// flow_content ::= flow_collection | SCALAR +// ****** +func yaml_parser_parse_node(parser *yaml_parser_t, event *yaml_event_t, block, indentless_sequence bool) bool { + //defer trace("yaml_parser_parse_node", "block:", block, "indentless_sequence:", indentless_sequence)() + + token := peek_token(parser) + if token == nil { + return false + } + + if token.typ == yaml_ALIAS_TOKEN { + parser.state = parser.states[len(parser.states)-1] + parser.states = parser.states[:len(parser.states)-1] + *event = yaml_event_t{ + typ: yaml_ALIAS_EVENT, + start_mark: token.start_mark, + end_mark: token.end_mark, + anchor: token.value, + } + yaml_parser_set_event_comments(parser, event) + skip_token(parser) + return true + } + + start_mark := token.start_mark + end_mark := token.start_mark + + var tag_token bool + var tag_handle, tag_suffix, anchor []byte + var tag_mark yaml_mark_t + if token.typ == yaml_ANCHOR_TOKEN { + anchor = token.value + start_mark = token.start_mark + end_mark = token.end_mark + skip_token(parser) + token = peek_token(parser) + if token == nil { + return false + } + if token.typ == yaml_TAG_TOKEN { + tag_token = true + tag_handle = token.value + tag_suffix = token.suffix + tag_mark = token.start_mark + end_mark = token.end_mark + skip_token(parser) + token = peek_token(parser) + if token == nil { + return false + } + } + } else if token.typ == yaml_TAG_TOKEN { + tag_token = true + tag_handle = token.value + tag_suffix = token.suffix + start_mark = token.start_mark + tag_mark = token.start_mark + end_mark = token.end_mark + skip_token(parser) + token = peek_token(parser) + if token == nil { + return false + } + if token.typ == yaml_ANCHOR_TOKEN { + anchor = token.value + end_mark = token.end_mark + skip_token(parser) + token = peek_token(parser) + if token == nil { + return false + } + } + } + + var tag []byte + if tag_token { + if len(tag_handle) == 0 { + tag = tag_suffix + tag_suffix = nil + } else { + for i := range parser.tag_directives { + if bytes.Equal(parser.tag_directives[i].handle, tag_handle) { + tag = append([]byte(nil), parser.tag_directives[i].prefix...) + tag = append(tag, tag_suffix...) + break + } + } + if len(tag) == 0 { + yaml_parser_set_parser_error_context(parser, + "while parsing a node", start_mark, + "found undefined tag handle", tag_mark) + return false + } + } + } + + implicit := len(tag) == 0 + if indentless_sequence && token.typ == yaml_BLOCK_ENTRY_TOKEN { + end_mark = token.end_mark + parser.state = yaml_PARSE_INDENTLESS_SEQUENCE_ENTRY_STATE + *event = yaml_event_t{ + typ: yaml_SEQUENCE_START_EVENT, + start_mark: start_mark, + end_mark: end_mark, + anchor: anchor, + tag: tag, + implicit: implicit, + style: yaml_style_t(yaml_BLOCK_SEQUENCE_STYLE), + } + return true + } + if token.typ == yaml_SCALAR_TOKEN { + var plain_implicit, quoted_implicit bool + end_mark = token.end_mark + if (len(tag) == 0 && token.style == yaml_PLAIN_SCALAR_STYLE) || (len(tag) == 1 && tag[0] == '!') { + plain_implicit = true + } else if len(tag) == 0 { + quoted_implicit = true + } + parser.state = parser.states[len(parser.states)-1] + parser.states = parser.states[:len(parser.states)-1] + + *event = yaml_event_t{ + typ: yaml_SCALAR_EVENT, + start_mark: start_mark, + end_mark: end_mark, + anchor: anchor, + tag: tag, + value: token.value, + implicit: plain_implicit, + quoted_implicit: quoted_implicit, + style: yaml_style_t(token.style), + } + yaml_parser_set_event_comments(parser, event) + skip_token(parser) + return true + } + if token.typ == yaml_FLOW_SEQUENCE_START_TOKEN { + // [Go] Some of the events below can be merged as they differ only on style. + end_mark = token.end_mark + parser.state = yaml_PARSE_FLOW_SEQUENCE_FIRST_ENTRY_STATE + *event = yaml_event_t{ + typ: yaml_SEQUENCE_START_EVENT, + start_mark: start_mark, + end_mark: end_mark, + anchor: anchor, + tag: tag, + implicit: implicit, + style: yaml_style_t(yaml_FLOW_SEQUENCE_STYLE), + } + yaml_parser_set_event_comments(parser, event) + return true + } + if token.typ == yaml_FLOW_MAPPING_START_TOKEN { + end_mark = token.end_mark + parser.state = yaml_PARSE_FLOW_MAPPING_FIRST_KEY_STATE + *event = yaml_event_t{ + typ: yaml_MAPPING_START_EVENT, + start_mark: start_mark, + end_mark: end_mark, + anchor: anchor, + tag: tag, + implicit: implicit, + style: yaml_style_t(yaml_FLOW_MAPPING_STYLE), + } + yaml_parser_set_event_comments(parser, event) + return true + } + if block && token.typ == yaml_BLOCK_SEQUENCE_START_TOKEN { + end_mark = token.end_mark + parser.state = yaml_PARSE_BLOCK_SEQUENCE_FIRST_ENTRY_STATE + *event = yaml_event_t{ + typ: yaml_SEQUENCE_START_EVENT, + start_mark: start_mark, + end_mark: end_mark, + anchor: anchor, + tag: tag, + implicit: implicit, + style: yaml_style_t(yaml_BLOCK_SEQUENCE_STYLE), + } + if parser.stem_comment != nil { + event.head_comment = parser.stem_comment + parser.stem_comment = nil + } + return true + } + if block && token.typ == yaml_BLOCK_MAPPING_START_TOKEN { + end_mark = token.end_mark + parser.state = yaml_PARSE_BLOCK_MAPPING_FIRST_KEY_STATE + *event = yaml_event_t{ + typ: yaml_MAPPING_START_EVENT, + start_mark: start_mark, + end_mark: end_mark, + anchor: anchor, + tag: tag, + implicit: implicit, + style: yaml_style_t(yaml_BLOCK_MAPPING_STYLE), + } + if parser.stem_comment != nil { + event.head_comment = parser.stem_comment + parser.stem_comment = nil + } + return true + } + if len(anchor) > 0 || len(tag) > 0 { + parser.state = parser.states[len(parser.states)-1] + parser.states = parser.states[:len(parser.states)-1] + + *event = yaml_event_t{ + typ: yaml_SCALAR_EVENT, + start_mark: start_mark, + end_mark: end_mark, + anchor: anchor, + tag: tag, + implicit: implicit, + quoted_implicit: false, + style: yaml_style_t(yaml_PLAIN_SCALAR_STYLE), + } + return true + } + + context := "while parsing a flow node" + if block { + context = "while parsing a block node" + } + yaml_parser_set_parser_error_context(parser, context, start_mark, + "did not find expected node content", token.start_mark) + return false +} + +// Parse the productions: +// block_sequence ::= BLOCK-SEQUENCE-START (BLOCK-ENTRY block_node?)* BLOCK-END +// ******************** *********** * ********* +// +func yaml_parser_parse_block_sequence_entry(parser *yaml_parser_t, event *yaml_event_t, first bool) bool { + if first { + token := peek_token(parser) + if token == nil { + return false + } + parser.marks = append(parser.marks, token.start_mark) + skip_token(parser) + } + + token := peek_token(parser) + if token == nil { + return false + } + + if token.typ == yaml_BLOCK_ENTRY_TOKEN { + mark := token.end_mark + prior_head_len := len(parser.head_comment) + skip_token(parser) + yaml_parser_split_stem_comment(parser, prior_head_len) + token = peek_token(parser) + if token == nil { + return false + } + if token.typ != yaml_BLOCK_ENTRY_TOKEN && token.typ != yaml_BLOCK_END_TOKEN { + parser.states = append(parser.states, yaml_PARSE_BLOCK_SEQUENCE_ENTRY_STATE) + return yaml_parser_parse_node(parser, event, true, false) + } else { + parser.state = yaml_PARSE_BLOCK_SEQUENCE_ENTRY_STATE + return yaml_parser_process_empty_scalar(parser, event, mark) + } + } + if token.typ == yaml_BLOCK_END_TOKEN { + parser.state = parser.states[len(parser.states)-1] + parser.states = parser.states[:len(parser.states)-1] + parser.marks = parser.marks[:len(parser.marks)-1] + + *event = yaml_event_t{ + typ: yaml_SEQUENCE_END_EVENT, + start_mark: token.start_mark, + end_mark: token.end_mark, + } + + skip_token(parser) + return true + } + + context_mark := parser.marks[len(parser.marks)-1] + parser.marks = parser.marks[:len(parser.marks)-1] + return yaml_parser_set_parser_error_context(parser, + "while parsing a block collection", context_mark, + "did not find expected '-' indicator", token.start_mark) +} + +// Parse the productions: +// indentless_sequence ::= (BLOCK-ENTRY block_node?)+ +// *********** * +func yaml_parser_parse_indentless_sequence_entry(parser *yaml_parser_t, event *yaml_event_t) bool { + token := peek_token(parser) + if token == nil { + return false + } + + if token.typ == yaml_BLOCK_ENTRY_TOKEN { + mark := token.end_mark + prior_head_len := len(parser.head_comment) + skip_token(parser) + yaml_parser_split_stem_comment(parser, prior_head_len) + token = peek_token(parser) + if token == nil { + return false + } + if token.typ != yaml_BLOCK_ENTRY_TOKEN && + token.typ != yaml_KEY_TOKEN && + token.typ != yaml_VALUE_TOKEN && + token.typ != yaml_BLOCK_END_TOKEN { + parser.states = append(parser.states, yaml_PARSE_INDENTLESS_SEQUENCE_ENTRY_STATE) + return yaml_parser_parse_node(parser, event, true, false) + } + parser.state = yaml_PARSE_INDENTLESS_SEQUENCE_ENTRY_STATE + return yaml_parser_process_empty_scalar(parser, event, mark) + } + parser.state = parser.states[len(parser.states)-1] + parser.states = parser.states[:len(parser.states)-1] + + *event = yaml_event_t{ + typ: yaml_SEQUENCE_END_EVENT, + start_mark: token.start_mark, + end_mark: token.start_mark, // [Go] Shouldn't this be token.end_mark? + } + return true +} + +// Split stem comment from head comment. +// +// When a sequence or map is found under a sequence entry, the former head comment +// is assigned to the underlying sequence or map as a whole, not the individual +// sequence or map entry as would be expected otherwise. To handle this case the +// previous head comment is moved aside as the stem comment. +func yaml_parser_split_stem_comment(parser *yaml_parser_t, stem_len int) { + if stem_len == 0 { + return + } + + token := peek_token(parser) + if token == nil || token.typ != yaml_BLOCK_SEQUENCE_START_TOKEN && token.typ != yaml_BLOCK_MAPPING_START_TOKEN { + return + } + + parser.stem_comment = parser.head_comment[:stem_len] + if len(parser.head_comment) == stem_len { + parser.head_comment = nil + } else { + // Copy suffix to prevent very strange bugs if someone ever appends + // further bytes to the prefix in the stem_comment slice above. + parser.head_comment = append([]byte(nil), parser.head_comment[stem_len+1:]...) + } +} + +// Parse the productions: +// block_mapping ::= BLOCK-MAPPING_START +// ******************* +// ((KEY block_node_or_indentless_sequence?)? +// *** * +// (VALUE block_node_or_indentless_sequence?)?)* +// +// BLOCK-END +// ********* +// +func yaml_parser_parse_block_mapping_key(parser *yaml_parser_t, event *yaml_event_t, first bool) bool { + if first { + token := peek_token(parser) + if token == nil { + return false + } + parser.marks = append(parser.marks, token.start_mark) + skip_token(parser) + } + + token := peek_token(parser) + if token == nil { + return false + } + + // [Go] A tail comment was left from the prior mapping value processed. Emit an event + // as it needs to be processed with that value and not the following key. + if len(parser.tail_comment) > 0 { + *event = yaml_event_t{ + typ: yaml_TAIL_COMMENT_EVENT, + start_mark: token.start_mark, + end_mark: token.end_mark, + foot_comment: parser.tail_comment, + } + parser.tail_comment = nil + return true + } + + if token.typ == yaml_KEY_TOKEN { + mark := token.end_mark + skip_token(parser) + token = peek_token(parser) + if token == nil { + return false + } + if token.typ != yaml_KEY_TOKEN && + token.typ != yaml_VALUE_TOKEN && + token.typ != yaml_BLOCK_END_TOKEN { + parser.states = append(parser.states, yaml_PARSE_BLOCK_MAPPING_VALUE_STATE) + return yaml_parser_parse_node(parser, event, true, true) + } else { + parser.state = yaml_PARSE_BLOCK_MAPPING_VALUE_STATE + return yaml_parser_process_empty_scalar(parser, event, mark) + } + } else if token.typ == yaml_BLOCK_END_TOKEN { + parser.state = parser.states[len(parser.states)-1] + parser.states = parser.states[:len(parser.states)-1] + parser.marks = parser.marks[:len(parser.marks)-1] + *event = yaml_event_t{ + typ: yaml_MAPPING_END_EVENT, + start_mark: token.start_mark, + end_mark: token.end_mark, + } + yaml_parser_set_event_comments(parser, event) + skip_token(parser) + return true + } + + context_mark := parser.marks[len(parser.marks)-1] + parser.marks = parser.marks[:len(parser.marks)-1] + return yaml_parser_set_parser_error_context(parser, + "while parsing a block mapping", context_mark, + "did not find expected key", token.start_mark) +} + +// Parse the productions: +// block_mapping ::= BLOCK-MAPPING_START +// +// ((KEY block_node_or_indentless_sequence?)? +// +// (VALUE block_node_or_indentless_sequence?)?)* +// ***** * +// BLOCK-END +// +// +func yaml_parser_parse_block_mapping_value(parser *yaml_parser_t, event *yaml_event_t) bool { + token := peek_token(parser) + if token == nil { + return false + } + if token.typ == yaml_VALUE_TOKEN { + mark := token.end_mark + skip_token(parser) + token = peek_token(parser) + if token == nil { + return false + } + if token.typ != yaml_KEY_TOKEN && + token.typ != yaml_VALUE_TOKEN && + token.typ != yaml_BLOCK_END_TOKEN { + parser.states = append(parser.states, yaml_PARSE_BLOCK_MAPPING_KEY_STATE) + return yaml_parser_parse_node(parser, event, true, true) + } + parser.state = yaml_PARSE_BLOCK_MAPPING_KEY_STATE + return yaml_parser_process_empty_scalar(parser, event, mark) + } + parser.state = yaml_PARSE_BLOCK_MAPPING_KEY_STATE + return yaml_parser_process_empty_scalar(parser, event, token.start_mark) +} + +// Parse the productions: +// flow_sequence ::= FLOW-SEQUENCE-START +// ******************* +// (flow_sequence_entry FLOW-ENTRY)* +// * ********** +// flow_sequence_entry? +// * +// FLOW-SEQUENCE-END +// ***************** +// flow_sequence_entry ::= flow_node | KEY flow_node? (VALUE flow_node?)? +// * +// +func yaml_parser_parse_flow_sequence_entry(parser *yaml_parser_t, event *yaml_event_t, first bool) bool { + if first { + token := peek_token(parser) + if token == nil { + return false + } + parser.marks = append(parser.marks, token.start_mark) + skip_token(parser) + } + token := peek_token(parser) + if token == nil { + return false + } + if token.typ != yaml_FLOW_SEQUENCE_END_TOKEN { + if !first { + if token.typ == yaml_FLOW_ENTRY_TOKEN { + skip_token(parser) + token = peek_token(parser) + if token == nil { + return false + } + } else { + context_mark := parser.marks[len(parser.marks)-1] + parser.marks = parser.marks[:len(parser.marks)-1] + return yaml_parser_set_parser_error_context(parser, + "while parsing a flow sequence", context_mark, + "did not find expected ',' or ']'", token.start_mark) + } + } + + if token.typ == yaml_KEY_TOKEN { + parser.state = yaml_PARSE_FLOW_SEQUENCE_ENTRY_MAPPING_KEY_STATE + *event = yaml_event_t{ + typ: yaml_MAPPING_START_EVENT, + start_mark: token.start_mark, + end_mark: token.end_mark, + implicit: true, + style: yaml_style_t(yaml_FLOW_MAPPING_STYLE), + } + skip_token(parser) + return true + } else if token.typ != yaml_FLOW_SEQUENCE_END_TOKEN { + parser.states = append(parser.states, yaml_PARSE_FLOW_SEQUENCE_ENTRY_STATE) + return yaml_parser_parse_node(parser, event, false, false) + } + } + + parser.state = parser.states[len(parser.states)-1] + parser.states = parser.states[:len(parser.states)-1] + parser.marks = parser.marks[:len(parser.marks)-1] + + *event = yaml_event_t{ + typ: yaml_SEQUENCE_END_EVENT, + start_mark: token.start_mark, + end_mark: token.end_mark, + } + yaml_parser_set_event_comments(parser, event) + + skip_token(parser) + return true +} + +// +// Parse the productions: +// flow_sequence_entry ::= flow_node | KEY flow_node? (VALUE flow_node?)? +// *** * +// +func yaml_parser_parse_flow_sequence_entry_mapping_key(parser *yaml_parser_t, event *yaml_event_t) bool { + token := peek_token(parser) + if token == nil { + return false + } + if token.typ != yaml_VALUE_TOKEN && + token.typ != yaml_FLOW_ENTRY_TOKEN && + token.typ != yaml_FLOW_SEQUENCE_END_TOKEN { + parser.states = append(parser.states, yaml_PARSE_FLOW_SEQUENCE_ENTRY_MAPPING_VALUE_STATE) + return yaml_parser_parse_node(parser, event, false, false) + } + mark := token.end_mark + skip_token(parser) + parser.state = yaml_PARSE_FLOW_SEQUENCE_ENTRY_MAPPING_VALUE_STATE + return yaml_parser_process_empty_scalar(parser, event, mark) +} + +// Parse the productions: +// flow_sequence_entry ::= flow_node | KEY flow_node? (VALUE flow_node?)? +// ***** * +// +func yaml_parser_parse_flow_sequence_entry_mapping_value(parser *yaml_parser_t, event *yaml_event_t) bool { + token := peek_token(parser) + if token == nil { + return false + } + if token.typ == yaml_VALUE_TOKEN { + skip_token(parser) + token := peek_token(parser) + if token == nil { + return false + } + if token.typ != yaml_FLOW_ENTRY_TOKEN && token.typ != yaml_FLOW_SEQUENCE_END_TOKEN { + parser.states = append(parser.states, yaml_PARSE_FLOW_SEQUENCE_ENTRY_MAPPING_END_STATE) + return yaml_parser_parse_node(parser, event, false, false) + } + } + parser.state = yaml_PARSE_FLOW_SEQUENCE_ENTRY_MAPPING_END_STATE + return yaml_parser_process_empty_scalar(parser, event, token.start_mark) +} + +// Parse the productions: +// flow_sequence_entry ::= flow_node | KEY flow_node? (VALUE flow_node?)? +// * +// +func yaml_parser_parse_flow_sequence_entry_mapping_end(parser *yaml_parser_t, event *yaml_event_t) bool { + token := peek_token(parser) + if token == nil { + return false + } + parser.state = yaml_PARSE_FLOW_SEQUENCE_ENTRY_STATE + *event = yaml_event_t{ + typ: yaml_MAPPING_END_EVENT, + start_mark: token.start_mark, + end_mark: token.start_mark, // [Go] Shouldn't this be end_mark? + } + return true +} + +// Parse the productions: +// flow_mapping ::= FLOW-MAPPING-START +// ****************** +// (flow_mapping_entry FLOW-ENTRY)* +// * ********** +// flow_mapping_entry? +// ****************** +// FLOW-MAPPING-END +// **************** +// flow_mapping_entry ::= flow_node | KEY flow_node? (VALUE flow_node?)? +// * *** * +// +func yaml_parser_parse_flow_mapping_key(parser *yaml_parser_t, event *yaml_event_t, first bool) bool { + if first { + token := peek_token(parser) + parser.marks = append(parser.marks, token.start_mark) + skip_token(parser) + } + + token := peek_token(parser) + if token == nil { + return false + } + + if token.typ != yaml_FLOW_MAPPING_END_TOKEN { + if !first { + if token.typ == yaml_FLOW_ENTRY_TOKEN { + skip_token(parser) + token = peek_token(parser) + if token == nil { + return false + } + } else { + context_mark := parser.marks[len(parser.marks)-1] + parser.marks = parser.marks[:len(parser.marks)-1] + return yaml_parser_set_parser_error_context(parser, + "while parsing a flow mapping", context_mark, + "did not find expected ',' or '}'", token.start_mark) + } + } + + if token.typ == yaml_KEY_TOKEN { + skip_token(parser) + token = peek_token(parser) + if token == nil { + return false + } + if token.typ != yaml_VALUE_TOKEN && + token.typ != yaml_FLOW_ENTRY_TOKEN && + token.typ != yaml_FLOW_MAPPING_END_TOKEN { + parser.states = append(parser.states, yaml_PARSE_FLOW_MAPPING_VALUE_STATE) + return yaml_parser_parse_node(parser, event, false, false) + } else { + parser.state = yaml_PARSE_FLOW_MAPPING_VALUE_STATE + return yaml_parser_process_empty_scalar(parser, event, token.start_mark) + } + } else if token.typ != yaml_FLOW_MAPPING_END_TOKEN { + parser.states = append(parser.states, yaml_PARSE_FLOW_MAPPING_EMPTY_VALUE_STATE) + return yaml_parser_parse_node(parser, event, false, false) + } + } + + parser.state = parser.states[len(parser.states)-1] + parser.states = parser.states[:len(parser.states)-1] + parser.marks = parser.marks[:len(parser.marks)-1] + *event = yaml_event_t{ + typ: yaml_MAPPING_END_EVENT, + start_mark: token.start_mark, + end_mark: token.end_mark, + } + yaml_parser_set_event_comments(parser, event) + skip_token(parser) + return true +} + +// Parse the productions: +// flow_mapping_entry ::= flow_node | KEY flow_node? (VALUE flow_node?)? +// * ***** * +// +func yaml_parser_parse_flow_mapping_value(parser *yaml_parser_t, event *yaml_event_t, empty bool) bool { + token := peek_token(parser) + if token == nil { + return false + } + if empty { + parser.state = yaml_PARSE_FLOW_MAPPING_KEY_STATE + return yaml_parser_process_empty_scalar(parser, event, token.start_mark) + } + if token.typ == yaml_VALUE_TOKEN { + skip_token(parser) + token = peek_token(parser) + if token == nil { + return false + } + if token.typ != yaml_FLOW_ENTRY_TOKEN && token.typ != yaml_FLOW_MAPPING_END_TOKEN { + parser.states = append(parser.states, yaml_PARSE_FLOW_MAPPING_KEY_STATE) + return yaml_parser_parse_node(parser, event, false, false) + } + } + parser.state = yaml_PARSE_FLOW_MAPPING_KEY_STATE + return yaml_parser_process_empty_scalar(parser, event, token.start_mark) +} + +// Generate an empty scalar event. +func yaml_parser_process_empty_scalar(parser *yaml_parser_t, event *yaml_event_t, mark yaml_mark_t) bool { + *event = yaml_event_t{ + typ: yaml_SCALAR_EVENT, + start_mark: mark, + end_mark: mark, + value: nil, // Empty + implicit: true, + style: yaml_style_t(yaml_PLAIN_SCALAR_STYLE), + } + return true +} + +var default_tag_directives = []yaml_tag_directive_t{ + {[]byte("!"), []byte("!")}, + {[]byte("!!"), []byte("tag:yaml.org,2002:")}, +} + +// Parse directives. +func yaml_parser_process_directives(parser *yaml_parser_t, + version_directive_ref **yaml_version_directive_t, + tag_directives_ref *[]yaml_tag_directive_t) bool { + + var version_directive *yaml_version_directive_t + var tag_directives []yaml_tag_directive_t + + token := peek_token(parser) + if token == nil { + return false + } + + for token.typ == yaml_VERSION_DIRECTIVE_TOKEN || token.typ == yaml_TAG_DIRECTIVE_TOKEN { + if token.typ == yaml_VERSION_DIRECTIVE_TOKEN { + if version_directive != nil { + yaml_parser_set_parser_error(parser, + "found duplicate %YAML directive", token.start_mark) + return false + } + if token.major != 1 || token.minor != 1 { + yaml_parser_set_parser_error(parser, + "found incompatible YAML document", token.start_mark) + return false + } + version_directive = &yaml_version_directive_t{ + major: token.major, + minor: token.minor, + } + } else if token.typ == yaml_TAG_DIRECTIVE_TOKEN { + value := yaml_tag_directive_t{ + handle: token.value, + prefix: token.prefix, + } + if !yaml_parser_append_tag_directive(parser, value, false, token.start_mark) { + return false + } + tag_directives = append(tag_directives, value) + } + + skip_token(parser) + token = peek_token(parser) + if token == nil { + return false + } + } + + for i := range default_tag_directives { + if !yaml_parser_append_tag_directive(parser, default_tag_directives[i], true, token.start_mark) { + return false + } + } + + if version_directive_ref != nil { + *version_directive_ref = version_directive + } + if tag_directives_ref != nil { + *tag_directives_ref = tag_directives + } + return true +} + +// Append a tag directive to the directives stack. +func yaml_parser_append_tag_directive(parser *yaml_parser_t, value yaml_tag_directive_t, allow_duplicates bool, mark yaml_mark_t) bool { + for i := range parser.tag_directives { + if bytes.Equal(value.handle, parser.tag_directives[i].handle) { + if allow_duplicates { + return true + } + return yaml_parser_set_parser_error(parser, "found duplicate %TAG directive", mark) + } + } + + // [Go] I suspect the copy is unnecessary. This was likely done + // because there was no way to track ownership of the data. + value_copy := yaml_tag_directive_t{ + handle: make([]byte, len(value.handle)), + prefix: make([]byte, len(value.prefix)), + } + copy(value_copy.handle, value.handle) + copy(value_copy.prefix, value.prefix) + parser.tag_directives = append(parser.tag_directives, value_copy) + return true +} diff --git a/mcp/dsx-exchange-mcp/vendor/gopkg.in/yaml.v3/readerc.go b/mcp/dsx-exchange-mcp/vendor/gopkg.in/yaml.v3/readerc.go new file mode 100644 index 0000000..b7de0a8 --- /dev/null +++ b/mcp/dsx-exchange-mcp/vendor/gopkg.in/yaml.v3/readerc.go @@ -0,0 +1,434 @@ +// +// Copyright (c) 2011-2019 Canonical Ltd +// Copyright (c) 2006-2010 Kirill Simonov +// +// Permission is hereby granted, free of charge, to any person obtaining a copy of +// this software and associated documentation files (the "Software"), to deal in +// the Software without restriction, including without limitation the rights to +// use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies +// of the Software, and to permit persons to whom the Software is furnished to do +// so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in all +// copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. + +package yaml + +import ( + "io" +) + +// Set the reader error and return 0. +func yaml_parser_set_reader_error(parser *yaml_parser_t, problem string, offset int, value int) bool { + parser.error = yaml_READER_ERROR + parser.problem = problem + parser.problem_offset = offset + parser.problem_value = value + return false +} + +// Byte order marks. +const ( + bom_UTF8 = "\xef\xbb\xbf" + bom_UTF16LE = "\xff\xfe" + bom_UTF16BE = "\xfe\xff" +) + +// Determine the input stream encoding by checking the BOM symbol. If no BOM is +// found, the UTF-8 encoding is assumed. Return 1 on success, 0 on failure. +func yaml_parser_determine_encoding(parser *yaml_parser_t) bool { + // Ensure that we had enough bytes in the raw buffer. + for !parser.eof && len(parser.raw_buffer)-parser.raw_buffer_pos < 3 { + if !yaml_parser_update_raw_buffer(parser) { + return false + } + } + + // Determine the encoding. + buf := parser.raw_buffer + pos := parser.raw_buffer_pos + avail := len(buf) - pos + if avail >= 2 && buf[pos] == bom_UTF16LE[0] && buf[pos+1] == bom_UTF16LE[1] { + parser.encoding = yaml_UTF16LE_ENCODING + parser.raw_buffer_pos += 2 + parser.offset += 2 + } else if avail >= 2 && buf[pos] == bom_UTF16BE[0] && buf[pos+1] == bom_UTF16BE[1] { + parser.encoding = yaml_UTF16BE_ENCODING + parser.raw_buffer_pos += 2 + parser.offset += 2 + } else if avail >= 3 && buf[pos] == bom_UTF8[0] && buf[pos+1] == bom_UTF8[1] && buf[pos+2] == bom_UTF8[2] { + parser.encoding = yaml_UTF8_ENCODING + parser.raw_buffer_pos += 3 + parser.offset += 3 + } else { + parser.encoding = yaml_UTF8_ENCODING + } + return true +} + +// Update the raw buffer. +func yaml_parser_update_raw_buffer(parser *yaml_parser_t) bool { + size_read := 0 + + // Return if the raw buffer is full. + if parser.raw_buffer_pos == 0 && len(parser.raw_buffer) == cap(parser.raw_buffer) { + return true + } + + // Return on EOF. + if parser.eof { + return true + } + + // Move the remaining bytes in the raw buffer to the beginning. + if parser.raw_buffer_pos > 0 && parser.raw_buffer_pos < len(parser.raw_buffer) { + copy(parser.raw_buffer, parser.raw_buffer[parser.raw_buffer_pos:]) + } + parser.raw_buffer = parser.raw_buffer[:len(parser.raw_buffer)-parser.raw_buffer_pos] + parser.raw_buffer_pos = 0 + + // Call the read handler to fill the buffer. + size_read, err := parser.read_handler(parser, parser.raw_buffer[len(parser.raw_buffer):cap(parser.raw_buffer)]) + parser.raw_buffer = parser.raw_buffer[:len(parser.raw_buffer)+size_read] + if err == io.EOF { + parser.eof = true + } else if err != nil { + return yaml_parser_set_reader_error(parser, "input error: "+err.Error(), parser.offset, -1) + } + return true +} + +// Ensure that the buffer contains at least `length` characters. +// Return true on success, false on failure. +// +// The length is supposed to be significantly less that the buffer size. +func yaml_parser_update_buffer(parser *yaml_parser_t, length int) bool { + if parser.read_handler == nil { + panic("read handler must be set") + } + + // [Go] This function was changed to guarantee the requested length size at EOF. + // The fact we need to do this is pretty awful, but the description above implies + // for that to be the case, and there are tests + + // If the EOF flag is set and the raw buffer is empty, do nothing. + if parser.eof && parser.raw_buffer_pos == len(parser.raw_buffer) { + // [Go] ACTUALLY! Read the documentation of this function above. + // This is just broken. To return true, we need to have the + // given length in the buffer. Not doing that means every single + // check that calls this function to make sure the buffer has a + // given length is Go) panicking; or C) accessing invalid memory. + //return true + } + + // Return if the buffer contains enough characters. + if parser.unread >= length { + return true + } + + // Determine the input encoding if it is not known yet. + if parser.encoding == yaml_ANY_ENCODING { + if !yaml_parser_determine_encoding(parser) { + return false + } + } + + // Move the unread characters to the beginning of the buffer. + buffer_len := len(parser.buffer) + if parser.buffer_pos > 0 && parser.buffer_pos < buffer_len { + copy(parser.buffer, parser.buffer[parser.buffer_pos:]) + buffer_len -= parser.buffer_pos + parser.buffer_pos = 0 + } else if parser.buffer_pos == buffer_len { + buffer_len = 0 + parser.buffer_pos = 0 + } + + // Open the whole buffer for writing, and cut it before returning. + parser.buffer = parser.buffer[:cap(parser.buffer)] + + // Fill the buffer until it has enough characters. + first := true + for parser.unread < length { + + // Fill the raw buffer if necessary. + if !first || parser.raw_buffer_pos == len(parser.raw_buffer) { + if !yaml_parser_update_raw_buffer(parser) { + parser.buffer = parser.buffer[:buffer_len] + return false + } + } + first = false + + // Decode the raw buffer. + inner: + for parser.raw_buffer_pos != len(parser.raw_buffer) { + var value rune + var width int + + raw_unread := len(parser.raw_buffer) - parser.raw_buffer_pos + + // Decode the next character. + switch parser.encoding { + case yaml_UTF8_ENCODING: + // Decode a UTF-8 character. Check RFC 3629 + // (http://www.ietf.org/rfc/rfc3629.txt) for more details. + // + // The following table (taken from the RFC) is used for + // decoding. + // + // Char. number range | UTF-8 octet sequence + // (hexadecimal) | (binary) + // --------------------+------------------------------------ + // 0000 0000-0000 007F | 0xxxxxxx + // 0000 0080-0000 07FF | 110xxxxx 10xxxxxx + // 0000 0800-0000 FFFF | 1110xxxx 10xxxxxx 10xxxxxx + // 0001 0000-0010 FFFF | 11110xxx 10xxxxxx 10xxxxxx 10xxxxxx + // + // Additionally, the characters in the range 0xD800-0xDFFF + // are prohibited as they are reserved for use with UTF-16 + // surrogate pairs. + + // Determine the length of the UTF-8 sequence. + octet := parser.raw_buffer[parser.raw_buffer_pos] + switch { + case octet&0x80 == 0x00: + width = 1 + case octet&0xE0 == 0xC0: + width = 2 + case octet&0xF0 == 0xE0: + width = 3 + case octet&0xF8 == 0xF0: + width = 4 + default: + // The leading octet is invalid. + return yaml_parser_set_reader_error(parser, + "invalid leading UTF-8 octet", + parser.offset, int(octet)) + } + + // Check if the raw buffer contains an incomplete character. + if width > raw_unread { + if parser.eof { + return yaml_parser_set_reader_error(parser, + "incomplete UTF-8 octet sequence", + parser.offset, -1) + } + break inner + } + + // Decode the leading octet. + switch { + case octet&0x80 == 0x00: + value = rune(octet & 0x7F) + case octet&0xE0 == 0xC0: + value = rune(octet & 0x1F) + case octet&0xF0 == 0xE0: + value = rune(octet & 0x0F) + case octet&0xF8 == 0xF0: + value = rune(octet & 0x07) + default: + value = 0 + } + + // Check and decode the trailing octets. + for k := 1; k < width; k++ { + octet = parser.raw_buffer[parser.raw_buffer_pos+k] + + // Check if the octet is valid. + if (octet & 0xC0) != 0x80 { + return yaml_parser_set_reader_error(parser, + "invalid trailing UTF-8 octet", + parser.offset+k, int(octet)) + } + + // Decode the octet. + value = (value << 6) + rune(octet&0x3F) + } + + // Check the length of the sequence against the value. + switch { + case width == 1: + case width == 2 && value >= 0x80: + case width == 3 && value >= 0x800: + case width == 4 && value >= 0x10000: + default: + return yaml_parser_set_reader_error(parser, + "invalid length of a UTF-8 sequence", + parser.offset, -1) + } + + // Check the range of the value. + if value >= 0xD800 && value <= 0xDFFF || value > 0x10FFFF { + return yaml_parser_set_reader_error(parser, + "invalid Unicode character", + parser.offset, int(value)) + } + + case yaml_UTF16LE_ENCODING, yaml_UTF16BE_ENCODING: + var low, high int + if parser.encoding == yaml_UTF16LE_ENCODING { + low, high = 0, 1 + } else { + low, high = 1, 0 + } + + // The UTF-16 encoding is not as simple as one might + // naively think. Check RFC 2781 + // (http://www.ietf.org/rfc/rfc2781.txt). + // + // Normally, two subsequent bytes describe a Unicode + // character. However a special technique (called a + // surrogate pair) is used for specifying character + // values larger than 0xFFFF. + // + // A surrogate pair consists of two pseudo-characters: + // high surrogate area (0xD800-0xDBFF) + // low surrogate area (0xDC00-0xDFFF) + // + // The following formulas are used for decoding + // and encoding characters using surrogate pairs: + // + // U = U' + 0x10000 (0x01 00 00 <= U <= 0x10 FF FF) + // U' = yyyyyyyyyyxxxxxxxxxx (0 <= U' <= 0x0F FF FF) + // W1 = 110110yyyyyyyyyy + // W2 = 110111xxxxxxxxxx + // + // where U is the character value, W1 is the high surrogate + // area, W2 is the low surrogate area. + + // Check for incomplete UTF-16 character. + if raw_unread < 2 { + if parser.eof { + return yaml_parser_set_reader_error(parser, + "incomplete UTF-16 character", + parser.offset, -1) + } + break inner + } + + // Get the character. + value = rune(parser.raw_buffer[parser.raw_buffer_pos+low]) + + (rune(parser.raw_buffer[parser.raw_buffer_pos+high]) << 8) + + // Check for unexpected low surrogate area. + if value&0xFC00 == 0xDC00 { + return yaml_parser_set_reader_error(parser, + "unexpected low surrogate area", + parser.offset, int(value)) + } + + // Check for a high surrogate area. + if value&0xFC00 == 0xD800 { + width = 4 + + // Check for incomplete surrogate pair. + if raw_unread < 4 { + if parser.eof { + return yaml_parser_set_reader_error(parser, + "incomplete UTF-16 surrogate pair", + parser.offset, -1) + } + break inner + } + + // Get the next character. + value2 := rune(parser.raw_buffer[parser.raw_buffer_pos+low+2]) + + (rune(parser.raw_buffer[parser.raw_buffer_pos+high+2]) << 8) + + // Check for a low surrogate area. + if value2&0xFC00 != 0xDC00 { + return yaml_parser_set_reader_error(parser, + "expected low surrogate area", + parser.offset+2, int(value2)) + } + + // Generate the value of the surrogate pair. + value = 0x10000 + ((value & 0x3FF) << 10) + (value2 & 0x3FF) + } else { + width = 2 + } + + default: + panic("impossible") + } + + // Check if the character is in the allowed range: + // #x9 | #xA | #xD | [#x20-#x7E] (8 bit) + // | #x85 | [#xA0-#xD7FF] | [#xE000-#xFFFD] (16 bit) + // | [#x10000-#x10FFFF] (32 bit) + switch { + case value == 0x09: + case value == 0x0A: + case value == 0x0D: + case value >= 0x20 && value <= 0x7E: + case value == 0x85: + case value >= 0xA0 && value <= 0xD7FF: + case value >= 0xE000 && value <= 0xFFFD: + case value >= 0x10000 && value <= 0x10FFFF: + default: + return yaml_parser_set_reader_error(parser, + "control characters are not allowed", + parser.offset, int(value)) + } + + // Move the raw pointers. + parser.raw_buffer_pos += width + parser.offset += width + + // Finally put the character into the buffer. + if value <= 0x7F { + // 0000 0000-0000 007F . 0xxxxxxx + parser.buffer[buffer_len+0] = byte(value) + buffer_len += 1 + } else if value <= 0x7FF { + // 0000 0080-0000 07FF . 110xxxxx 10xxxxxx + parser.buffer[buffer_len+0] = byte(0xC0 + (value >> 6)) + parser.buffer[buffer_len+1] = byte(0x80 + (value & 0x3F)) + buffer_len += 2 + } else if value <= 0xFFFF { + // 0000 0800-0000 FFFF . 1110xxxx 10xxxxxx 10xxxxxx + parser.buffer[buffer_len+0] = byte(0xE0 + (value >> 12)) + parser.buffer[buffer_len+1] = byte(0x80 + ((value >> 6) & 0x3F)) + parser.buffer[buffer_len+2] = byte(0x80 + (value & 0x3F)) + buffer_len += 3 + } else { + // 0001 0000-0010 FFFF . 11110xxx 10xxxxxx 10xxxxxx 10xxxxxx + parser.buffer[buffer_len+0] = byte(0xF0 + (value >> 18)) + parser.buffer[buffer_len+1] = byte(0x80 + ((value >> 12) & 0x3F)) + parser.buffer[buffer_len+2] = byte(0x80 + ((value >> 6) & 0x3F)) + parser.buffer[buffer_len+3] = byte(0x80 + (value & 0x3F)) + buffer_len += 4 + } + + parser.unread++ + } + + // On EOF, put NUL into the buffer and return. + if parser.eof { + parser.buffer[buffer_len] = 0 + buffer_len++ + parser.unread++ + break + } + } + // [Go] Read the documentation of this function above. To return true, + // we need to have the given length in the buffer. Not doing that means + // every single check that calls this function to make sure the buffer + // has a given length is Go) panicking; or C) accessing invalid memory. + // This happens here due to the EOF above breaking early. + for buffer_len < length { + parser.buffer[buffer_len] = 0 + buffer_len++ + } + parser.buffer = parser.buffer[:buffer_len] + return true +} diff --git a/mcp/dsx-exchange-mcp/vendor/gopkg.in/yaml.v3/resolve.go b/mcp/dsx-exchange-mcp/vendor/gopkg.in/yaml.v3/resolve.go new file mode 100644 index 0000000..64ae888 --- /dev/null +++ b/mcp/dsx-exchange-mcp/vendor/gopkg.in/yaml.v3/resolve.go @@ -0,0 +1,326 @@ +// +// Copyright (c) 2011-2019 Canonical Ltd +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package yaml + +import ( + "encoding/base64" + "math" + "regexp" + "strconv" + "strings" + "time" +) + +type resolveMapItem struct { + value interface{} + tag string +} + +var resolveTable = make([]byte, 256) +var resolveMap = make(map[string]resolveMapItem) + +func init() { + t := resolveTable + t[int('+')] = 'S' // Sign + t[int('-')] = 'S' + for _, c := range "0123456789" { + t[int(c)] = 'D' // Digit + } + for _, c := range "yYnNtTfFoO~" { + t[int(c)] = 'M' // In map + } + t[int('.')] = '.' // Float (potentially in map) + + var resolveMapList = []struct { + v interface{} + tag string + l []string + }{ + {true, boolTag, []string{"true", "True", "TRUE"}}, + {false, boolTag, []string{"false", "False", "FALSE"}}, + {nil, nullTag, []string{"", "~", "null", "Null", "NULL"}}, + {math.NaN(), floatTag, []string{".nan", ".NaN", ".NAN"}}, + {math.Inf(+1), floatTag, []string{".inf", ".Inf", ".INF"}}, + {math.Inf(+1), floatTag, []string{"+.inf", "+.Inf", "+.INF"}}, + {math.Inf(-1), floatTag, []string{"-.inf", "-.Inf", "-.INF"}}, + {"<<", mergeTag, []string{"<<"}}, + } + + m := resolveMap + for _, item := range resolveMapList { + for _, s := range item.l { + m[s] = resolveMapItem{item.v, item.tag} + } + } +} + +const ( + nullTag = "!!null" + boolTag = "!!bool" + strTag = "!!str" + intTag = "!!int" + floatTag = "!!float" + timestampTag = "!!timestamp" + seqTag = "!!seq" + mapTag = "!!map" + binaryTag = "!!binary" + mergeTag = "!!merge" +) + +var longTags = make(map[string]string) +var shortTags = make(map[string]string) + +func init() { + for _, stag := range []string{nullTag, boolTag, strTag, intTag, floatTag, timestampTag, seqTag, mapTag, binaryTag, mergeTag} { + ltag := longTag(stag) + longTags[stag] = ltag + shortTags[ltag] = stag + } +} + +const longTagPrefix = "tag:yaml.org,2002:" + +func shortTag(tag string) string { + if strings.HasPrefix(tag, longTagPrefix) { + if stag, ok := shortTags[tag]; ok { + return stag + } + return "!!" + tag[len(longTagPrefix):] + } + return tag +} + +func longTag(tag string) string { + if strings.HasPrefix(tag, "!!") { + if ltag, ok := longTags[tag]; ok { + return ltag + } + return longTagPrefix + tag[2:] + } + return tag +} + +func resolvableTag(tag string) bool { + switch tag { + case "", strTag, boolTag, intTag, floatTag, nullTag, timestampTag: + return true + } + return false +} + +var yamlStyleFloat = regexp.MustCompile(`^[-+]?(\.[0-9]+|[0-9]+(\.[0-9]*)?)([eE][-+]?[0-9]+)?$`) + +func resolve(tag string, in string) (rtag string, out interface{}) { + tag = shortTag(tag) + if !resolvableTag(tag) { + return tag, in + } + + defer func() { + switch tag { + case "", rtag, strTag, binaryTag: + return + case floatTag: + if rtag == intTag { + switch v := out.(type) { + case int64: + rtag = floatTag + out = float64(v) + return + case int: + rtag = floatTag + out = float64(v) + return + } + } + } + failf("cannot decode %s `%s` as a %s", shortTag(rtag), in, shortTag(tag)) + }() + + // Any data is accepted as a !!str or !!binary. + // Otherwise, the prefix is enough of a hint about what it might be. + hint := byte('N') + if in != "" { + hint = resolveTable[in[0]] + } + if hint != 0 && tag != strTag && tag != binaryTag { + // Handle things we can lookup in a map. + if item, ok := resolveMap[in]; ok { + return item.tag, item.value + } + + // Base 60 floats are a bad idea, were dropped in YAML 1.2, and + // are purposefully unsupported here. They're still quoted on + // the way out for compatibility with other parser, though. + + switch hint { + case 'M': + // We've already checked the map above. + + case '.': + // Not in the map, so maybe a normal float. + floatv, err := strconv.ParseFloat(in, 64) + if err == nil { + return floatTag, floatv + } + + case 'D', 'S': + // Int, float, or timestamp. + // Only try values as a timestamp if the value is unquoted or there's an explicit + // !!timestamp tag. + if tag == "" || tag == timestampTag { + t, ok := parseTimestamp(in) + if ok { + return timestampTag, t + } + } + + plain := strings.Replace(in, "_", "", -1) + intv, err := strconv.ParseInt(plain, 0, 64) + if err == nil { + if intv == int64(int(intv)) { + return intTag, int(intv) + } else { + return intTag, intv + } + } + uintv, err := strconv.ParseUint(plain, 0, 64) + if err == nil { + return intTag, uintv + } + if yamlStyleFloat.MatchString(plain) { + floatv, err := strconv.ParseFloat(plain, 64) + if err == nil { + return floatTag, floatv + } + } + if strings.HasPrefix(plain, "0b") { + intv, err := strconv.ParseInt(plain[2:], 2, 64) + if err == nil { + if intv == int64(int(intv)) { + return intTag, int(intv) + } else { + return intTag, intv + } + } + uintv, err := strconv.ParseUint(plain[2:], 2, 64) + if err == nil { + return intTag, uintv + } + } else if strings.HasPrefix(plain, "-0b") { + intv, err := strconv.ParseInt("-"+plain[3:], 2, 64) + if err == nil { + if true || intv == int64(int(intv)) { + return intTag, int(intv) + } else { + return intTag, intv + } + } + } + // Octals as introduced in version 1.2 of the spec. + // Octals from the 1.1 spec, spelled as 0777, are still + // decoded by default in v3 as well for compatibility. + // May be dropped in v4 depending on how usage evolves. + if strings.HasPrefix(plain, "0o") { + intv, err := strconv.ParseInt(plain[2:], 8, 64) + if err == nil { + if intv == int64(int(intv)) { + return intTag, int(intv) + } else { + return intTag, intv + } + } + uintv, err := strconv.ParseUint(plain[2:], 8, 64) + if err == nil { + return intTag, uintv + } + } else if strings.HasPrefix(plain, "-0o") { + intv, err := strconv.ParseInt("-"+plain[3:], 8, 64) + if err == nil { + if true || intv == int64(int(intv)) { + return intTag, int(intv) + } else { + return intTag, intv + } + } + } + default: + panic("internal error: missing handler for resolver table: " + string(rune(hint)) + " (with " + in + ")") + } + } + return strTag, in +} + +// encodeBase64 encodes s as base64 that is broken up into multiple lines +// as appropriate for the resulting length. +func encodeBase64(s string) string { + const lineLen = 70 + encLen := base64.StdEncoding.EncodedLen(len(s)) + lines := encLen/lineLen + 1 + buf := make([]byte, encLen*2+lines) + in := buf[0:encLen] + out := buf[encLen:] + base64.StdEncoding.Encode(in, []byte(s)) + k := 0 + for i := 0; i < len(in); i += lineLen { + j := i + lineLen + if j > len(in) { + j = len(in) + } + k += copy(out[k:], in[i:j]) + if lines > 1 { + out[k] = '\n' + k++ + } + } + return string(out[:k]) +} + +// This is a subset of the formats allowed by the regular expression +// defined at http://yaml.org/type/timestamp.html. +var allowedTimestampFormats = []string{ + "2006-1-2T15:4:5.999999999Z07:00", // RCF3339Nano with short date fields. + "2006-1-2t15:4:5.999999999Z07:00", // RFC3339Nano with short date fields and lower-case "t". + "2006-1-2 15:4:5.999999999", // space separated with no time zone + "2006-1-2", // date only + // Notable exception: time.Parse cannot handle: "2001-12-14 21:59:43.10 -5" + // from the set of examples. +} + +// parseTimestamp parses s as a timestamp string and +// returns the timestamp and reports whether it succeeded. +// Timestamp formats are defined at http://yaml.org/type/timestamp.html +func parseTimestamp(s string) (time.Time, bool) { + // TODO write code to check all the formats supported by + // http://yaml.org/type/timestamp.html instead of using time.Parse. + + // Quick check: all date formats start with YYYY-. + i := 0 + for ; i < len(s); i++ { + if c := s[i]; c < '0' || c > '9' { + break + } + } + if i != 4 || i == len(s) || s[i] != '-' { + return time.Time{}, false + } + for _, format := range allowedTimestampFormats { + if t, err := time.Parse(format, s); err == nil { + return t, true + } + } + return time.Time{}, false +} diff --git a/mcp/dsx-exchange-mcp/vendor/gopkg.in/yaml.v3/scannerc.go b/mcp/dsx-exchange-mcp/vendor/gopkg.in/yaml.v3/scannerc.go new file mode 100644 index 0000000..ca00701 --- /dev/null +++ b/mcp/dsx-exchange-mcp/vendor/gopkg.in/yaml.v3/scannerc.go @@ -0,0 +1,3038 @@ +// +// Copyright (c) 2011-2019 Canonical Ltd +// Copyright (c) 2006-2010 Kirill Simonov +// +// Permission is hereby granted, free of charge, to any person obtaining a copy of +// this software and associated documentation files (the "Software"), to deal in +// the Software without restriction, including without limitation the rights to +// use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies +// of the Software, and to permit persons to whom the Software is furnished to do +// so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in all +// copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. + +package yaml + +import ( + "bytes" + "fmt" +) + +// Introduction +// ************ +// +// The following notes assume that you are familiar with the YAML specification +// (http://yaml.org/spec/1.2/spec.html). We mostly follow it, although in +// some cases we are less restrictive that it requires. +// +// The process of transforming a YAML stream into a sequence of events is +// divided on two steps: Scanning and Parsing. +// +// The Scanner transforms the input stream into a sequence of tokens, while the +// parser transform the sequence of tokens produced by the Scanner into a +// sequence of parsing events. +// +// The Scanner is rather clever and complicated. The Parser, on the contrary, +// is a straightforward implementation of a recursive-descendant parser (or, +// LL(1) parser, as it is usually called). +// +// Actually there are two issues of Scanning that might be called "clever", the +// rest is quite straightforward. The issues are "block collection start" and +// "simple keys". Both issues are explained below in details. +// +// Here the Scanning step is explained and implemented. We start with the list +// of all the tokens produced by the Scanner together with short descriptions. +// +// Now, tokens: +// +// STREAM-START(encoding) # The stream start. +// STREAM-END # The stream end. +// VERSION-DIRECTIVE(major,minor) # The '%YAML' directive. +// TAG-DIRECTIVE(handle,prefix) # The '%TAG' directive. +// DOCUMENT-START # '---' +// DOCUMENT-END # '...' +// BLOCK-SEQUENCE-START # Indentation increase denoting a block +// BLOCK-MAPPING-START # sequence or a block mapping. +// BLOCK-END # Indentation decrease. +// FLOW-SEQUENCE-START # '[' +// FLOW-SEQUENCE-END # ']' +// BLOCK-SEQUENCE-START # '{' +// BLOCK-SEQUENCE-END # '}' +// BLOCK-ENTRY # '-' +// FLOW-ENTRY # ',' +// KEY # '?' or nothing (simple keys). +// VALUE # ':' +// ALIAS(anchor) # '*anchor' +// ANCHOR(anchor) # '&anchor' +// TAG(handle,suffix) # '!handle!suffix' +// SCALAR(value,style) # A scalar. +// +// The following two tokens are "virtual" tokens denoting the beginning and the +// end of the stream: +// +// STREAM-START(encoding) +// STREAM-END +// +// We pass the information about the input stream encoding with the +// STREAM-START token. +// +// The next two tokens are responsible for tags: +// +// VERSION-DIRECTIVE(major,minor) +// TAG-DIRECTIVE(handle,prefix) +// +// Example: +// +// %YAML 1.1 +// %TAG ! !foo +// %TAG !yaml! tag:yaml.org,2002: +// --- +// +// The correspoding sequence of tokens: +// +// STREAM-START(utf-8) +// VERSION-DIRECTIVE(1,1) +// TAG-DIRECTIVE("!","!foo") +// TAG-DIRECTIVE("!yaml","tag:yaml.org,2002:") +// DOCUMENT-START +// STREAM-END +// +// Note that the VERSION-DIRECTIVE and TAG-DIRECTIVE tokens occupy a whole +// line. +// +// The document start and end indicators are represented by: +// +// DOCUMENT-START +// DOCUMENT-END +// +// Note that if a YAML stream contains an implicit document (without '---' +// and '...' indicators), no DOCUMENT-START and DOCUMENT-END tokens will be +// produced. +// +// In the following examples, we present whole documents together with the +// produced tokens. +// +// 1. An implicit document: +// +// 'a scalar' +// +// Tokens: +// +// STREAM-START(utf-8) +// SCALAR("a scalar",single-quoted) +// STREAM-END +// +// 2. An explicit document: +// +// --- +// 'a scalar' +// ... +// +// Tokens: +// +// STREAM-START(utf-8) +// DOCUMENT-START +// SCALAR("a scalar",single-quoted) +// DOCUMENT-END +// STREAM-END +// +// 3. Several documents in a stream: +// +// 'a scalar' +// --- +// 'another scalar' +// --- +// 'yet another scalar' +// +// Tokens: +// +// STREAM-START(utf-8) +// SCALAR("a scalar",single-quoted) +// DOCUMENT-START +// SCALAR("another scalar",single-quoted) +// DOCUMENT-START +// SCALAR("yet another scalar",single-quoted) +// STREAM-END +// +// We have already introduced the SCALAR token above. The following tokens are +// used to describe aliases, anchors, tag, and scalars: +// +// ALIAS(anchor) +// ANCHOR(anchor) +// TAG(handle,suffix) +// SCALAR(value,style) +// +// The following series of examples illustrate the usage of these tokens: +// +// 1. A recursive sequence: +// +// &A [ *A ] +// +// Tokens: +// +// STREAM-START(utf-8) +// ANCHOR("A") +// FLOW-SEQUENCE-START +// ALIAS("A") +// FLOW-SEQUENCE-END +// STREAM-END +// +// 2. A tagged scalar: +// +// !!float "3.14" # A good approximation. +// +// Tokens: +// +// STREAM-START(utf-8) +// TAG("!!","float") +// SCALAR("3.14",double-quoted) +// STREAM-END +// +// 3. Various scalar styles: +// +// --- # Implicit empty plain scalars do not produce tokens. +// --- a plain scalar +// --- 'a single-quoted scalar' +// --- "a double-quoted scalar" +// --- |- +// a literal scalar +// --- >- +// a folded +// scalar +// +// Tokens: +// +// STREAM-START(utf-8) +// DOCUMENT-START +// DOCUMENT-START +// SCALAR("a plain scalar",plain) +// DOCUMENT-START +// SCALAR("a single-quoted scalar",single-quoted) +// DOCUMENT-START +// SCALAR("a double-quoted scalar",double-quoted) +// DOCUMENT-START +// SCALAR("a literal scalar",literal) +// DOCUMENT-START +// SCALAR("a folded scalar",folded) +// STREAM-END +// +// Now it's time to review collection-related tokens. We will start with +// flow collections: +// +// FLOW-SEQUENCE-START +// FLOW-SEQUENCE-END +// FLOW-MAPPING-START +// FLOW-MAPPING-END +// FLOW-ENTRY +// KEY +// VALUE +// +// The tokens FLOW-SEQUENCE-START, FLOW-SEQUENCE-END, FLOW-MAPPING-START, and +// FLOW-MAPPING-END represent the indicators '[', ']', '{', and '}' +// correspondingly. FLOW-ENTRY represent the ',' indicator. Finally the +// indicators '?' and ':', which are used for denoting mapping keys and values, +// are represented by the KEY and VALUE tokens. +// +// The following examples show flow collections: +// +// 1. A flow sequence: +// +// [item 1, item 2, item 3] +// +// Tokens: +// +// STREAM-START(utf-8) +// FLOW-SEQUENCE-START +// SCALAR("item 1",plain) +// FLOW-ENTRY +// SCALAR("item 2",plain) +// FLOW-ENTRY +// SCALAR("item 3",plain) +// FLOW-SEQUENCE-END +// STREAM-END +// +// 2. A flow mapping: +// +// { +// a simple key: a value, # Note that the KEY token is produced. +// ? a complex key: another value, +// } +// +// Tokens: +// +// STREAM-START(utf-8) +// FLOW-MAPPING-START +// KEY +// SCALAR("a simple key",plain) +// VALUE +// SCALAR("a value",plain) +// FLOW-ENTRY +// KEY +// SCALAR("a complex key",plain) +// VALUE +// SCALAR("another value",plain) +// FLOW-ENTRY +// FLOW-MAPPING-END +// STREAM-END +// +// A simple key is a key which is not denoted by the '?' indicator. Note that +// the Scanner still produce the KEY token whenever it encounters a simple key. +// +// For scanning block collections, the following tokens are used (note that we +// repeat KEY and VALUE here): +// +// BLOCK-SEQUENCE-START +// BLOCK-MAPPING-START +// BLOCK-END +// BLOCK-ENTRY +// KEY +// VALUE +// +// The tokens BLOCK-SEQUENCE-START and BLOCK-MAPPING-START denote indentation +// increase that precedes a block collection (cf. the INDENT token in Python). +// The token BLOCK-END denote indentation decrease that ends a block collection +// (cf. the DEDENT token in Python). However YAML has some syntax pecularities +// that makes detections of these tokens more complex. +// +// The tokens BLOCK-ENTRY, KEY, and VALUE are used to represent the indicators +// '-', '?', and ':' correspondingly. +// +// The following examples show how the tokens BLOCK-SEQUENCE-START, +// BLOCK-MAPPING-START, and BLOCK-END are emitted by the Scanner: +// +// 1. Block sequences: +// +// - item 1 +// - item 2 +// - +// - item 3.1 +// - item 3.2 +// - +// key 1: value 1 +// key 2: value 2 +// +// Tokens: +// +// STREAM-START(utf-8) +// BLOCK-SEQUENCE-START +// BLOCK-ENTRY +// SCALAR("item 1",plain) +// BLOCK-ENTRY +// SCALAR("item 2",plain) +// BLOCK-ENTRY +// BLOCK-SEQUENCE-START +// BLOCK-ENTRY +// SCALAR("item 3.1",plain) +// BLOCK-ENTRY +// SCALAR("item 3.2",plain) +// BLOCK-END +// BLOCK-ENTRY +// BLOCK-MAPPING-START +// KEY +// SCALAR("key 1",plain) +// VALUE +// SCALAR("value 1",plain) +// KEY +// SCALAR("key 2",plain) +// VALUE +// SCALAR("value 2",plain) +// BLOCK-END +// BLOCK-END +// STREAM-END +// +// 2. Block mappings: +// +// a simple key: a value # The KEY token is produced here. +// ? a complex key +// : another value +// a mapping: +// key 1: value 1 +// key 2: value 2 +// a sequence: +// - item 1 +// - item 2 +// +// Tokens: +// +// STREAM-START(utf-8) +// BLOCK-MAPPING-START +// KEY +// SCALAR("a simple key",plain) +// VALUE +// SCALAR("a value",plain) +// KEY +// SCALAR("a complex key",plain) +// VALUE +// SCALAR("another value",plain) +// KEY +// SCALAR("a mapping",plain) +// BLOCK-MAPPING-START +// KEY +// SCALAR("key 1",plain) +// VALUE +// SCALAR("value 1",plain) +// KEY +// SCALAR("key 2",plain) +// VALUE +// SCALAR("value 2",plain) +// BLOCK-END +// KEY +// SCALAR("a sequence",plain) +// VALUE +// BLOCK-SEQUENCE-START +// BLOCK-ENTRY +// SCALAR("item 1",plain) +// BLOCK-ENTRY +// SCALAR("item 2",plain) +// BLOCK-END +// BLOCK-END +// STREAM-END +// +// YAML does not always require to start a new block collection from a new +// line. If the current line contains only '-', '?', and ':' indicators, a new +// block collection may start at the current line. The following examples +// illustrate this case: +// +// 1. Collections in a sequence: +// +// - - item 1 +// - item 2 +// - key 1: value 1 +// key 2: value 2 +// - ? complex key +// : complex value +// +// Tokens: +// +// STREAM-START(utf-8) +// BLOCK-SEQUENCE-START +// BLOCK-ENTRY +// BLOCK-SEQUENCE-START +// BLOCK-ENTRY +// SCALAR("item 1",plain) +// BLOCK-ENTRY +// SCALAR("item 2",plain) +// BLOCK-END +// BLOCK-ENTRY +// BLOCK-MAPPING-START +// KEY +// SCALAR("key 1",plain) +// VALUE +// SCALAR("value 1",plain) +// KEY +// SCALAR("key 2",plain) +// VALUE +// SCALAR("value 2",plain) +// BLOCK-END +// BLOCK-ENTRY +// BLOCK-MAPPING-START +// KEY +// SCALAR("complex key") +// VALUE +// SCALAR("complex value") +// BLOCK-END +// BLOCK-END +// STREAM-END +// +// 2. Collections in a mapping: +// +// ? a sequence +// : - item 1 +// - item 2 +// ? a mapping +// : key 1: value 1 +// key 2: value 2 +// +// Tokens: +// +// STREAM-START(utf-8) +// BLOCK-MAPPING-START +// KEY +// SCALAR("a sequence",plain) +// VALUE +// BLOCK-SEQUENCE-START +// BLOCK-ENTRY +// SCALAR("item 1",plain) +// BLOCK-ENTRY +// SCALAR("item 2",plain) +// BLOCK-END +// KEY +// SCALAR("a mapping",plain) +// VALUE +// BLOCK-MAPPING-START +// KEY +// SCALAR("key 1",plain) +// VALUE +// SCALAR("value 1",plain) +// KEY +// SCALAR("key 2",plain) +// VALUE +// SCALAR("value 2",plain) +// BLOCK-END +// BLOCK-END +// STREAM-END +// +// YAML also permits non-indented sequences if they are included into a block +// mapping. In this case, the token BLOCK-SEQUENCE-START is not produced: +// +// key: +// - item 1 # BLOCK-SEQUENCE-START is NOT produced here. +// - item 2 +// +// Tokens: +// +// STREAM-START(utf-8) +// BLOCK-MAPPING-START +// KEY +// SCALAR("key",plain) +// VALUE +// BLOCK-ENTRY +// SCALAR("item 1",plain) +// BLOCK-ENTRY +// SCALAR("item 2",plain) +// BLOCK-END +// + +// Ensure that the buffer contains the required number of characters. +// Return true on success, false on failure (reader error or memory error). +func cache(parser *yaml_parser_t, length int) bool { + // [Go] This was inlined: !cache(A, B) -> unread < B && !update(A, B) + return parser.unread >= length || yaml_parser_update_buffer(parser, length) +} + +// Advance the buffer pointer. +func skip(parser *yaml_parser_t) { + if !is_blank(parser.buffer, parser.buffer_pos) { + parser.newlines = 0 + } + parser.mark.index++ + parser.mark.column++ + parser.unread-- + parser.buffer_pos += width(parser.buffer[parser.buffer_pos]) +} + +func skip_line(parser *yaml_parser_t) { + if is_crlf(parser.buffer, parser.buffer_pos) { + parser.mark.index += 2 + parser.mark.column = 0 + parser.mark.line++ + parser.unread -= 2 + parser.buffer_pos += 2 + parser.newlines++ + } else if is_break(parser.buffer, parser.buffer_pos) { + parser.mark.index++ + parser.mark.column = 0 + parser.mark.line++ + parser.unread-- + parser.buffer_pos += width(parser.buffer[parser.buffer_pos]) + parser.newlines++ + } +} + +// Copy a character to a string buffer and advance pointers. +func read(parser *yaml_parser_t, s []byte) []byte { + if !is_blank(parser.buffer, parser.buffer_pos) { + parser.newlines = 0 + } + w := width(parser.buffer[parser.buffer_pos]) + if w == 0 { + panic("invalid character sequence") + } + if len(s) == 0 { + s = make([]byte, 0, 32) + } + if w == 1 && len(s)+w <= cap(s) { + s = s[:len(s)+1] + s[len(s)-1] = parser.buffer[parser.buffer_pos] + parser.buffer_pos++ + } else { + s = append(s, parser.buffer[parser.buffer_pos:parser.buffer_pos+w]...) + parser.buffer_pos += w + } + parser.mark.index++ + parser.mark.column++ + parser.unread-- + return s +} + +// Copy a line break character to a string buffer and advance pointers. +func read_line(parser *yaml_parser_t, s []byte) []byte { + buf := parser.buffer + pos := parser.buffer_pos + switch { + case buf[pos] == '\r' && buf[pos+1] == '\n': + // CR LF . LF + s = append(s, '\n') + parser.buffer_pos += 2 + parser.mark.index++ + parser.unread-- + case buf[pos] == '\r' || buf[pos] == '\n': + // CR|LF . LF + s = append(s, '\n') + parser.buffer_pos += 1 + case buf[pos] == '\xC2' && buf[pos+1] == '\x85': + // NEL . LF + s = append(s, '\n') + parser.buffer_pos += 2 + case buf[pos] == '\xE2' && buf[pos+1] == '\x80' && (buf[pos+2] == '\xA8' || buf[pos+2] == '\xA9'): + // LS|PS . LS|PS + s = append(s, buf[parser.buffer_pos:pos+3]...) + parser.buffer_pos += 3 + default: + return s + } + parser.mark.index++ + parser.mark.column = 0 + parser.mark.line++ + parser.unread-- + parser.newlines++ + return s +} + +// Get the next token. +func yaml_parser_scan(parser *yaml_parser_t, token *yaml_token_t) bool { + // Erase the token object. + *token = yaml_token_t{} // [Go] Is this necessary? + + // No tokens after STREAM-END or error. + if parser.stream_end_produced || parser.error != yaml_NO_ERROR { + return true + } + + // Ensure that the tokens queue contains enough tokens. + if !parser.token_available { + if !yaml_parser_fetch_more_tokens(parser) { + return false + } + } + + // Fetch the next token from the queue. + *token = parser.tokens[parser.tokens_head] + parser.tokens_head++ + parser.tokens_parsed++ + parser.token_available = false + + if token.typ == yaml_STREAM_END_TOKEN { + parser.stream_end_produced = true + } + return true +} + +// Set the scanner error and return false. +func yaml_parser_set_scanner_error(parser *yaml_parser_t, context string, context_mark yaml_mark_t, problem string) bool { + parser.error = yaml_SCANNER_ERROR + parser.context = context + parser.context_mark = context_mark + parser.problem = problem + parser.problem_mark = parser.mark + return false +} + +func yaml_parser_set_scanner_tag_error(parser *yaml_parser_t, directive bool, context_mark yaml_mark_t, problem string) bool { + context := "while parsing a tag" + if directive { + context = "while parsing a %TAG directive" + } + return yaml_parser_set_scanner_error(parser, context, context_mark, problem) +} + +func trace(args ...interface{}) func() { + pargs := append([]interface{}{"+++"}, args...) + fmt.Println(pargs...) + pargs = append([]interface{}{"---"}, args...) + return func() { fmt.Println(pargs...) } +} + +// Ensure that the tokens queue contains at least one token which can be +// returned to the Parser. +func yaml_parser_fetch_more_tokens(parser *yaml_parser_t) bool { + // While we need more tokens to fetch, do it. + for { + // [Go] The comment parsing logic requires a lookahead of two tokens + // so that foot comments may be parsed in time of associating them + // with the tokens that are parsed before them, and also for line + // comments to be transformed into head comments in some edge cases. + if parser.tokens_head < len(parser.tokens)-2 { + // If a potential simple key is at the head position, we need to fetch + // the next token to disambiguate it. + head_tok_idx, ok := parser.simple_keys_by_tok[parser.tokens_parsed] + if !ok { + break + } else if valid, ok := yaml_simple_key_is_valid(parser, &parser.simple_keys[head_tok_idx]); !ok { + return false + } else if !valid { + break + } + } + // Fetch the next token. + if !yaml_parser_fetch_next_token(parser) { + return false + } + } + + parser.token_available = true + return true +} + +// The dispatcher for token fetchers. +func yaml_parser_fetch_next_token(parser *yaml_parser_t) (ok bool) { + // Ensure that the buffer is initialized. + if parser.unread < 1 && !yaml_parser_update_buffer(parser, 1) { + return false + } + + // Check if we just started scanning. Fetch STREAM-START then. + if !parser.stream_start_produced { + return yaml_parser_fetch_stream_start(parser) + } + + scan_mark := parser.mark + + // Eat whitespaces and comments until we reach the next token. + if !yaml_parser_scan_to_next_token(parser) { + return false + } + + // [Go] While unrolling indents, transform the head comments of prior + // indentation levels observed after scan_start into foot comments at + // the respective indexes. + + // Check the indentation level against the current column. + if !yaml_parser_unroll_indent(parser, parser.mark.column, scan_mark) { + return false + } + + // Ensure that the buffer contains at least 4 characters. 4 is the length + // of the longest indicators ('--- ' and '... '). + if parser.unread < 4 && !yaml_parser_update_buffer(parser, 4) { + return false + } + + // Is it the end of the stream? + if is_z(parser.buffer, parser.buffer_pos) { + return yaml_parser_fetch_stream_end(parser) + } + + // Is it a directive? + if parser.mark.column == 0 && parser.buffer[parser.buffer_pos] == '%' { + return yaml_parser_fetch_directive(parser) + } + + buf := parser.buffer + pos := parser.buffer_pos + + // Is it the document start indicator? + if parser.mark.column == 0 && buf[pos] == '-' && buf[pos+1] == '-' && buf[pos+2] == '-' && is_blankz(buf, pos+3) { + return yaml_parser_fetch_document_indicator(parser, yaml_DOCUMENT_START_TOKEN) + } + + // Is it the document end indicator? + if parser.mark.column == 0 && buf[pos] == '.' && buf[pos+1] == '.' && buf[pos+2] == '.' && is_blankz(buf, pos+3) { + return yaml_parser_fetch_document_indicator(parser, yaml_DOCUMENT_END_TOKEN) + } + + comment_mark := parser.mark + if len(parser.tokens) > 0 && (parser.flow_level == 0 && buf[pos] == ':' || parser.flow_level > 0 && buf[pos] == ',') { + // Associate any following comments with the prior token. + comment_mark = parser.tokens[len(parser.tokens)-1].start_mark + } + defer func() { + if !ok { + return + } + if len(parser.tokens) > 0 && parser.tokens[len(parser.tokens)-1].typ == yaml_BLOCK_ENTRY_TOKEN { + // Sequence indicators alone have no line comments. It becomes + // a head comment for whatever follows. + return + } + if !yaml_parser_scan_line_comment(parser, comment_mark) { + ok = false + return + } + }() + + // Is it the flow sequence start indicator? + if buf[pos] == '[' { + return yaml_parser_fetch_flow_collection_start(parser, yaml_FLOW_SEQUENCE_START_TOKEN) + } + + // Is it the flow mapping start indicator? + if parser.buffer[parser.buffer_pos] == '{' { + return yaml_parser_fetch_flow_collection_start(parser, yaml_FLOW_MAPPING_START_TOKEN) + } + + // Is it the flow sequence end indicator? + if parser.buffer[parser.buffer_pos] == ']' { + return yaml_parser_fetch_flow_collection_end(parser, + yaml_FLOW_SEQUENCE_END_TOKEN) + } + + // Is it the flow mapping end indicator? + if parser.buffer[parser.buffer_pos] == '}' { + return yaml_parser_fetch_flow_collection_end(parser, + yaml_FLOW_MAPPING_END_TOKEN) + } + + // Is it the flow entry indicator? + if parser.buffer[parser.buffer_pos] == ',' { + return yaml_parser_fetch_flow_entry(parser) + } + + // Is it the block entry indicator? + if parser.buffer[parser.buffer_pos] == '-' && is_blankz(parser.buffer, parser.buffer_pos+1) { + return yaml_parser_fetch_block_entry(parser) + } + + // Is it the key indicator? + if parser.buffer[parser.buffer_pos] == '?' && (parser.flow_level > 0 || is_blankz(parser.buffer, parser.buffer_pos+1)) { + return yaml_parser_fetch_key(parser) + } + + // Is it the value indicator? + if parser.buffer[parser.buffer_pos] == ':' && (parser.flow_level > 0 || is_blankz(parser.buffer, parser.buffer_pos+1)) { + return yaml_parser_fetch_value(parser) + } + + // Is it an alias? + if parser.buffer[parser.buffer_pos] == '*' { + return yaml_parser_fetch_anchor(parser, yaml_ALIAS_TOKEN) + } + + // Is it an anchor? + if parser.buffer[parser.buffer_pos] == '&' { + return yaml_parser_fetch_anchor(parser, yaml_ANCHOR_TOKEN) + } + + // Is it a tag? + if parser.buffer[parser.buffer_pos] == '!' { + return yaml_parser_fetch_tag(parser) + } + + // Is it a literal scalar? + if parser.buffer[parser.buffer_pos] == '|' && parser.flow_level == 0 { + return yaml_parser_fetch_block_scalar(parser, true) + } + + // Is it a folded scalar? + if parser.buffer[parser.buffer_pos] == '>' && parser.flow_level == 0 { + return yaml_parser_fetch_block_scalar(parser, false) + } + + // Is it a single-quoted scalar? + if parser.buffer[parser.buffer_pos] == '\'' { + return yaml_parser_fetch_flow_scalar(parser, true) + } + + // Is it a double-quoted scalar? + if parser.buffer[parser.buffer_pos] == '"' { + return yaml_parser_fetch_flow_scalar(parser, false) + } + + // Is it a plain scalar? + // + // A plain scalar may start with any non-blank characters except + // + // '-', '?', ':', ',', '[', ']', '{', '}', + // '#', '&', '*', '!', '|', '>', '\'', '\"', + // '%', '@', '`'. + // + // In the block context (and, for the '-' indicator, in the flow context + // too), it may also start with the characters + // + // '-', '?', ':' + // + // if it is followed by a non-space character. + // + // The last rule is more restrictive than the specification requires. + // [Go] TODO Make this logic more reasonable. + //switch parser.buffer[parser.buffer_pos] { + //case '-', '?', ':', ',', '?', '-', ',', ':', ']', '[', '}', '{', '&', '#', '!', '*', '>', '|', '"', '\'', '@', '%', '-', '`': + //} + if !(is_blankz(parser.buffer, parser.buffer_pos) || parser.buffer[parser.buffer_pos] == '-' || + parser.buffer[parser.buffer_pos] == '?' || parser.buffer[parser.buffer_pos] == ':' || + parser.buffer[parser.buffer_pos] == ',' || parser.buffer[parser.buffer_pos] == '[' || + parser.buffer[parser.buffer_pos] == ']' || parser.buffer[parser.buffer_pos] == '{' || + parser.buffer[parser.buffer_pos] == '}' || parser.buffer[parser.buffer_pos] == '#' || + parser.buffer[parser.buffer_pos] == '&' || parser.buffer[parser.buffer_pos] == '*' || + parser.buffer[parser.buffer_pos] == '!' || parser.buffer[parser.buffer_pos] == '|' || + parser.buffer[parser.buffer_pos] == '>' || parser.buffer[parser.buffer_pos] == '\'' || + parser.buffer[parser.buffer_pos] == '"' || parser.buffer[parser.buffer_pos] == '%' || + parser.buffer[parser.buffer_pos] == '@' || parser.buffer[parser.buffer_pos] == '`') || + (parser.buffer[parser.buffer_pos] == '-' && !is_blank(parser.buffer, parser.buffer_pos+1)) || + (parser.flow_level == 0 && + (parser.buffer[parser.buffer_pos] == '?' || parser.buffer[parser.buffer_pos] == ':') && + !is_blankz(parser.buffer, parser.buffer_pos+1)) { + return yaml_parser_fetch_plain_scalar(parser) + } + + // If we don't determine the token type so far, it is an error. + return yaml_parser_set_scanner_error(parser, + "while scanning for the next token", parser.mark, + "found character that cannot start any token") +} + +func yaml_simple_key_is_valid(parser *yaml_parser_t, simple_key *yaml_simple_key_t) (valid, ok bool) { + if !simple_key.possible { + return false, true + } + + // The 1.2 specification says: + // + // "If the ? indicator is omitted, parsing needs to see past the + // implicit key to recognize it as such. To limit the amount of + // lookahead required, the “:” indicator must appear at most 1024 + // Unicode characters beyond the start of the key. In addition, the key + // is restricted to a single line." + // + if simple_key.mark.line < parser.mark.line || simple_key.mark.index+1024 < parser.mark.index { + // Check if the potential simple key to be removed is required. + if simple_key.required { + return false, yaml_parser_set_scanner_error(parser, + "while scanning a simple key", simple_key.mark, + "could not find expected ':'") + } + simple_key.possible = false + return false, true + } + return true, true +} + +// Check if a simple key may start at the current position and add it if +// needed. +func yaml_parser_save_simple_key(parser *yaml_parser_t) bool { + // A simple key is required at the current position if the scanner is in + // the block context and the current column coincides with the indentation + // level. + + required := parser.flow_level == 0 && parser.indent == parser.mark.column + + // + // If the current position may start a simple key, save it. + // + if parser.simple_key_allowed { + simple_key := yaml_simple_key_t{ + possible: true, + required: required, + token_number: parser.tokens_parsed + (len(parser.tokens) - parser.tokens_head), + mark: parser.mark, + } + + if !yaml_parser_remove_simple_key(parser) { + return false + } + parser.simple_keys[len(parser.simple_keys)-1] = simple_key + parser.simple_keys_by_tok[simple_key.token_number] = len(parser.simple_keys) - 1 + } + return true +} + +// Remove a potential simple key at the current flow level. +func yaml_parser_remove_simple_key(parser *yaml_parser_t) bool { + i := len(parser.simple_keys) - 1 + if parser.simple_keys[i].possible { + // If the key is required, it is an error. + if parser.simple_keys[i].required { + return yaml_parser_set_scanner_error(parser, + "while scanning a simple key", parser.simple_keys[i].mark, + "could not find expected ':'") + } + // Remove the key from the stack. + parser.simple_keys[i].possible = false + delete(parser.simple_keys_by_tok, parser.simple_keys[i].token_number) + } + return true +} + +// max_flow_level limits the flow_level +const max_flow_level = 10000 + +// Increase the flow level and resize the simple key list if needed. +func yaml_parser_increase_flow_level(parser *yaml_parser_t) bool { + // Reset the simple key on the next level. + parser.simple_keys = append(parser.simple_keys, yaml_simple_key_t{ + possible: false, + required: false, + token_number: parser.tokens_parsed + (len(parser.tokens) - parser.tokens_head), + mark: parser.mark, + }) + + // Increase the flow level. + parser.flow_level++ + if parser.flow_level > max_flow_level { + return yaml_parser_set_scanner_error(parser, + "while increasing flow level", parser.simple_keys[len(parser.simple_keys)-1].mark, + fmt.Sprintf("exceeded max depth of %d", max_flow_level)) + } + return true +} + +// Decrease the flow level. +func yaml_parser_decrease_flow_level(parser *yaml_parser_t) bool { + if parser.flow_level > 0 { + parser.flow_level-- + last := len(parser.simple_keys) - 1 + delete(parser.simple_keys_by_tok, parser.simple_keys[last].token_number) + parser.simple_keys = parser.simple_keys[:last] + } + return true +} + +// max_indents limits the indents stack size +const max_indents = 10000 + +// Push the current indentation level to the stack and set the new level +// the current column is greater than the indentation level. In this case, +// append or insert the specified token into the token queue. +func yaml_parser_roll_indent(parser *yaml_parser_t, column, number int, typ yaml_token_type_t, mark yaml_mark_t) bool { + // In the flow context, do nothing. + if parser.flow_level > 0 { + return true + } + + if parser.indent < column { + // Push the current indentation level to the stack and set the new + // indentation level. + parser.indents = append(parser.indents, parser.indent) + parser.indent = column + if len(parser.indents) > max_indents { + return yaml_parser_set_scanner_error(parser, + "while increasing indent level", parser.simple_keys[len(parser.simple_keys)-1].mark, + fmt.Sprintf("exceeded max depth of %d", max_indents)) + } + + // Create a token and insert it into the queue. + token := yaml_token_t{ + typ: typ, + start_mark: mark, + end_mark: mark, + } + if number > -1 { + number -= parser.tokens_parsed + } + yaml_insert_token(parser, number, &token) + } + return true +} + +// Pop indentation levels from the indents stack until the current level +// becomes less or equal to the column. For each indentation level, append +// the BLOCK-END token. +func yaml_parser_unroll_indent(parser *yaml_parser_t, column int, scan_mark yaml_mark_t) bool { + // In the flow context, do nothing. + if parser.flow_level > 0 { + return true + } + + block_mark := scan_mark + block_mark.index-- + + // Loop through the indentation levels in the stack. + for parser.indent > column { + + // [Go] Reposition the end token before potential following + // foot comments of parent blocks. For that, search + // backwards for recent comments that were at the same + // indent as the block that is ending now. + stop_index := block_mark.index + for i := len(parser.comments) - 1; i >= 0; i-- { + comment := &parser.comments[i] + + if comment.end_mark.index < stop_index { + // Don't go back beyond the start of the comment/whitespace scan, unless column < 0. + // If requested indent column is < 0, then the document is over and everything else + // is a foot anyway. + break + } + if comment.start_mark.column == parser.indent+1 { + // This is a good match. But maybe there's a former comment + // at that same indent level, so keep searching. + block_mark = comment.start_mark + } + + // While the end of the former comment matches with + // the start of the following one, we know there's + // nothing in between and scanning is still safe. + stop_index = comment.scan_mark.index + } + + // Create a token and append it to the queue. + token := yaml_token_t{ + typ: yaml_BLOCK_END_TOKEN, + start_mark: block_mark, + end_mark: block_mark, + } + yaml_insert_token(parser, -1, &token) + + // Pop the indentation level. + parser.indent = parser.indents[len(parser.indents)-1] + parser.indents = parser.indents[:len(parser.indents)-1] + } + return true +} + +// Initialize the scanner and produce the STREAM-START token. +func yaml_parser_fetch_stream_start(parser *yaml_parser_t) bool { + + // Set the initial indentation. + parser.indent = -1 + + // Initialize the simple key stack. + parser.simple_keys = append(parser.simple_keys, yaml_simple_key_t{}) + + parser.simple_keys_by_tok = make(map[int]int) + + // A simple key is allowed at the beginning of the stream. + parser.simple_key_allowed = true + + // We have started. + parser.stream_start_produced = true + + // Create the STREAM-START token and append it to the queue. + token := yaml_token_t{ + typ: yaml_STREAM_START_TOKEN, + start_mark: parser.mark, + end_mark: parser.mark, + encoding: parser.encoding, + } + yaml_insert_token(parser, -1, &token) + return true +} + +// Produce the STREAM-END token and shut down the scanner. +func yaml_parser_fetch_stream_end(parser *yaml_parser_t) bool { + + // Force new line. + if parser.mark.column != 0 { + parser.mark.column = 0 + parser.mark.line++ + } + + // Reset the indentation level. + if !yaml_parser_unroll_indent(parser, -1, parser.mark) { + return false + } + + // Reset simple keys. + if !yaml_parser_remove_simple_key(parser) { + return false + } + + parser.simple_key_allowed = false + + // Create the STREAM-END token and append it to the queue. + token := yaml_token_t{ + typ: yaml_STREAM_END_TOKEN, + start_mark: parser.mark, + end_mark: parser.mark, + } + yaml_insert_token(parser, -1, &token) + return true +} + +// Produce a VERSION-DIRECTIVE or TAG-DIRECTIVE token. +func yaml_parser_fetch_directive(parser *yaml_parser_t) bool { + // Reset the indentation level. + if !yaml_parser_unroll_indent(parser, -1, parser.mark) { + return false + } + + // Reset simple keys. + if !yaml_parser_remove_simple_key(parser) { + return false + } + + parser.simple_key_allowed = false + + // Create the YAML-DIRECTIVE or TAG-DIRECTIVE token. + token := yaml_token_t{} + if !yaml_parser_scan_directive(parser, &token) { + return false + } + // Append the token to the queue. + yaml_insert_token(parser, -1, &token) + return true +} + +// Produce the DOCUMENT-START or DOCUMENT-END token. +func yaml_parser_fetch_document_indicator(parser *yaml_parser_t, typ yaml_token_type_t) bool { + // Reset the indentation level. + if !yaml_parser_unroll_indent(parser, -1, parser.mark) { + return false + } + + // Reset simple keys. + if !yaml_parser_remove_simple_key(parser) { + return false + } + + parser.simple_key_allowed = false + + // Consume the token. + start_mark := parser.mark + + skip(parser) + skip(parser) + skip(parser) + + end_mark := parser.mark + + // Create the DOCUMENT-START or DOCUMENT-END token. + token := yaml_token_t{ + typ: typ, + start_mark: start_mark, + end_mark: end_mark, + } + // Append the token to the queue. + yaml_insert_token(parser, -1, &token) + return true +} + +// Produce the FLOW-SEQUENCE-START or FLOW-MAPPING-START token. +func yaml_parser_fetch_flow_collection_start(parser *yaml_parser_t, typ yaml_token_type_t) bool { + + // The indicators '[' and '{' may start a simple key. + if !yaml_parser_save_simple_key(parser) { + return false + } + + // Increase the flow level. + if !yaml_parser_increase_flow_level(parser) { + return false + } + + // A simple key may follow the indicators '[' and '{'. + parser.simple_key_allowed = true + + // Consume the token. + start_mark := parser.mark + skip(parser) + end_mark := parser.mark + + // Create the FLOW-SEQUENCE-START of FLOW-MAPPING-START token. + token := yaml_token_t{ + typ: typ, + start_mark: start_mark, + end_mark: end_mark, + } + // Append the token to the queue. + yaml_insert_token(parser, -1, &token) + return true +} + +// Produce the FLOW-SEQUENCE-END or FLOW-MAPPING-END token. +func yaml_parser_fetch_flow_collection_end(parser *yaml_parser_t, typ yaml_token_type_t) bool { + // Reset any potential simple key on the current flow level. + if !yaml_parser_remove_simple_key(parser) { + return false + } + + // Decrease the flow level. + if !yaml_parser_decrease_flow_level(parser) { + return false + } + + // No simple keys after the indicators ']' and '}'. + parser.simple_key_allowed = false + + // Consume the token. + + start_mark := parser.mark + skip(parser) + end_mark := parser.mark + + // Create the FLOW-SEQUENCE-END of FLOW-MAPPING-END token. + token := yaml_token_t{ + typ: typ, + start_mark: start_mark, + end_mark: end_mark, + } + // Append the token to the queue. + yaml_insert_token(parser, -1, &token) + return true +} + +// Produce the FLOW-ENTRY token. +func yaml_parser_fetch_flow_entry(parser *yaml_parser_t) bool { + // Reset any potential simple keys on the current flow level. + if !yaml_parser_remove_simple_key(parser) { + return false + } + + // Simple keys are allowed after ','. + parser.simple_key_allowed = true + + // Consume the token. + start_mark := parser.mark + skip(parser) + end_mark := parser.mark + + // Create the FLOW-ENTRY token and append it to the queue. + token := yaml_token_t{ + typ: yaml_FLOW_ENTRY_TOKEN, + start_mark: start_mark, + end_mark: end_mark, + } + yaml_insert_token(parser, -1, &token) + return true +} + +// Produce the BLOCK-ENTRY token. +func yaml_parser_fetch_block_entry(parser *yaml_parser_t) bool { + // Check if the scanner is in the block context. + if parser.flow_level == 0 { + // Check if we are allowed to start a new entry. + if !parser.simple_key_allowed { + return yaml_parser_set_scanner_error(parser, "", parser.mark, + "block sequence entries are not allowed in this context") + } + // Add the BLOCK-SEQUENCE-START token if needed. + if !yaml_parser_roll_indent(parser, parser.mark.column, -1, yaml_BLOCK_SEQUENCE_START_TOKEN, parser.mark) { + return false + } + } else { + // It is an error for the '-' indicator to occur in the flow context, + // but we let the Parser detect and report about it because the Parser + // is able to point to the context. + } + + // Reset any potential simple keys on the current flow level. + if !yaml_parser_remove_simple_key(parser) { + return false + } + + // Simple keys are allowed after '-'. + parser.simple_key_allowed = true + + // Consume the token. + start_mark := parser.mark + skip(parser) + end_mark := parser.mark + + // Create the BLOCK-ENTRY token and append it to the queue. + token := yaml_token_t{ + typ: yaml_BLOCK_ENTRY_TOKEN, + start_mark: start_mark, + end_mark: end_mark, + } + yaml_insert_token(parser, -1, &token) + return true +} + +// Produce the KEY token. +func yaml_parser_fetch_key(parser *yaml_parser_t) bool { + + // In the block context, additional checks are required. + if parser.flow_level == 0 { + // Check if we are allowed to start a new key (not nessesary simple). + if !parser.simple_key_allowed { + return yaml_parser_set_scanner_error(parser, "", parser.mark, + "mapping keys are not allowed in this context") + } + // Add the BLOCK-MAPPING-START token if needed. + if !yaml_parser_roll_indent(parser, parser.mark.column, -1, yaml_BLOCK_MAPPING_START_TOKEN, parser.mark) { + return false + } + } + + // Reset any potential simple keys on the current flow level. + if !yaml_parser_remove_simple_key(parser) { + return false + } + + // Simple keys are allowed after '?' in the block context. + parser.simple_key_allowed = parser.flow_level == 0 + + // Consume the token. + start_mark := parser.mark + skip(parser) + end_mark := parser.mark + + // Create the KEY token and append it to the queue. + token := yaml_token_t{ + typ: yaml_KEY_TOKEN, + start_mark: start_mark, + end_mark: end_mark, + } + yaml_insert_token(parser, -1, &token) + return true +} + +// Produce the VALUE token. +func yaml_parser_fetch_value(parser *yaml_parser_t) bool { + + simple_key := &parser.simple_keys[len(parser.simple_keys)-1] + + // Have we found a simple key? + if valid, ok := yaml_simple_key_is_valid(parser, simple_key); !ok { + return false + + } else if valid { + + // Create the KEY token and insert it into the queue. + token := yaml_token_t{ + typ: yaml_KEY_TOKEN, + start_mark: simple_key.mark, + end_mark: simple_key.mark, + } + yaml_insert_token(parser, simple_key.token_number-parser.tokens_parsed, &token) + + // In the block context, we may need to add the BLOCK-MAPPING-START token. + if !yaml_parser_roll_indent(parser, simple_key.mark.column, + simple_key.token_number, + yaml_BLOCK_MAPPING_START_TOKEN, simple_key.mark) { + return false + } + + // Remove the simple key. + simple_key.possible = false + delete(parser.simple_keys_by_tok, simple_key.token_number) + + // A simple key cannot follow another simple key. + parser.simple_key_allowed = false + + } else { + // The ':' indicator follows a complex key. + + // In the block context, extra checks are required. + if parser.flow_level == 0 { + + // Check if we are allowed to start a complex value. + if !parser.simple_key_allowed { + return yaml_parser_set_scanner_error(parser, "", parser.mark, + "mapping values are not allowed in this context") + } + + // Add the BLOCK-MAPPING-START token if needed. + if !yaml_parser_roll_indent(parser, parser.mark.column, -1, yaml_BLOCK_MAPPING_START_TOKEN, parser.mark) { + return false + } + } + + // Simple keys after ':' are allowed in the block context. + parser.simple_key_allowed = parser.flow_level == 0 + } + + // Consume the token. + start_mark := parser.mark + skip(parser) + end_mark := parser.mark + + // Create the VALUE token and append it to the queue. + token := yaml_token_t{ + typ: yaml_VALUE_TOKEN, + start_mark: start_mark, + end_mark: end_mark, + } + yaml_insert_token(parser, -1, &token) + return true +} + +// Produce the ALIAS or ANCHOR token. +func yaml_parser_fetch_anchor(parser *yaml_parser_t, typ yaml_token_type_t) bool { + // An anchor or an alias could be a simple key. + if !yaml_parser_save_simple_key(parser) { + return false + } + + // A simple key cannot follow an anchor or an alias. + parser.simple_key_allowed = false + + // Create the ALIAS or ANCHOR token and append it to the queue. + var token yaml_token_t + if !yaml_parser_scan_anchor(parser, &token, typ) { + return false + } + yaml_insert_token(parser, -1, &token) + return true +} + +// Produce the TAG token. +func yaml_parser_fetch_tag(parser *yaml_parser_t) bool { + // A tag could be a simple key. + if !yaml_parser_save_simple_key(parser) { + return false + } + + // A simple key cannot follow a tag. + parser.simple_key_allowed = false + + // Create the TAG token and append it to the queue. + var token yaml_token_t + if !yaml_parser_scan_tag(parser, &token) { + return false + } + yaml_insert_token(parser, -1, &token) + return true +} + +// Produce the SCALAR(...,literal) or SCALAR(...,folded) tokens. +func yaml_parser_fetch_block_scalar(parser *yaml_parser_t, literal bool) bool { + // Remove any potential simple keys. + if !yaml_parser_remove_simple_key(parser) { + return false + } + + // A simple key may follow a block scalar. + parser.simple_key_allowed = true + + // Create the SCALAR token and append it to the queue. + var token yaml_token_t + if !yaml_parser_scan_block_scalar(parser, &token, literal) { + return false + } + yaml_insert_token(parser, -1, &token) + return true +} + +// Produce the SCALAR(...,single-quoted) or SCALAR(...,double-quoted) tokens. +func yaml_parser_fetch_flow_scalar(parser *yaml_parser_t, single bool) bool { + // A plain scalar could be a simple key. + if !yaml_parser_save_simple_key(parser) { + return false + } + + // A simple key cannot follow a flow scalar. + parser.simple_key_allowed = false + + // Create the SCALAR token and append it to the queue. + var token yaml_token_t + if !yaml_parser_scan_flow_scalar(parser, &token, single) { + return false + } + yaml_insert_token(parser, -1, &token) + return true +} + +// Produce the SCALAR(...,plain) token. +func yaml_parser_fetch_plain_scalar(parser *yaml_parser_t) bool { + // A plain scalar could be a simple key. + if !yaml_parser_save_simple_key(parser) { + return false + } + + // A simple key cannot follow a flow scalar. + parser.simple_key_allowed = false + + // Create the SCALAR token and append it to the queue. + var token yaml_token_t + if !yaml_parser_scan_plain_scalar(parser, &token) { + return false + } + yaml_insert_token(parser, -1, &token) + return true +} + +// Eat whitespaces and comments until the next token is found. +func yaml_parser_scan_to_next_token(parser *yaml_parser_t) bool { + + scan_mark := parser.mark + + // Until the next token is not found. + for { + // Allow the BOM mark to start a line. + if parser.unread < 1 && !yaml_parser_update_buffer(parser, 1) { + return false + } + if parser.mark.column == 0 && is_bom(parser.buffer, parser.buffer_pos) { + skip(parser) + } + + // Eat whitespaces. + // Tabs are allowed: + // - in the flow context + // - in the block context, but not at the beginning of the line or + // after '-', '?', or ':' (complex value). + if parser.unread < 1 && !yaml_parser_update_buffer(parser, 1) { + return false + } + + for parser.buffer[parser.buffer_pos] == ' ' || ((parser.flow_level > 0 || !parser.simple_key_allowed) && parser.buffer[parser.buffer_pos] == '\t') { + skip(parser) + if parser.unread < 1 && !yaml_parser_update_buffer(parser, 1) { + return false + } + } + + // Check if we just had a line comment under a sequence entry that + // looks more like a header to the following content. Similar to this: + // + // - # The comment + // - Some data + // + // If so, transform the line comment to a head comment and reposition. + if len(parser.comments) > 0 && len(parser.tokens) > 1 { + tokenA := parser.tokens[len(parser.tokens)-2] + tokenB := parser.tokens[len(parser.tokens)-1] + comment := &parser.comments[len(parser.comments)-1] + if tokenA.typ == yaml_BLOCK_SEQUENCE_START_TOKEN && tokenB.typ == yaml_BLOCK_ENTRY_TOKEN && len(comment.line) > 0 && !is_break(parser.buffer, parser.buffer_pos) { + // If it was in the prior line, reposition so it becomes a + // header of the follow up token. Otherwise, keep it in place + // so it becomes a header of the former. + comment.head = comment.line + comment.line = nil + if comment.start_mark.line == parser.mark.line-1 { + comment.token_mark = parser.mark + } + } + } + + // Eat a comment until a line break. + if parser.buffer[parser.buffer_pos] == '#' { + if !yaml_parser_scan_comments(parser, scan_mark) { + return false + } + } + + // If it is a line break, eat it. + if is_break(parser.buffer, parser.buffer_pos) { + if parser.unread < 2 && !yaml_parser_update_buffer(parser, 2) { + return false + } + skip_line(parser) + + // In the block context, a new line may start a simple key. + if parser.flow_level == 0 { + parser.simple_key_allowed = true + } + } else { + break // We have found a token. + } + } + + return true +} + +// Scan a YAML-DIRECTIVE or TAG-DIRECTIVE token. +// +// Scope: +// %YAML 1.1 # a comment \n +// ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ +// %TAG !yaml! tag:yaml.org,2002: \n +// ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ +// +func yaml_parser_scan_directive(parser *yaml_parser_t, token *yaml_token_t) bool { + // Eat '%'. + start_mark := parser.mark + skip(parser) + + // Scan the directive name. + var name []byte + if !yaml_parser_scan_directive_name(parser, start_mark, &name) { + return false + } + + // Is it a YAML directive? + if bytes.Equal(name, []byte("YAML")) { + // Scan the VERSION directive value. + var major, minor int8 + if !yaml_parser_scan_version_directive_value(parser, start_mark, &major, &minor) { + return false + } + end_mark := parser.mark + + // Create a VERSION-DIRECTIVE token. + *token = yaml_token_t{ + typ: yaml_VERSION_DIRECTIVE_TOKEN, + start_mark: start_mark, + end_mark: end_mark, + major: major, + minor: minor, + } + + // Is it a TAG directive? + } else if bytes.Equal(name, []byte("TAG")) { + // Scan the TAG directive value. + var handle, prefix []byte + if !yaml_parser_scan_tag_directive_value(parser, start_mark, &handle, &prefix) { + return false + } + end_mark := parser.mark + + // Create a TAG-DIRECTIVE token. + *token = yaml_token_t{ + typ: yaml_TAG_DIRECTIVE_TOKEN, + start_mark: start_mark, + end_mark: end_mark, + value: handle, + prefix: prefix, + } + + // Unknown directive. + } else { + yaml_parser_set_scanner_error(parser, "while scanning a directive", + start_mark, "found unknown directive name") + return false + } + + // Eat the rest of the line including any comments. + if parser.unread < 1 && !yaml_parser_update_buffer(parser, 1) { + return false + } + + for is_blank(parser.buffer, parser.buffer_pos) { + skip(parser) + if parser.unread < 1 && !yaml_parser_update_buffer(parser, 1) { + return false + } + } + + if parser.buffer[parser.buffer_pos] == '#' { + // [Go] Discard this inline comment for the time being. + //if !yaml_parser_scan_line_comment(parser, start_mark) { + // return false + //} + for !is_breakz(parser.buffer, parser.buffer_pos) { + skip(parser) + if parser.unread < 1 && !yaml_parser_update_buffer(parser, 1) { + return false + } + } + } + + // Check if we are at the end of the line. + if !is_breakz(parser.buffer, parser.buffer_pos) { + yaml_parser_set_scanner_error(parser, "while scanning a directive", + start_mark, "did not find expected comment or line break") + return false + } + + // Eat a line break. + if is_break(parser.buffer, parser.buffer_pos) { + if parser.unread < 2 && !yaml_parser_update_buffer(parser, 2) { + return false + } + skip_line(parser) + } + + return true +} + +// Scan the directive name. +// +// Scope: +// %YAML 1.1 # a comment \n +// ^^^^ +// %TAG !yaml! tag:yaml.org,2002: \n +// ^^^ +// +func yaml_parser_scan_directive_name(parser *yaml_parser_t, start_mark yaml_mark_t, name *[]byte) bool { + // Consume the directive name. + if parser.unread < 1 && !yaml_parser_update_buffer(parser, 1) { + return false + } + + var s []byte + for is_alpha(parser.buffer, parser.buffer_pos) { + s = read(parser, s) + if parser.unread < 1 && !yaml_parser_update_buffer(parser, 1) { + return false + } + } + + // Check if the name is empty. + if len(s) == 0 { + yaml_parser_set_scanner_error(parser, "while scanning a directive", + start_mark, "could not find expected directive name") + return false + } + + // Check for an blank character after the name. + if !is_blankz(parser.buffer, parser.buffer_pos) { + yaml_parser_set_scanner_error(parser, "while scanning a directive", + start_mark, "found unexpected non-alphabetical character") + return false + } + *name = s + return true +} + +// Scan the value of VERSION-DIRECTIVE. +// +// Scope: +// %YAML 1.1 # a comment \n +// ^^^^^^ +func yaml_parser_scan_version_directive_value(parser *yaml_parser_t, start_mark yaml_mark_t, major, minor *int8) bool { + // Eat whitespaces. + if parser.unread < 1 && !yaml_parser_update_buffer(parser, 1) { + return false + } + for is_blank(parser.buffer, parser.buffer_pos) { + skip(parser) + if parser.unread < 1 && !yaml_parser_update_buffer(parser, 1) { + return false + } + } + + // Consume the major version number. + if !yaml_parser_scan_version_directive_number(parser, start_mark, major) { + return false + } + + // Eat '.'. + if parser.buffer[parser.buffer_pos] != '.' { + return yaml_parser_set_scanner_error(parser, "while scanning a %YAML directive", + start_mark, "did not find expected digit or '.' character") + } + + skip(parser) + + // Consume the minor version number. + if !yaml_parser_scan_version_directive_number(parser, start_mark, minor) { + return false + } + return true +} + +const max_number_length = 2 + +// Scan the version number of VERSION-DIRECTIVE. +// +// Scope: +// %YAML 1.1 # a comment \n +// ^ +// %YAML 1.1 # a comment \n +// ^ +func yaml_parser_scan_version_directive_number(parser *yaml_parser_t, start_mark yaml_mark_t, number *int8) bool { + + // Repeat while the next character is digit. + if parser.unread < 1 && !yaml_parser_update_buffer(parser, 1) { + return false + } + var value, length int8 + for is_digit(parser.buffer, parser.buffer_pos) { + // Check if the number is too long. + length++ + if length > max_number_length { + return yaml_parser_set_scanner_error(parser, "while scanning a %YAML directive", + start_mark, "found extremely long version number") + } + value = value*10 + int8(as_digit(parser.buffer, parser.buffer_pos)) + skip(parser) + if parser.unread < 1 && !yaml_parser_update_buffer(parser, 1) { + return false + } + } + + // Check if the number was present. + if length == 0 { + return yaml_parser_set_scanner_error(parser, "while scanning a %YAML directive", + start_mark, "did not find expected version number") + } + *number = value + return true +} + +// Scan the value of a TAG-DIRECTIVE token. +// +// Scope: +// %TAG !yaml! tag:yaml.org,2002: \n +// ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ +// +func yaml_parser_scan_tag_directive_value(parser *yaml_parser_t, start_mark yaml_mark_t, handle, prefix *[]byte) bool { + var handle_value, prefix_value []byte + + // Eat whitespaces. + if parser.unread < 1 && !yaml_parser_update_buffer(parser, 1) { + return false + } + + for is_blank(parser.buffer, parser.buffer_pos) { + skip(parser) + if parser.unread < 1 && !yaml_parser_update_buffer(parser, 1) { + return false + } + } + + // Scan a handle. + if !yaml_parser_scan_tag_handle(parser, true, start_mark, &handle_value) { + return false + } + + // Expect a whitespace. + if parser.unread < 1 && !yaml_parser_update_buffer(parser, 1) { + return false + } + if !is_blank(parser.buffer, parser.buffer_pos) { + yaml_parser_set_scanner_error(parser, "while scanning a %TAG directive", + start_mark, "did not find expected whitespace") + return false + } + + // Eat whitespaces. + for is_blank(parser.buffer, parser.buffer_pos) { + skip(parser) + if parser.unread < 1 && !yaml_parser_update_buffer(parser, 1) { + return false + } + } + + // Scan a prefix. + if !yaml_parser_scan_tag_uri(parser, true, nil, start_mark, &prefix_value) { + return false + } + + // Expect a whitespace or line break. + if parser.unread < 1 && !yaml_parser_update_buffer(parser, 1) { + return false + } + if !is_blankz(parser.buffer, parser.buffer_pos) { + yaml_parser_set_scanner_error(parser, "while scanning a %TAG directive", + start_mark, "did not find expected whitespace or line break") + return false + } + + *handle = handle_value + *prefix = prefix_value + return true +} + +func yaml_parser_scan_anchor(parser *yaml_parser_t, token *yaml_token_t, typ yaml_token_type_t) bool { + var s []byte + + // Eat the indicator character. + start_mark := parser.mark + skip(parser) + + // Consume the value. + if parser.unread < 1 && !yaml_parser_update_buffer(parser, 1) { + return false + } + + for is_alpha(parser.buffer, parser.buffer_pos) { + s = read(parser, s) + if parser.unread < 1 && !yaml_parser_update_buffer(parser, 1) { + return false + } + } + + end_mark := parser.mark + + /* + * Check if length of the anchor is greater than 0 and it is followed by + * a whitespace character or one of the indicators: + * + * '?', ':', ',', ']', '}', '%', '@', '`'. + */ + + if len(s) == 0 || + !(is_blankz(parser.buffer, parser.buffer_pos) || parser.buffer[parser.buffer_pos] == '?' || + parser.buffer[parser.buffer_pos] == ':' || parser.buffer[parser.buffer_pos] == ',' || + parser.buffer[parser.buffer_pos] == ']' || parser.buffer[parser.buffer_pos] == '}' || + parser.buffer[parser.buffer_pos] == '%' || parser.buffer[parser.buffer_pos] == '@' || + parser.buffer[parser.buffer_pos] == '`') { + context := "while scanning an alias" + if typ == yaml_ANCHOR_TOKEN { + context = "while scanning an anchor" + } + yaml_parser_set_scanner_error(parser, context, start_mark, + "did not find expected alphabetic or numeric character") + return false + } + + // Create a token. + *token = yaml_token_t{ + typ: typ, + start_mark: start_mark, + end_mark: end_mark, + value: s, + } + + return true +} + +/* + * Scan a TAG token. + */ + +func yaml_parser_scan_tag(parser *yaml_parser_t, token *yaml_token_t) bool { + var handle, suffix []byte + + start_mark := parser.mark + + // Check if the tag is in the canonical form. + if parser.unread < 2 && !yaml_parser_update_buffer(parser, 2) { + return false + } + + if parser.buffer[parser.buffer_pos+1] == '<' { + // Keep the handle as '' + + // Eat '!<' + skip(parser) + skip(parser) + + // Consume the tag value. + if !yaml_parser_scan_tag_uri(parser, false, nil, start_mark, &suffix) { + return false + } + + // Check for '>' and eat it. + if parser.buffer[parser.buffer_pos] != '>' { + yaml_parser_set_scanner_error(parser, "while scanning a tag", + start_mark, "did not find the expected '>'") + return false + } + + skip(parser) + } else { + // The tag has either the '!suffix' or the '!handle!suffix' form. + + // First, try to scan a handle. + if !yaml_parser_scan_tag_handle(parser, false, start_mark, &handle) { + return false + } + + // Check if it is, indeed, handle. + if handle[0] == '!' && len(handle) > 1 && handle[len(handle)-1] == '!' { + // Scan the suffix now. + if !yaml_parser_scan_tag_uri(parser, false, nil, start_mark, &suffix) { + return false + } + } else { + // It wasn't a handle after all. Scan the rest of the tag. + if !yaml_parser_scan_tag_uri(parser, false, handle, start_mark, &suffix) { + return false + } + + // Set the handle to '!'. + handle = []byte{'!'} + + // A special case: the '!' tag. Set the handle to '' and the + // suffix to '!'. + if len(suffix) == 0 { + handle, suffix = suffix, handle + } + } + } + + // Check the character which ends the tag. + if parser.unread < 1 && !yaml_parser_update_buffer(parser, 1) { + return false + } + if !is_blankz(parser.buffer, parser.buffer_pos) { + yaml_parser_set_scanner_error(parser, "while scanning a tag", + start_mark, "did not find expected whitespace or line break") + return false + } + + end_mark := parser.mark + + // Create a token. + *token = yaml_token_t{ + typ: yaml_TAG_TOKEN, + start_mark: start_mark, + end_mark: end_mark, + value: handle, + suffix: suffix, + } + return true +} + +// Scan a tag handle. +func yaml_parser_scan_tag_handle(parser *yaml_parser_t, directive bool, start_mark yaml_mark_t, handle *[]byte) bool { + // Check the initial '!' character. + if parser.unread < 1 && !yaml_parser_update_buffer(parser, 1) { + return false + } + if parser.buffer[parser.buffer_pos] != '!' { + yaml_parser_set_scanner_tag_error(parser, directive, + start_mark, "did not find expected '!'") + return false + } + + var s []byte + + // Copy the '!' character. + s = read(parser, s) + + // Copy all subsequent alphabetical and numerical characters. + if parser.unread < 1 && !yaml_parser_update_buffer(parser, 1) { + return false + } + for is_alpha(parser.buffer, parser.buffer_pos) { + s = read(parser, s) + if parser.unread < 1 && !yaml_parser_update_buffer(parser, 1) { + return false + } + } + + // Check if the trailing character is '!' and copy it. + if parser.buffer[parser.buffer_pos] == '!' { + s = read(parser, s) + } else { + // It's either the '!' tag or not really a tag handle. If it's a %TAG + // directive, it's an error. If it's a tag token, it must be a part of URI. + if directive && string(s) != "!" { + yaml_parser_set_scanner_tag_error(parser, directive, + start_mark, "did not find expected '!'") + return false + } + } + + *handle = s + return true +} + +// Scan a tag. +func yaml_parser_scan_tag_uri(parser *yaml_parser_t, directive bool, head []byte, start_mark yaml_mark_t, uri *[]byte) bool { + //size_t length = head ? strlen((char *)head) : 0 + var s []byte + hasTag := len(head) > 0 + + // Copy the head if needed. + // + // Note that we don't copy the leading '!' character. + if len(head) > 1 { + s = append(s, head[1:]...) + } + + // Scan the tag. + if parser.unread < 1 && !yaml_parser_update_buffer(parser, 1) { + return false + } + + // The set of characters that may appear in URI is as follows: + // + // '0'-'9', 'A'-'Z', 'a'-'z', '_', '-', ';', '/', '?', ':', '@', '&', + // '=', '+', '$', ',', '.', '!', '~', '*', '\'', '(', ')', '[', ']', + // '%'. + // [Go] TODO Convert this into more reasonable logic. + for is_alpha(parser.buffer, parser.buffer_pos) || parser.buffer[parser.buffer_pos] == ';' || + parser.buffer[parser.buffer_pos] == '/' || parser.buffer[parser.buffer_pos] == '?' || + parser.buffer[parser.buffer_pos] == ':' || parser.buffer[parser.buffer_pos] == '@' || + parser.buffer[parser.buffer_pos] == '&' || parser.buffer[parser.buffer_pos] == '=' || + parser.buffer[parser.buffer_pos] == '+' || parser.buffer[parser.buffer_pos] == '$' || + parser.buffer[parser.buffer_pos] == ',' || parser.buffer[parser.buffer_pos] == '.' || + parser.buffer[parser.buffer_pos] == '!' || parser.buffer[parser.buffer_pos] == '~' || + parser.buffer[parser.buffer_pos] == '*' || parser.buffer[parser.buffer_pos] == '\'' || + parser.buffer[parser.buffer_pos] == '(' || parser.buffer[parser.buffer_pos] == ')' || + parser.buffer[parser.buffer_pos] == '[' || parser.buffer[parser.buffer_pos] == ']' || + parser.buffer[parser.buffer_pos] == '%' { + // Check if it is a URI-escape sequence. + if parser.buffer[parser.buffer_pos] == '%' { + if !yaml_parser_scan_uri_escapes(parser, directive, start_mark, &s) { + return false + } + } else { + s = read(parser, s) + } + if parser.unread < 1 && !yaml_parser_update_buffer(parser, 1) { + return false + } + hasTag = true + } + + if !hasTag { + yaml_parser_set_scanner_tag_error(parser, directive, + start_mark, "did not find expected tag URI") + return false + } + *uri = s + return true +} + +// Decode an URI-escape sequence corresponding to a single UTF-8 character. +func yaml_parser_scan_uri_escapes(parser *yaml_parser_t, directive bool, start_mark yaml_mark_t, s *[]byte) bool { + + // Decode the required number of characters. + w := 1024 + for w > 0 { + // Check for a URI-escaped octet. + if parser.unread < 3 && !yaml_parser_update_buffer(parser, 3) { + return false + } + + if !(parser.buffer[parser.buffer_pos] == '%' && + is_hex(parser.buffer, parser.buffer_pos+1) && + is_hex(parser.buffer, parser.buffer_pos+2)) { + return yaml_parser_set_scanner_tag_error(parser, directive, + start_mark, "did not find URI escaped octet") + } + + // Get the octet. + octet := byte((as_hex(parser.buffer, parser.buffer_pos+1) << 4) + as_hex(parser.buffer, parser.buffer_pos+2)) + + // If it is the leading octet, determine the length of the UTF-8 sequence. + if w == 1024 { + w = width(octet) + if w == 0 { + return yaml_parser_set_scanner_tag_error(parser, directive, + start_mark, "found an incorrect leading UTF-8 octet") + } + } else { + // Check if the trailing octet is correct. + if octet&0xC0 != 0x80 { + return yaml_parser_set_scanner_tag_error(parser, directive, + start_mark, "found an incorrect trailing UTF-8 octet") + } + } + + // Copy the octet and move the pointers. + *s = append(*s, octet) + skip(parser) + skip(parser) + skip(parser) + w-- + } + return true +} + +// Scan a block scalar. +func yaml_parser_scan_block_scalar(parser *yaml_parser_t, token *yaml_token_t, literal bool) bool { + // Eat the indicator '|' or '>'. + start_mark := parser.mark + skip(parser) + + // Scan the additional block scalar indicators. + if parser.unread < 1 && !yaml_parser_update_buffer(parser, 1) { + return false + } + + // Check for a chomping indicator. + var chomping, increment int + if parser.buffer[parser.buffer_pos] == '+' || parser.buffer[parser.buffer_pos] == '-' { + // Set the chomping method and eat the indicator. + if parser.buffer[parser.buffer_pos] == '+' { + chomping = +1 + } else { + chomping = -1 + } + skip(parser) + + // Check for an indentation indicator. + if parser.unread < 1 && !yaml_parser_update_buffer(parser, 1) { + return false + } + if is_digit(parser.buffer, parser.buffer_pos) { + // Check that the indentation is greater than 0. + if parser.buffer[parser.buffer_pos] == '0' { + yaml_parser_set_scanner_error(parser, "while scanning a block scalar", + start_mark, "found an indentation indicator equal to 0") + return false + } + + // Get the indentation level and eat the indicator. + increment = as_digit(parser.buffer, parser.buffer_pos) + skip(parser) + } + + } else if is_digit(parser.buffer, parser.buffer_pos) { + // Do the same as above, but in the opposite order. + + if parser.buffer[parser.buffer_pos] == '0' { + yaml_parser_set_scanner_error(parser, "while scanning a block scalar", + start_mark, "found an indentation indicator equal to 0") + return false + } + increment = as_digit(parser.buffer, parser.buffer_pos) + skip(parser) + + if parser.unread < 1 && !yaml_parser_update_buffer(parser, 1) { + return false + } + if parser.buffer[parser.buffer_pos] == '+' || parser.buffer[parser.buffer_pos] == '-' { + if parser.buffer[parser.buffer_pos] == '+' { + chomping = +1 + } else { + chomping = -1 + } + skip(parser) + } + } + + // Eat whitespaces and comments to the end of the line. + if parser.unread < 1 && !yaml_parser_update_buffer(parser, 1) { + return false + } + for is_blank(parser.buffer, parser.buffer_pos) { + skip(parser) + if parser.unread < 1 && !yaml_parser_update_buffer(parser, 1) { + return false + } + } + if parser.buffer[parser.buffer_pos] == '#' { + if !yaml_parser_scan_line_comment(parser, start_mark) { + return false + } + for !is_breakz(parser.buffer, parser.buffer_pos) { + skip(parser) + if parser.unread < 1 && !yaml_parser_update_buffer(parser, 1) { + return false + } + } + } + + // Check if we are at the end of the line. + if !is_breakz(parser.buffer, parser.buffer_pos) { + yaml_parser_set_scanner_error(parser, "while scanning a block scalar", + start_mark, "did not find expected comment or line break") + return false + } + + // Eat a line break. + if is_break(parser.buffer, parser.buffer_pos) { + if parser.unread < 2 && !yaml_parser_update_buffer(parser, 2) { + return false + } + skip_line(parser) + } + + end_mark := parser.mark + + // Set the indentation level if it was specified. + var indent int + if increment > 0 { + if parser.indent >= 0 { + indent = parser.indent + increment + } else { + indent = increment + } + } + + // Scan the leading line breaks and determine the indentation level if needed. + var s, leading_break, trailing_breaks []byte + if !yaml_parser_scan_block_scalar_breaks(parser, &indent, &trailing_breaks, start_mark, &end_mark) { + return false + } + + // Scan the block scalar content. + if parser.unread < 1 && !yaml_parser_update_buffer(parser, 1) { + return false + } + var leading_blank, trailing_blank bool + for parser.mark.column == indent && !is_z(parser.buffer, parser.buffer_pos) { + // We are at the beginning of a non-empty line. + + // Is it a trailing whitespace? + trailing_blank = is_blank(parser.buffer, parser.buffer_pos) + + // Check if we need to fold the leading line break. + if !literal && !leading_blank && !trailing_blank && len(leading_break) > 0 && leading_break[0] == '\n' { + // Do we need to join the lines by space? + if len(trailing_breaks) == 0 { + s = append(s, ' ') + } + } else { + s = append(s, leading_break...) + } + leading_break = leading_break[:0] + + // Append the remaining line breaks. + s = append(s, trailing_breaks...) + trailing_breaks = trailing_breaks[:0] + + // Is it a leading whitespace? + leading_blank = is_blank(parser.buffer, parser.buffer_pos) + + // Consume the current line. + for !is_breakz(parser.buffer, parser.buffer_pos) { + s = read(parser, s) + if parser.unread < 1 && !yaml_parser_update_buffer(parser, 1) { + return false + } + } + + // Consume the line break. + if parser.unread < 2 && !yaml_parser_update_buffer(parser, 2) { + return false + } + + leading_break = read_line(parser, leading_break) + + // Eat the following indentation spaces and line breaks. + if !yaml_parser_scan_block_scalar_breaks(parser, &indent, &trailing_breaks, start_mark, &end_mark) { + return false + } + } + + // Chomp the tail. + if chomping != -1 { + s = append(s, leading_break...) + } + if chomping == 1 { + s = append(s, trailing_breaks...) + } + + // Create a token. + *token = yaml_token_t{ + typ: yaml_SCALAR_TOKEN, + start_mark: start_mark, + end_mark: end_mark, + value: s, + style: yaml_LITERAL_SCALAR_STYLE, + } + if !literal { + token.style = yaml_FOLDED_SCALAR_STYLE + } + return true +} + +// Scan indentation spaces and line breaks for a block scalar. Determine the +// indentation level if needed. +func yaml_parser_scan_block_scalar_breaks(parser *yaml_parser_t, indent *int, breaks *[]byte, start_mark yaml_mark_t, end_mark *yaml_mark_t) bool { + *end_mark = parser.mark + + // Eat the indentation spaces and line breaks. + max_indent := 0 + for { + // Eat the indentation spaces. + if parser.unread < 1 && !yaml_parser_update_buffer(parser, 1) { + return false + } + for (*indent == 0 || parser.mark.column < *indent) && is_space(parser.buffer, parser.buffer_pos) { + skip(parser) + if parser.unread < 1 && !yaml_parser_update_buffer(parser, 1) { + return false + } + } + if parser.mark.column > max_indent { + max_indent = parser.mark.column + } + + // Check for a tab character messing the indentation. + if (*indent == 0 || parser.mark.column < *indent) && is_tab(parser.buffer, parser.buffer_pos) { + return yaml_parser_set_scanner_error(parser, "while scanning a block scalar", + start_mark, "found a tab character where an indentation space is expected") + } + + // Have we found a non-empty line? + if !is_break(parser.buffer, parser.buffer_pos) { + break + } + + // Consume the line break. + if parser.unread < 2 && !yaml_parser_update_buffer(parser, 2) { + return false + } + // [Go] Should really be returning breaks instead. + *breaks = read_line(parser, *breaks) + *end_mark = parser.mark + } + + // Determine the indentation level if needed. + if *indent == 0 { + *indent = max_indent + if *indent < parser.indent+1 { + *indent = parser.indent + 1 + } + if *indent < 1 { + *indent = 1 + } + } + return true +} + +// Scan a quoted scalar. +func yaml_parser_scan_flow_scalar(parser *yaml_parser_t, token *yaml_token_t, single bool) bool { + // Eat the left quote. + start_mark := parser.mark + skip(parser) + + // Consume the content of the quoted scalar. + var s, leading_break, trailing_breaks, whitespaces []byte + for { + // Check that there are no document indicators at the beginning of the line. + if parser.unread < 4 && !yaml_parser_update_buffer(parser, 4) { + return false + } + + if parser.mark.column == 0 && + ((parser.buffer[parser.buffer_pos+0] == '-' && + parser.buffer[parser.buffer_pos+1] == '-' && + parser.buffer[parser.buffer_pos+2] == '-') || + (parser.buffer[parser.buffer_pos+0] == '.' && + parser.buffer[parser.buffer_pos+1] == '.' && + parser.buffer[parser.buffer_pos+2] == '.')) && + is_blankz(parser.buffer, parser.buffer_pos+3) { + yaml_parser_set_scanner_error(parser, "while scanning a quoted scalar", + start_mark, "found unexpected document indicator") + return false + } + + // Check for EOF. + if is_z(parser.buffer, parser.buffer_pos) { + yaml_parser_set_scanner_error(parser, "while scanning a quoted scalar", + start_mark, "found unexpected end of stream") + return false + } + + // Consume non-blank characters. + leading_blanks := false + for !is_blankz(parser.buffer, parser.buffer_pos) { + if single && parser.buffer[parser.buffer_pos] == '\'' && parser.buffer[parser.buffer_pos+1] == '\'' { + // Is is an escaped single quote. + s = append(s, '\'') + skip(parser) + skip(parser) + + } else if single && parser.buffer[parser.buffer_pos] == '\'' { + // It is a right single quote. + break + } else if !single && parser.buffer[parser.buffer_pos] == '"' { + // It is a right double quote. + break + + } else if !single && parser.buffer[parser.buffer_pos] == '\\' && is_break(parser.buffer, parser.buffer_pos+1) { + // It is an escaped line break. + if parser.unread < 3 && !yaml_parser_update_buffer(parser, 3) { + return false + } + skip(parser) + skip_line(parser) + leading_blanks = true + break + + } else if !single && parser.buffer[parser.buffer_pos] == '\\' { + // It is an escape sequence. + code_length := 0 + + // Check the escape character. + switch parser.buffer[parser.buffer_pos+1] { + case '0': + s = append(s, 0) + case 'a': + s = append(s, '\x07') + case 'b': + s = append(s, '\x08') + case 't', '\t': + s = append(s, '\x09') + case 'n': + s = append(s, '\x0A') + case 'v': + s = append(s, '\x0B') + case 'f': + s = append(s, '\x0C') + case 'r': + s = append(s, '\x0D') + case 'e': + s = append(s, '\x1B') + case ' ': + s = append(s, '\x20') + case '"': + s = append(s, '"') + case '\'': + s = append(s, '\'') + case '\\': + s = append(s, '\\') + case 'N': // NEL (#x85) + s = append(s, '\xC2') + s = append(s, '\x85') + case '_': // #xA0 + s = append(s, '\xC2') + s = append(s, '\xA0') + case 'L': // LS (#x2028) + s = append(s, '\xE2') + s = append(s, '\x80') + s = append(s, '\xA8') + case 'P': // PS (#x2029) + s = append(s, '\xE2') + s = append(s, '\x80') + s = append(s, '\xA9') + case 'x': + code_length = 2 + case 'u': + code_length = 4 + case 'U': + code_length = 8 + default: + yaml_parser_set_scanner_error(parser, "while parsing a quoted scalar", + start_mark, "found unknown escape character") + return false + } + + skip(parser) + skip(parser) + + // Consume an arbitrary escape code. + if code_length > 0 { + var value int + + // Scan the character value. + if parser.unread < code_length && !yaml_parser_update_buffer(parser, code_length) { + return false + } + for k := 0; k < code_length; k++ { + if !is_hex(parser.buffer, parser.buffer_pos+k) { + yaml_parser_set_scanner_error(parser, "while parsing a quoted scalar", + start_mark, "did not find expected hexdecimal number") + return false + } + value = (value << 4) + as_hex(parser.buffer, parser.buffer_pos+k) + } + + // Check the value and write the character. + if (value >= 0xD800 && value <= 0xDFFF) || value > 0x10FFFF { + yaml_parser_set_scanner_error(parser, "while parsing a quoted scalar", + start_mark, "found invalid Unicode character escape code") + return false + } + if value <= 0x7F { + s = append(s, byte(value)) + } else if value <= 0x7FF { + s = append(s, byte(0xC0+(value>>6))) + s = append(s, byte(0x80+(value&0x3F))) + } else if value <= 0xFFFF { + s = append(s, byte(0xE0+(value>>12))) + s = append(s, byte(0x80+((value>>6)&0x3F))) + s = append(s, byte(0x80+(value&0x3F))) + } else { + s = append(s, byte(0xF0+(value>>18))) + s = append(s, byte(0x80+((value>>12)&0x3F))) + s = append(s, byte(0x80+((value>>6)&0x3F))) + s = append(s, byte(0x80+(value&0x3F))) + } + + // Advance the pointer. + for k := 0; k < code_length; k++ { + skip(parser) + } + } + } else { + // It is a non-escaped non-blank character. + s = read(parser, s) + } + if parser.unread < 2 && !yaml_parser_update_buffer(parser, 2) { + return false + } + } + + if parser.unread < 1 && !yaml_parser_update_buffer(parser, 1) { + return false + } + + // Check if we are at the end of the scalar. + if single { + if parser.buffer[parser.buffer_pos] == '\'' { + break + } + } else { + if parser.buffer[parser.buffer_pos] == '"' { + break + } + } + + // Consume blank characters. + for is_blank(parser.buffer, parser.buffer_pos) || is_break(parser.buffer, parser.buffer_pos) { + if is_blank(parser.buffer, parser.buffer_pos) { + // Consume a space or a tab character. + if !leading_blanks { + whitespaces = read(parser, whitespaces) + } else { + skip(parser) + } + } else { + if parser.unread < 2 && !yaml_parser_update_buffer(parser, 2) { + return false + } + + // Check if it is a first line break. + if !leading_blanks { + whitespaces = whitespaces[:0] + leading_break = read_line(parser, leading_break) + leading_blanks = true + } else { + trailing_breaks = read_line(parser, trailing_breaks) + } + } + if parser.unread < 1 && !yaml_parser_update_buffer(parser, 1) { + return false + } + } + + // Join the whitespaces or fold line breaks. + if leading_blanks { + // Do we need to fold line breaks? + if len(leading_break) > 0 && leading_break[0] == '\n' { + if len(trailing_breaks) == 0 { + s = append(s, ' ') + } else { + s = append(s, trailing_breaks...) + } + } else { + s = append(s, leading_break...) + s = append(s, trailing_breaks...) + } + trailing_breaks = trailing_breaks[:0] + leading_break = leading_break[:0] + } else { + s = append(s, whitespaces...) + whitespaces = whitespaces[:0] + } + } + + // Eat the right quote. + skip(parser) + end_mark := parser.mark + + // Create a token. + *token = yaml_token_t{ + typ: yaml_SCALAR_TOKEN, + start_mark: start_mark, + end_mark: end_mark, + value: s, + style: yaml_SINGLE_QUOTED_SCALAR_STYLE, + } + if !single { + token.style = yaml_DOUBLE_QUOTED_SCALAR_STYLE + } + return true +} + +// Scan a plain scalar. +func yaml_parser_scan_plain_scalar(parser *yaml_parser_t, token *yaml_token_t) bool { + + var s, leading_break, trailing_breaks, whitespaces []byte + var leading_blanks bool + var indent = parser.indent + 1 + + start_mark := parser.mark + end_mark := parser.mark + + // Consume the content of the plain scalar. + for { + // Check for a document indicator. + if parser.unread < 4 && !yaml_parser_update_buffer(parser, 4) { + return false + } + if parser.mark.column == 0 && + ((parser.buffer[parser.buffer_pos+0] == '-' && + parser.buffer[parser.buffer_pos+1] == '-' && + parser.buffer[parser.buffer_pos+2] == '-') || + (parser.buffer[parser.buffer_pos+0] == '.' && + parser.buffer[parser.buffer_pos+1] == '.' && + parser.buffer[parser.buffer_pos+2] == '.')) && + is_blankz(parser.buffer, parser.buffer_pos+3) { + break + } + + // Check for a comment. + if parser.buffer[parser.buffer_pos] == '#' { + break + } + + // Consume non-blank characters. + for !is_blankz(parser.buffer, parser.buffer_pos) { + + // Check for indicators that may end a plain scalar. + if (parser.buffer[parser.buffer_pos] == ':' && is_blankz(parser.buffer, parser.buffer_pos+1)) || + (parser.flow_level > 0 && + (parser.buffer[parser.buffer_pos] == ',' || + parser.buffer[parser.buffer_pos] == '?' || parser.buffer[parser.buffer_pos] == '[' || + parser.buffer[parser.buffer_pos] == ']' || parser.buffer[parser.buffer_pos] == '{' || + parser.buffer[parser.buffer_pos] == '}')) { + break + } + + // Check if we need to join whitespaces and breaks. + if leading_blanks || len(whitespaces) > 0 { + if leading_blanks { + // Do we need to fold line breaks? + if leading_break[0] == '\n' { + if len(trailing_breaks) == 0 { + s = append(s, ' ') + } else { + s = append(s, trailing_breaks...) + } + } else { + s = append(s, leading_break...) + s = append(s, trailing_breaks...) + } + trailing_breaks = trailing_breaks[:0] + leading_break = leading_break[:0] + leading_blanks = false + } else { + s = append(s, whitespaces...) + whitespaces = whitespaces[:0] + } + } + + // Copy the character. + s = read(parser, s) + + end_mark = parser.mark + if parser.unread < 2 && !yaml_parser_update_buffer(parser, 2) { + return false + } + } + + // Is it the end? + if !(is_blank(parser.buffer, parser.buffer_pos) || is_break(parser.buffer, parser.buffer_pos)) { + break + } + + // Consume blank characters. + if parser.unread < 1 && !yaml_parser_update_buffer(parser, 1) { + return false + } + + for is_blank(parser.buffer, parser.buffer_pos) || is_break(parser.buffer, parser.buffer_pos) { + if is_blank(parser.buffer, parser.buffer_pos) { + + // Check for tab characters that abuse indentation. + if leading_blanks && parser.mark.column < indent && is_tab(parser.buffer, parser.buffer_pos) { + yaml_parser_set_scanner_error(parser, "while scanning a plain scalar", + start_mark, "found a tab character that violates indentation") + return false + } + + // Consume a space or a tab character. + if !leading_blanks { + whitespaces = read(parser, whitespaces) + } else { + skip(parser) + } + } else { + if parser.unread < 2 && !yaml_parser_update_buffer(parser, 2) { + return false + } + + // Check if it is a first line break. + if !leading_blanks { + whitespaces = whitespaces[:0] + leading_break = read_line(parser, leading_break) + leading_blanks = true + } else { + trailing_breaks = read_line(parser, trailing_breaks) + } + } + if parser.unread < 1 && !yaml_parser_update_buffer(parser, 1) { + return false + } + } + + // Check indentation level. + if parser.flow_level == 0 && parser.mark.column < indent { + break + } + } + + // Create a token. + *token = yaml_token_t{ + typ: yaml_SCALAR_TOKEN, + start_mark: start_mark, + end_mark: end_mark, + value: s, + style: yaml_PLAIN_SCALAR_STYLE, + } + + // Note that we change the 'simple_key_allowed' flag. + if leading_blanks { + parser.simple_key_allowed = true + } + return true +} + +func yaml_parser_scan_line_comment(parser *yaml_parser_t, token_mark yaml_mark_t) bool { + if parser.newlines > 0 { + return true + } + + var start_mark yaml_mark_t + var text []byte + + for peek := 0; peek < 512; peek++ { + if parser.unread < peek+1 && !yaml_parser_update_buffer(parser, peek+1) { + break + } + if is_blank(parser.buffer, parser.buffer_pos+peek) { + continue + } + if parser.buffer[parser.buffer_pos+peek] == '#' { + seen := parser.mark.index+peek + for { + if parser.unread < 1 && !yaml_parser_update_buffer(parser, 1) { + return false + } + if is_breakz(parser.buffer, parser.buffer_pos) { + if parser.mark.index >= seen { + break + } + if parser.unread < 2 && !yaml_parser_update_buffer(parser, 2) { + return false + } + skip_line(parser) + } else if parser.mark.index >= seen { + if len(text) == 0 { + start_mark = parser.mark + } + text = read(parser, text) + } else { + skip(parser) + } + } + } + break + } + if len(text) > 0 { + parser.comments = append(parser.comments, yaml_comment_t{ + token_mark: token_mark, + start_mark: start_mark, + line: text, + }) + } + return true +} + +func yaml_parser_scan_comments(parser *yaml_parser_t, scan_mark yaml_mark_t) bool { + token := parser.tokens[len(parser.tokens)-1] + + if token.typ == yaml_FLOW_ENTRY_TOKEN && len(parser.tokens) > 1 { + token = parser.tokens[len(parser.tokens)-2] + } + + var token_mark = token.start_mark + var start_mark yaml_mark_t + var next_indent = parser.indent + if next_indent < 0 { + next_indent = 0 + } + + var recent_empty = false + var first_empty = parser.newlines <= 1 + + var line = parser.mark.line + var column = parser.mark.column + + var text []byte + + // The foot line is the place where a comment must start to + // still be considered as a foot of the prior content. + // If there's some content in the currently parsed line, then + // the foot is the line below it. + var foot_line = -1 + if scan_mark.line > 0 { + foot_line = parser.mark.line-parser.newlines+1 + if parser.newlines == 0 && parser.mark.column > 1 { + foot_line++ + } + } + + var peek = 0 + for ; peek < 512; peek++ { + if parser.unread < peek+1 && !yaml_parser_update_buffer(parser, peek+1) { + break + } + column++ + if is_blank(parser.buffer, parser.buffer_pos+peek) { + continue + } + c := parser.buffer[parser.buffer_pos+peek] + var close_flow = parser.flow_level > 0 && (c == ']' || c == '}') + if close_flow || is_breakz(parser.buffer, parser.buffer_pos+peek) { + // Got line break or terminator. + if close_flow || !recent_empty { + if close_flow || first_empty && (start_mark.line == foot_line && token.typ != yaml_VALUE_TOKEN || start_mark.column-1 < next_indent) { + // This is the first empty line and there were no empty lines before, + // so this initial part of the comment is a foot of the prior token + // instead of being a head for the following one. Split it up. + // Alternatively, this might also be the last comment inside a flow + // scope, so it must be a footer. + if len(text) > 0 { + if start_mark.column-1 < next_indent { + // If dedented it's unrelated to the prior token. + token_mark = start_mark + } + parser.comments = append(parser.comments, yaml_comment_t{ + scan_mark: scan_mark, + token_mark: token_mark, + start_mark: start_mark, + end_mark: yaml_mark_t{parser.mark.index + peek, line, column}, + foot: text, + }) + scan_mark = yaml_mark_t{parser.mark.index + peek, line, column} + token_mark = scan_mark + text = nil + } + } else { + if len(text) > 0 && parser.buffer[parser.buffer_pos+peek] != 0 { + text = append(text, '\n') + } + } + } + if !is_break(parser.buffer, parser.buffer_pos+peek) { + break + } + first_empty = false + recent_empty = true + column = 0 + line++ + continue + } + + if len(text) > 0 && (close_flow || column-1 < next_indent && column != start_mark.column) { + // The comment at the different indentation is a foot of the + // preceding data rather than a head of the upcoming one. + parser.comments = append(parser.comments, yaml_comment_t{ + scan_mark: scan_mark, + token_mark: token_mark, + start_mark: start_mark, + end_mark: yaml_mark_t{parser.mark.index + peek, line, column}, + foot: text, + }) + scan_mark = yaml_mark_t{parser.mark.index + peek, line, column} + token_mark = scan_mark + text = nil + } + + if parser.buffer[parser.buffer_pos+peek] != '#' { + break + } + + if len(text) == 0 { + start_mark = yaml_mark_t{parser.mark.index + peek, line, column} + } else { + text = append(text, '\n') + } + + recent_empty = false + + // Consume until after the consumed comment line. + seen := parser.mark.index+peek + for { + if parser.unread < 1 && !yaml_parser_update_buffer(parser, 1) { + return false + } + if is_breakz(parser.buffer, parser.buffer_pos) { + if parser.mark.index >= seen { + break + } + if parser.unread < 2 && !yaml_parser_update_buffer(parser, 2) { + return false + } + skip_line(parser) + } else if parser.mark.index >= seen { + text = read(parser, text) + } else { + skip(parser) + } + } + + peek = 0 + column = 0 + line = parser.mark.line + next_indent = parser.indent + if next_indent < 0 { + next_indent = 0 + } + } + + if len(text) > 0 { + parser.comments = append(parser.comments, yaml_comment_t{ + scan_mark: scan_mark, + token_mark: start_mark, + start_mark: start_mark, + end_mark: yaml_mark_t{parser.mark.index + peek - 1, line, column}, + head: text, + }) + } + return true +} diff --git a/mcp/dsx-exchange-mcp/vendor/gopkg.in/yaml.v3/sorter.go b/mcp/dsx-exchange-mcp/vendor/gopkg.in/yaml.v3/sorter.go new file mode 100644 index 0000000..9210ece --- /dev/null +++ b/mcp/dsx-exchange-mcp/vendor/gopkg.in/yaml.v3/sorter.go @@ -0,0 +1,134 @@ +// +// Copyright (c) 2011-2019 Canonical Ltd +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package yaml + +import ( + "reflect" + "unicode" +) + +type keyList []reflect.Value + +func (l keyList) Len() int { return len(l) } +func (l keyList) Swap(i, j int) { l[i], l[j] = l[j], l[i] } +func (l keyList) Less(i, j int) bool { + a := l[i] + b := l[j] + ak := a.Kind() + bk := b.Kind() + for (ak == reflect.Interface || ak == reflect.Ptr) && !a.IsNil() { + a = a.Elem() + ak = a.Kind() + } + for (bk == reflect.Interface || bk == reflect.Ptr) && !b.IsNil() { + b = b.Elem() + bk = b.Kind() + } + af, aok := keyFloat(a) + bf, bok := keyFloat(b) + if aok && bok { + if af != bf { + return af < bf + } + if ak != bk { + return ak < bk + } + return numLess(a, b) + } + if ak != reflect.String || bk != reflect.String { + return ak < bk + } + ar, br := []rune(a.String()), []rune(b.String()) + digits := false + for i := 0; i < len(ar) && i < len(br); i++ { + if ar[i] == br[i] { + digits = unicode.IsDigit(ar[i]) + continue + } + al := unicode.IsLetter(ar[i]) + bl := unicode.IsLetter(br[i]) + if al && bl { + return ar[i] < br[i] + } + if al || bl { + if digits { + return al + } else { + return bl + } + } + var ai, bi int + var an, bn int64 + if ar[i] == '0' || br[i] == '0' { + for j := i - 1; j >= 0 && unicode.IsDigit(ar[j]); j-- { + if ar[j] != '0' { + an = 1 + bn = 1 + break + } + } + } + for ai = i; ai < len(ar) && unicode.IsDigit(ar[ai]); ai++ { + an = an*10 + int64(ar[ai]-'0') + } + for bi = i; bi < len(br) && unicode.IsDigit(br[bi]); bi++ { + bn = bn*10 + int64(br[bi]-'0') + } + if an != bn { + return an < bn + } + if ai != bi { + return ai < bi + } + return ar[i] < br[i] + } + return len(ar) < len(br) +} + +// keyFloat returns a float value for v if it is a number/bool +// and whether it is a number/bool or not. +func keyFloat(v reflect.Value) (f float64, ok bool) { + switch v.Kind() { + case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64: + return float64(v.Int()), true + case reflect.Float32, reflect.Float64: + return v.Float(), true + case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64, reflect.Uintptr: + return float64(v.Uint()), true + case reflect.Bool: + if v.Bool() { + return 1, true + } + return 0, true + } + return 0, false +} + +// numLess returns whether a < b. +// a and b must necessarily have the same kind. +func numLess(a, b reflect.Value) bool { + switch a.Kind() { + case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64: + return a.Int() < b.Int() + case reflect.Float32, reflect.Float64: + return a.Float() < b.Float() + case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64, reflect.Uintptr: + return a.Uint() < b.Uint() + case reflect.Bool: + return !a.Bool() && b.Bool() + } + panic("not a number") +} diff --git a/mcp/dsx-exchange-mcp/vendor/gopkg.in/yaml.v3/writerc.go b/mcp/dsx-exchange-mcp/vendor/gopkg.in/yaml.v3/writerc.go new file mode 100644 index 0000000..b8a116b --- /dev/null +++ b/mcp/dsx-exchange-mcp/vendor/gopkg.in/yaml.v3/writerc.go @@ -0,0 +1,48 @@ +// +// Copyright (c) 2011-2019 Canonical Ltd +// Copyright (c) 2006-2010 Kirill Simonov +// +// Permission is hereby granted, free of charge, to any person obtaining a copy of +// this software and associated documentation files (the "Software"), to deal in +// the Software without restriction, including without limitation the rights to +// use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies +// of the Software, and to permit persons to whom the Software is furnished to do +// so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in all +// copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. + +package yaml + +// Set the writer error and return false. +func yaml_emitter_set_writer_error(emitter *yaml_emitter_t, problem string) bool { + emitter.error = yaml_WRITER_ERROR + emitter.problem = problem + return false +} + +// Flush the output buffer. +func yaml_emitter_flush(emitter *yaml_emitter_t) bool { + if emitter.write_handler == nil { + panic("write handler not set") + } + + // Check if the buffer is empty. + if emitter.buffer_pos == 0 { + return true + } + + if err := emitter.write_handler(emitter, emitter.buffer[:emitter.buffer_pos]); err != nil { + return yaml_emitter_set_writer_error(emitter, "write error: "+err.Error()) + } + emitter.buffer_pos = 0 + return true +} diff --git a/mcp/dsx-exchange-mcp/vendor/gopkg.in/yaml.v3/yaml.go b/mcp/dsx-exchange-mcp/vendor/gopkg.in/yaml.v3/yaml.go new file mode 100644 index 0000000..8cec6da --- /dev/null +++ b/mcp/dsx-exchange-mcp/vendor/gopkg.in/yaml.v3/yaml.go @@ -0,0 +1,698 @@ +// +// Copyright (c) 2011-2019 Canonical Ltd +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +// Package yaml implements YAML support for the Go language. +// +// Source code and other details for the project are available at GitHub: +// +// https://github.com/go-yaml/yaml +// +package yaml + +import ( + "errors" + "fmt" + "io" + "reflect" + "strings" + "sync" + "unicode/utf8" +) + +// The Unmarshaler interface may be implemented by types to customize their +// behavior when being unmarshaled from a YAML document. +type Unmarshaler interface { + UnmarshalYAML(value *Node) error +} + +type obsoleteUnmarshaler interface { + UnmarshalYAML(unmarshal func(interface{}) error) error +} + +// The Marshaler interface may be implemented by types to customize their +// behavior when being marshaled into a YAML document. The returned value +// is marshaled in place of the original value implementing Marshaler. +// +// If an error is returned by MarshalYAML, the marshaling procedure stops +// and returns with the provided error. +type Marshaler interface { + MarshalYAML() (interface{}, error) +} + +// Unmarshal decodes the first document found within the in byte slice +// and assigns decoded values into the out value. +// +// Maps and pointers (to a struct, string, int, etc) are accepted as out +// values. If an internal pointer within a struct is not initialized, +// the yaml package will initialize it if necessary for unmarshalling +// the provided data. The out parameter must not be nil. +// +// The type of the decoded values should be compatible with the respective +// values in out. If one or more values cannot be decoded due to a type +// mismatches, decoding continues partially until the end of the YAML +// content, and a *yaml.TypeError is returned with details for all +// missed values. +// +// Struct fields are only unmarshalled if they are exported (have an +// upper case first letter), and are unmarshalled using the field name +// lowercased as the default key. Custom keys may be defined via the +// "yaml" name in the field tag: the content preceding the first comma +// is used as the key, and the following comma-separated options are +// used to tweak the marshalling process (see Marshal). +// Conflicting names result in a runtime error. +// +// For example: +// +// type T struct { +// F int `yaml:"a,omitempty"` +// B int +// } +// var t T +// yaml.Unmarshal([]byte("a: 1\nb: 2"), &t) +// +// See the documentation of Marshal for the format of tags and a list of +// supported tag options. +// +func Unmarshal(in []byte, out interface{}) (err error) { + return unmarshal(in, out, false) +} + +// A Decoder reads and decodes YAML values from an input stream. +type Decoder struct { + parser *parser + knownFields bool +} + +// NewDecoder returns a new decoder that reads from r. +// +// The decoder introduces its own buffering and may read +// data from r beyond the YAML values requested. +func NewDecoder(r io.Reader) *Decoder { + return &Decoder{ + parser: newParserFromReader(r), + } +} + +// KnownFields ensures that the keys in decoded mappings to +// exist as fields in the struct being decoded into. +func (dec *Decoder) KnownFields(enable bool) { + dec.knownFields = enable +} + +// Decode reads the next YAML-encoded value from its input +// and stores it in the value pointed to by v. +// +// See the documentation for Unmarshal for details about the +// conversion of YAML into a Go value. +func (dec *Decoder) Decode(v interface{}) (err error) { + d := newDecoder() + d.knownFields = dec.knownFields + defer handleErr(&err) + node := dec.parser.parse() + if node == nil { + return io.EOF + } + out := reflect.ValueOf(v) + if out.Kind() == reflect.Ptr && !out.IsNil() { + out = out.Elem() + } + d.unmarshal(node, out) + if len(d.terrors) > 0 { + return &TypeError{d.terrors} + } + return nil +} + +// Decode decodes the node and stores its data into the value pointed to by v. +// +// See the documentation for Unmarshal for details about the +// conversion of YAML into a Go value. +func (n *Node) Decode(v interface{}) (err error) { + d := newDecoder() + defer handleErr(&err) + out := reflect.ValueOf(v) + if out.Kind() == reflect.Ptr && !out.IsNil() { + out = out.Elem() + } + d.unmarshal(n, out) + if len(d.terrors) > 0 { + return &TypeError{d.terrors} + } + return nil +} + +func unmarshal(in []byte, out interface{}, strict bool) (err error) { + defer handleErr(&err) + d := newDecoder() + p := newParser(in) + defer p.destroy() + node := p.parse() + if node != nil { + v := reflect.ValueOf(out) + if v.Kind() == reflect.Ptr && !v.IsNil() { + v = v.Elem() + } + d.unmarshal(node, v) + } + if len(d.terrors) > 0 { + return &TypeError{d.terrors} + } + return nil +} + +// Marshal serializes the value provided into a YAML document. The structure +// of the generated document will reflect the structure of the value itself. +// Maps and pointers (to struct, string, int, etc) are accepted as the in value. +// +// Struct fields are only marshalled if they are exported (have an upper case +// first letter), and are marshalled using the field name lowercased as the +// default key. Custom keys may be defined via the "yaml" name in the field +// tag: the content preceding the first comma is used as the key, and the +// following comma-separated options are used to tweak the marshalling process. +// Conflicting names result in a runtime error. +// +// The field tag format accepted is: +// +// `(...) yaml:"[][,[,]]" (...)` +// +// The following flags are currently supported: +// +// omitempty Only include the field if it's not set to the zero +// value for the type or to empty slices or maps. +// Zero valued structs will be omitted if all their public +// fields are zero, unless they implement an IsZero +// method (see the IsZeroer interface type), in which +// case the field will be excluded if IsZero returns true. +// +// flow Marshal using a flow style (useful for structs, +// sequences and maps). +// +// inline Inline the field, which must be a struct or a map, +// causing all of its fields or keys to be processed as if +// they were part of the outer struct. For maps, keys must +// not conflict with the yaml keys of other struct fields. +// +// In addition, if the key is "-", the field is ignored. +// +// For example: +// +// type T struct { +// F int `yaml:"a,omitempty"` +// B int +// } +// yaml.Marshal(&T{B: 2}) // Returns "b: 2\n" +// yaml.Marshal(&T{F: 1}} // Returns "a: 1\nb: 0\n" +// +func Marshal(in interface{}) (out []byte, err error) { + defer handleErr(&err) + e := newEncoder() + defer e.destroy() + e.marshalDoc("", reflect.ValueOf(in)) + e.finish() + out = e.out + return +} + +// An Encoder writes YAML values to an output stream. +type Encoder struct { + encoder *encoder +} + +// NewEncoder returns a new encoder that writes to w. +// The Encoder should be closed after use to flush all data +// to w. +func NewEncoder(w io.Writer) *Encoder { + return &Encoder{ + encoder: newEncoderWithWriter(w), + } +} + +// Encode writes the YAML encoding of v to the stream. +// If multiple items are encoded to the stream, the +// second and subsequent document will be preceded +// with a "---" document separator, but the first will not. +// +// See the documentation for Marshal for details about the conversion of Go +// values to YAML. +func (e *Encoder) Encode(v interface{}) (err error) { + defer handleErr(&err) + e.encoder.marshalDoc("", reflect.ValueOf(v)) + return nil +} + +// Encode encodes value v and stores its representation in n. +// +// See the documentation for Marshal for details about the +// conversion of Go values into YAML. +func (n *Node) Encode(v interface{}) (err error) { + defer handleErr(&err) + e := newEncoder() + defer e.destroy() + e.marshalDoc("", reflect.ValueOf(v)) + e.finish() + p := newParser(e.out) + p.textless = true + defer p.destroy() + doc := p.parse() + *n = *doc.Content[0] + return nil +} + +// SetIndent changes the used indentation used when encoding. +func (e *Encoder) SetIndent(spaces int) { + if spaces < 0 { + panic("yaml: cannot indent to a negative number of spaces") + } + e.encoder.indent = spaces +} + +// Close closes the encoder by writing any remaining data. +// It does not write a stream terminating string "...". +func (e *Encoder) Close() (err error) { + defer handleErr(&err) + e.encoder.finish() + return nil +} + +func handleErr(err *error) { + if v := recover(); v != nil { + if e, ok := v.(yamlError); ok { + *err = e.err + } else { + panic(v) + } + } +} + +type yamlError struct { + err error +} + +func fail(err error) { + panic(yamlError{err}) +} + +func failf(format string, args ...interface{}) { + panic(yamlError{fmt.Errorf("yaml: "+format, args...)}) +} + +// A TypeError is returned by Unmarshal when one or more fields in +// the YAML document cannot be properly decoded into the requested +// types. When this error is returned, the value is still +// unmarshaled partially. +type TypeError struct { + Errors []string +} + +func (e *TypeError) Error() string { + return fmt.Sprintf("yaml: unmarshal errors:\n %s", strings.Join(e.Errors, "\n ")) +} + +type Kind uint32 + +const ( + DocumentNode Kind = 1 << iota + SequenceNode + MappingNode + ScalarNode + AliasNode +) + +type Style uint32 + +const ( + TaggedStyle Style = 1 << iota + DoubleQuotedStyle + SingleQuotedStyle + LiteralStyle + FoldedStyle + FlowStyle +) + +// Node represents an element in the YAML document hierarchy. While documents +// are typically encoded and decoded into higher level types, such as structs +// and maps, Node is an intermediate representation that allows detailed +// control over the content being decoded or encoded. +// +// It's worth noting that although Node offers access into details such as +// line numbers, colums, and comments, the content when re-encoded will not +// have its original textual representation preserved. An effort is made to +// render the data plesantly, and to preserve comments near the data they +// describe, though. +// +// Values that make use of the Node type interact with the yaml package in the +// same way any other type would do, by encoding and decoding yaml data +// directly or indirectly into them. +// +// For example: +// +// var person struct { +// Name string +// Address yaml.Node +// } +// err := yaml.Unmarshal(data, &person) +// +// Or by itself: +// +// var person Node +// err := yaml.Unmarshal(data, &person) +// +type Node struct { + // Kind defines whether the node is a document, a mapping, a sequence, + // a scalar value, or an alias to another node. The specific data type of + // scalar nodes may be obtained via the ShortTag and LongTag methods. + Kind Kind + + // Style allows customizing the apperance of the node in the tree. + Style Style + + // Tag holds the YAML tag defining the data type for the value. + // When decoding, this field will always be set to the resolved tag, + // even when it wasn't explicitly provided in the YAML content. + // When encoding, if this field is unset the value type will be + // implied from the node properties, and if it is set, it will only + // be serialized into the representation if TaggedStyle is used or + // the implicit tag diverges from the provided one. + Tag string + + // Value holds the unescaped and unquoted represenation of the value. + Value string + + // Anchor holds the anchor name for this node, which allows aliases to point to it. + Anchor string + + // Alias holds the node that this alias points to. Only valid when Kind is AliasNode. + Alias *Node + + // Content holds contained nodes for documents, mappings, and sequences. + Content []*Node + + // HeadComment holds any comments in the lines preceding the node and + // not separated by an empty line. + HeadComment string + + // LineComment holds any comments at the end of the line where the node is in. + LineComment string + + // FootComment holds any comments following the node and before empty lines. + FootComment string + + // Line and Column hold the node position in the decoded YAML text. + // These fields are not respected when encoding the node. + Line int + Column int +} + +// IsZero returns whether the node has all of its fields unset. +func (n *Node) IsZero() bool { + return n.Kind == 0 && n.Style == 0 && n.Tag == "" && n.Value == "" && n.Anchor == "" && n.Alias == nil && n.Content == nil && + n.HeadComment == "" && n.LineComment == "" && n.FootComment == "" && n.Line == 0 && n.Column == 0 +} + + +// LongTag returns the long form of the tag that indicates the data type for +// the node. If the Tag field isn't explicitly defined, one will be computed +// based on the node properties. +func (n *Node) LongTag() string { + return longTag(n.ShortTag()) +} + +// ShortTag returns the short form of the YAML tag that indicates data type for +// the node. If the Tag field isn't explicitly defined, one will be computed +// based on the node properties. +func (n *Node) ShortTag() string { + if n.indicatedString() { + return strTag + } + if n.Tag == "" || n.Tag == "!" { + switch n.Kind { + case MappingNode: + return mapTag + case SequenceNode: + return seqTag + case AliasNode: + if n.Alias != nil { + return n.Alias.ShortTag() + } + case ScalarNode: + tag, _ := resolve("", n.Value) + return tag + case 0: + // Special case to make the zero value convenient. + if n.IsZero() { + return nullTag + } + } + return "" + } + return shortTag(n.Tag) +} + +func (n *Node) indicatedString() bool { + return n.Kind == ScalarNode && + (shortTag(n.Tag) == strTag || + (n.Tag == "" || n.Tag == "!") && n.Style&(SingleQuotedStyle|DoubleQuotedStyle|LiteralStyle|FoldedStyle) != 0) +} + +// SetString is a convenience function that sets the node to a string value +// and defines its style in a pleasant way depending on its content. +func (n *Node) SetString(s string) { + n.Kind = ScalarNode + if utf8.ValidString(s) { + n.Value = s + n.Tag = strTag + } else { + n.Value = encodeBase64(s) + n.Tag = binaryTag + } + if strings.Contains(n.Value, "\n") { + n.Style = LiteralStyle + } +} + +// -------------------------------------------------------------------------- +// Maintain a mapping of keys to structure field indexes + +// The code in this section was copied from mgo/bson. + +// structInfo holds details for the serialization of fields of +// a given struct. +type structInfo struct { + FieldsMap map[string]fieldInfo + FieldsList []fieldInfo + + // InlineMap is the number of the field in the struct that + // contains an ,inline map, or -1 if there's none. + InlineMap int + + // InlineUnmarshalers holds indexes to inlined fields that + // contain unmarshaler values. + InlineUnmarshalers [][]int +} + +type fieldInfo struct { + Key string + Num int + OmitEmpty bool + Flow bool + // Id holds the unique field identifier, so we can cheaply + // check for field duplicates without maintaining an extra map. + Id int + + // Inline holds the field index if the field is part of an inlined struct. + Inline []int +} + +var structMap = make(map[reflect.Type]*structInfo) +var fieldMapMutex sync.RWMutex +var unmarshalerType reflect.Type + +func init() { + var v Unmarshaler + unmarshalerType = reflect.ValueOf(&v).Elem().Type() +} + +func getStructInfo(st reflect.Type) (*structInfo, error) { + fieldMapMutex.RLock() + sinfo, found := structMap[st] + fieldMapMutex.RUnlock() + if found { + return sinfo, nil + } + + n := st.NumField() + fieldsMap := make(map[string]fieldInfo) + fieldsList := make([]fieldInfo, 0, n) + inlineMap := -1 + inlineUnmarshalers := [][]int(nil) + for i := 0; i != n; i++ { + field := st.Field(i) + if field.PkgPath != "" && !field.Anonymous { + continue // Private field + } + + info := fieldInfo{Num: i} + + tag := field.Tag.Get("yaml") + if tag == "" && strings.Index(string(field.Tag), ":") < 0 { + tag = string(field.Tag) + } + if tag == "-" { + continue + } + + inline := false + fields := strings.Split(tag, ",") + if len(fields) > 1 { + for _, flag := range fields[1:] { + switch flag { + case "omitempty": + info.OmitEmpty = true + case "flow": + info.Flow = true + case "inline": + inline = true + default: + return nil, errors.New(fmt.Sprintf("unsupported flag %q in tag %q of type %s", flag, tag, st)) + } + } + tag = fields[0] + } + + if inline { + switch field.Type.Kind() { + case reflect.Map: + if inlineMap >= 0 { + return nil, errors.New("multiple ,inline maps in struct " + st.String()) + } + if field.Type.Key() != reflect.TypeOf("") { + return nil, errors.New("option ,inline needs a map with string keys in struct " + st.String()) + } + inlineMap = info.Num + case reflect.Struct, reflect.Ptr: + ftype := field.Type + for ftype.Kind() == reflect.Ptr { + ftype = ftype.Elem() + } + if ftype.Kind() != reflect.Struct { + return nil, errors.New("option ,inline may only be used on a struct or map field") + } + if reflect.PtrTo(ftype).Implements(unmarshalerType) { + inlineUnmarshalers = append(inlineUnmarshalers, []int{i}) + } else { + sinfo, err := getStructInfo(ftype) + if err != nil { + return nil, err + } + for _, index := range sinfo.InlineUnmarshalers { + inlineUnmarshalers = append(inlineUnmarshalers, append([]int{i}, index...)) + } + for _, finfo := range sinfo.FieldsList { + if _, found := fieldsMap[finfo.Key]; found { + msg := "duplicated key '" + finfo.Key + "' in struct " + st.String() + return nil, errors.New(msg) + } + if finfo.Inline == nil { + finfo.Inline = []int{i, finfo.Num} + } else { + finfo.Inline = append([]int{i}, finfo.Inline...) + } + finfo.Id = len(fieldsList) + fieldsMap[finfo.Key] = finfo + fieldsList = append(fieldsList, finfo) + } + } + default: + return nil, errors.New("option ,inline may only be used on a struct or map field") + } + continue + } + + if tag != "" { + info.Key = tag + } else { + info.Key = strings.ToLower(field.Name) + } + + if _, found = fieldsMap[info.Key]; found { + msg := "duplicated key '" + info.Key + "' in struct " + st.String() + return nil, errors.New(msg) + } + + info.Id = len(fieldsList) + fieldsList = append(fieldsList, info) + fieldsMap[info.Key] = info + } + + sinfo = &structInfo{ + FieldsMap: fieldsMap, + FieldsList: fieldsList, + InlineMap: inlineMap, + InlineUnmarshalers: inlineUnmarshalers, + } + + fieldMapMutex.Lock() + structMap[st] = sinfo + fieldMapMutex.Unlock() + return sinfo, nil +} + +// IsZeroer is used to check whether an object is zero to +// determine whether it should be omitted when marshaling +// with the omitempty flag. One notable implementation +// is time.Time. +type IsZeroer interface { + IsZero() bool +} + +func isZero(v reflect.Value) bool { + kind := v.Kind() + if z, ok := v.Interface().(IsZeroer); ok { + if (kind == reflect.Ptr || kind == reflect.Interface) && v.IsNil() { + return true + } + return z.IsZero() + } + switch kind { + case reflect.String: + return len(v.String()) == 0 + case reflect.Interface, reflect.Ptr: + return v.IsNil() + case reflect.Slice: + return v.Len() == 0 + case reflect.Map: + return v.Len() == 0 + case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64: + return v.Int() == 0 + case reflect.Float32, reflect.Float64: + return v.Float() == 0 + case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64, reflect.Uintptr: + return v.Uint() == 0 + case reflect.Bool: + return !v.Bool() + case reflect.Struct: + vt := v.Type() + for i := v.NumField() - 1; i >= 0; i-- { + if vt.Field(i).PkgPath != "" { + continue // Private field + } + if !isZero(v.Field(i)) { + return false + } + } + return true + } + return false +} diff --git a/mcp/dsx-exchange-mcp/vendor/gopkg.in/yaml.v3/yamlh.go b/mcp/dsx-exchange-mcp/vendor/gopkg.in/yaml.v3/yamlh.go new file mode 100644 index 0000000..7c6d007 --- /dev/null +++ b/mcp/dsx-exchange-mcp/vendor/gopkg.in/yaml.v3/yamlh.go @@ -0,0 +1,807 @@ +// +// Copyright (c) 2011-2019 Canonical Ltd +// Copyright (c) 2006-2010 Kirill Simonov +// +// Permission is hereby granted, free of charge, to any person obtaining a copy of +// this software and associated documentation files (the "Software"), to deal in +// the Software without restriction, including without limitation the rights to +// use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies +// of the Software, and to permit persons to whom the Software is furnished to do +// so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in all +// copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. + +package yaml + +import ( + "fmt" + "io" +) + +// The version directive data. +type yaml_version_directive_t struct { + major int8 // The major version number. + minor int8 // The minor version number. +} + +// The tag directive data. +type yaml_tag_directive_t struct { + handle []byte // The tag handle. + prefix []byte // The tag prefix. +} + +type yaml_encoding_t int + +// The stream encoding. +const ( + // Let the parser choose the encoding. + yaml_ANY_ENCODING yaml_encoding_t = iota + + yaml_UTF8_ENCODING // The default UTF-8 encoding. + yaml_UTF16LE_ENCODING // The UTF-16-LE encoding with BOM. + yaml_UTF16BE_ENCODING // The UTF-16-BE encoding with BOM. +) + +type yaml_break_t int + +// Line break types. +const ( + // Let the parser choose the break type. + yaml_ANY_BREAK yaml_break_t = iota + + yaml_CR_BREAK // Use CR for line breaks (Mac style). + yaml_LN_BREAK // Use LN for line breaks (Unix style). + yaml_CRLN_BREAK // Use CR LN for line breaks (DOS style). +) + +type yaml_error_type_t int + +// Many bad things could happen with the parser and emitter. +const ( + // No error is produced. + yaml_NO_ERROR yaml_error_type_t = iota + + yaml_MEMORY_ERROR // Cannot allocate or reallocate a block of memory. + yaml_READER_ERROR // Cannot read or decode the input stream. + yaml_SCANNER_ERROR // Cannot scan the input stream. + yaml_PARSER_ERROR // Cannot parse the input stream. + yaml_COMPOSER_ERROR // Cannot compose a YAML document. + yaml_WRITER_ERROR // Cannot write to the output stream. + yaml_EMITTER_ERROR // Cannot emit a YAML stream. +) + +// The pointer position. +type yaml_mark_t struct { + index int // The position index. + line int // The position line. + column int // The position column. +} + +// Node Styles + +type yaml_style_t int8 + +type yaml_scalar_style_t yaml_style_t + +// Scalar styles. +const ( + // Let the emitter choose the style. + yaml_ANY_SCALAR_STYLE yaml_scalar_style_t = 0 + + yaml_PLAIN_SCALAR_STYLE yaml_scalar_style_t = 1 << iota // The plain scalar style. + yaml_SINGLE_QUOTED_SCALAR_STYLE // The single-quoted scalar style. + yaml_DOUBLE_QUOTED_SCALAR_STYLE // The double-quoted scalar style. + yaml_LITERAL_SCALAR_STYLE // The literal scalar style. + yaml_FOLDED_SCALAR_STYLE // The folded scalar style. +) + +type yaml_sequence_style_t yaml_style_t + +// Sequence styles. +const ( + // Let the emitter choose the style. + yaml_ANY_SEQUENCE_STYLE yaml_sequence_style_t = iota + + yaml_BLOCK_SEQUENCE_STYLE // The block sequence style. + yaml_FLOW_SEQUENCE_STYLE // The flow sequence style. +) + +type yaml_mapping_style_t yaml_style_t + +// Mapping styles. +const ( + // Let the emitter choose the style. + yaml_ANY_MAPPING_STYLE yaml_mapping_style_t = iota + + yaml_BLOCK_MAPPING_STYLE // The block mapping style. + yaml_FLOW_MAPPING_STYLE // The flow mapping style. +) + +// Tokens + +type yaml_token_type_t int + +// Token types. +const ( + // An empty token. + yaml_NO_TOKEN yaml_token_type_t = iota + + yaml_STREAM_START_TOKEN // A STREAM-START token. + yaml_STREAM_END_TOKEN // A STREAM-END token. + + yaml_VERSION_DIRECTIVE_TOKEN // A VERSION-DIRECTIVE token. + yaml_TAG_DIRECTIVE_TOKEN // A TAG-DIRECTIVE token. + yaml_DOCUMENT_START_TOKEN // A DOCUMENT-START token. + yaml_DOCUMENT_END_TOKEN // A DOCUMENT-END token. + + yaml_BLOCK_SEQUENCE_START_TOKEN // A BLOCK-SEQUENCE-START token. + yaml_BLOCK_MAPPING_START_TOKEN // A BLOCK-SEQUENCE-END token. + yaml_BLOCK_END_TOKEN // A BLOCK-END token. + + yaml_FLOW_SEQUENCE_START_TOKEN // A FLOW-SEQUENCE-START token. + yaml_FLOW_SEQUENCE_END_TOKEN // A FLOW-SEQUENCE-END token. + yaml_FLOW_MAPPING_START_TOKEN // A FLOW-MAPPING-START token. + yaml_FLOW_MAPPING_END_TOKEN // A FLOW-MAPPING-END token. + + yaml_BLOCK_ENTRY_TOKEN // A BLOCK-ENTRY token. + yaml_FLOW_ENTRY_TOKEN // A FLOW-ENTRY token. + yaml_KEY_TOKEN // A KEY token. + yaml_VALUE_TOKEN // A VALUE token. + + yaml_ALIAS_TOKEN // An ALIAS token. + yaml_ANCHOR_TOKEN // An ANCHOR token. + yaml_TAG_TOKEN // A TAG token. + yaml_SCALAR_TOKEN // A SCALAR token. +) + +func (tt yaml_token_type_t) String() string { + switch tt { + case yaml_NO_TOKEN: + return "yaml_NO_TOKEN" + case yaml_STREAM_START_TOKEN: + return "yaml_STREAM_START_TOKEN" + case yaml_STREAM_END_TOKEN: + return "yaml_STREAM_END_TOKEN" + case yaml_VERSION_DIRECTIVE_TOKEN: + return "yaml_VERSION_DIRECTIVE_TOKEN" + case yaml_TAG_DIRECTIVE_TOKEN: + return "yaml_TAG_DIRECTIVE_TOKEN" + case yaml_DOCUMENT_START_TOKEN: + return "yaml_DOCUMENT_START_TOKEN" + case yaml_DOCUMENT_END_TOKEN: + return "yaml_DOCUMENT_END_TOKEN" + case yaml_BLOCK_SEQUENCE_START_TOKEN: + return "yaml_BLOCK_SEQUENCE_START_TOKEN" + case yaml_BLOCK_MAPPING_START_TOKEN: + return "yaml_BLOCK_MAPPING_START_TOKEN" + case yaml_BLOCK_END_TOKEN: + return "yaml_BLOCK_END_TOKEN" + case yaml_FLOW_SEQUENCE_START_TOKEN: + return "yaml_FLOW_SEQUENCE_START_TOKEN" + case yaml_FLOW_SEQUENCE_END_TOKEN: + return "yaml_FLOW_SEQUENCE_END_TOKEN" + case yaml_FLOW_MAPPING_START_TOKEN: + return "yaml_FLOW_MAPPING_START_TOKEN" + case yaml_FLOW_MAPPING_END_TOKEN: + return "yaml_FLOW_MAPPING_END_TOKEN" + case yaml_BLOCK_ENTRY_TOKEN: + return "yaml_BLOCK_ENTRY_TOKEN" + case yaml_FLOW_ENTRY_TOKEN: + return "yaml_FLOW_ENTRY_TOKEN" + case yaml_KEY_TOKEN: + return "yaml_KEY_TOKEN" + case yaml_VALUE_TOKEN: + return "yaml_VALUE_TOKEN" + case yaml_ALIAS_TOKEN: + return "yaml_ALIAS_TOKEN" + case yaml_ANCHOR_TOKEN: + return "yaml_ANCHOR_TOKEN" + case yaml_TAG_TOKEN: + return "yaml_TAG_TOKEN" + case yaml_SCALAR_TOKEN: + return "yaml_SCALAR_TOKEN" + } + return "" +} + +// The token structure. +type yaml_token_t struct { + // The token type. + typ yaml_token_type_t + + // The start/end of the token. + start_mark, end_mark yaml_mark_t + + // The stream encoding (for yaml_STREAM_START_TOKEN). + encoding yaml_encoding_t + + // The alias/anchor/scalar value or tag/tag directive handle + // (for yaml_ALIAS_TOKEN, yaml_ANCHOR_TOKEN, yaml_SCALAR_TOKEN, yaml_TAG_TOKEN, yaml_TAG_DIRECTIVE_TOKEN). + value []byte + + // The tag suffix (for yaml_TAG_TOKEN). + suffix []byte + + // The tag directive prefix (for yaml_TAG_DIRECTIVE_TOKEN). + prefix []byte + + // The scalar style (for yaml_SCALAR_TOKEN). + style yaml_scalar_style_t + + // The version directive major/minor (for yaml_VERSION_DIRECTIVE_TOKEN). + major, minor int8 +} + +// Events + +type yaml_event_type_t int8 + +// Event types. +const ( + // An empty event. + yaml_NO_EVENT yaml_event_type_t = iota + + yaml_STREAM_START_EVENT // A STREAM-START event. + yaml_STREAM_END_EVENT // A STREAM-END event. + yaml_DOCUMENT_START_EVENT // A DOCUMENT-START event. + yaml_DOCUMENT_END_EVENT // A DOCUMENT-END event. + yaml_ALIAS_EVENT // An ALIAS event. + yaml_SCALAR_EVENT // A SCALAR event. + yaml_SEQUENCE_START_EVENT // A SEQUENCE-START event. + yaml_SEQUENCE_END_EVENT // A SEQUENCE-END event. + yaml_MAPPING_START_EVENT // A MAPPING-START event. + yaml_MAPPING_END_EVENT // A MAPPING-END event. + yaml_TAIL_COMMENT_EVENT +) + +var eventStrings = []string{ + yaml_NO_EVENT: "none", + yaml_STREAM_START_EVENT: "stream start", + yaml_STREAM_END_EVENT: "stream end", + yaml_DOCUMENT_START_EVENT: "document start", + yaml_DOCUMENT_END_EVENT: "document end", + yaml_ALIAS_EVENT: "alias", + yaml_SCALAR_EVENT: "scalar", + yaml_SEQUENCE_START_EVENT: "sequence start", + yaml_SEQUENCE_END_EVENT: "sequence end", + yaml_MAPPING_START_EVENT: "mapping start", + yaml_MAPPING_END_EVENT: "mapping end", + yaml_TAIL_COMMENT_EVENT: "tail comment", +} + +func (e yaml_event_type_t) String() string { + if e < 0 || int(e) >= len(eventStrings) { + return fmt.Sprintf("unknown event %d", e) + } + return eventStrings[e] +} + +// The event structure. +type yaml_event_t struct { + + // The event type. + typ yaml_event_type_t + + // The start and end of the event. + start_mark, end_mark yaml_mark_t + + // The document encoding (for yaml_STREAM_START_EVENT). + encoding yaml_encoding_t + + // The version directive (for yaml_DOCUMENT_START_EVENT). + version_directive *yaml_version_directive_t + + // The list of tag directives (for yaml_DOCUMENT_START_EVENT). + tag_directives []yaml_tag_directive_t + + // The comments + head_comment []byte + line_comment []byte + foot_comment []byte + tail_comment []byte + + // The anchor (for yaml_SCALAR_EVENT, yaml_SEQUENCE_START_EVENT, yaml_MAPPING_START_EVENT, yaml_ALIAS_EVENT). + anchor []byte + + // The tag (for yaml_SCALAR_EVENT, yaml_SEQUENCE_START_EVENT, yaml_MAPPING_START_EVENT). + tag []byte + + // The scalar value (for yaml_SCALAR_EVENT). + value []byte + + // Is the document start/end indicator implicit, or the tag optional? + // (for yaml_DOCUMENT_START_EVENT, yaml_DOCUMENT_END_EVENT, yaml_SEQUENCE_START_EVENT, yaml_MAPPING_START_EVENT, yaml_SCALAR_EVENT). + implicit bool + + // Is the tag optional for any non-plain style? (for yaml_SCALAR_EVENT). + quoted_implicit bool + + // The style (for yaml_SCALAR_EVENT, yaml_SEQUENCE_START_EVENT, yaml_MAPPING_START_EVENT). + style yaml_style_t +} + +func (e *yaml_event_t) scalar_style() yaml_scalar_style_t { return yaml_scalar_style_t(e.style) } +func (e *yaml_event_t) sequence_style() yaml_sequence_style_t { return yaml_sequence_style_t(e.style) } +func (e *yaml_event_t) mapping_style() yaml_mapping_style_t { return yaml_mapping_style_t(e.style) } + +// Nodes + +const ( + yaml_NULL_TAG = "tag:yaml.org,2002:null" // The tag !!null with the only possible value: null. + yaml_BOOL_TAG = "tag:yaml.org,2002:bool" // The tag !!bool with the values: true and false. + yaml_STR_TAG = "tag:yaml.org,2002:str" // The tag !!str for string values. + yaml_INT_TAG = "tag:yaml.org,2002:int" // The tag !!int for integer values. + yaml_FLOAT_TAG = "tag:yaml.org,2002:float" // The tag !!float for float values. + yaml_TIMESTAMP_TAG = "tag:yaml.org,2002:timestamp" // The tag !!timestamp for date and time values. + + yaml_SEQ_TAG = "tag:yaml.org,2002:seq" // The tag !!seq is used to denote sequences. + yaml_MAP_TAG = "tag:yaml.org,2002:map" // The tag !!map is used to denote mapping. + + // Not in original libyaml. + yaml_BINARY_TAG = "tag:yaml.org,2002:binary" + yaml_MERGE_TAG = "tag:yaml.org,2002:merge" + + yaml_DEFAULT_SCALAR_TAG = yaml_STR_TAG // The default scalar tag is !!str. + yaml_DEFAULT_SEQUENCE_TAG = yaml_SEQ_TAG // The default sequence tag is !!seq. + yaml_DEFAULT_MAPPING_TAG = yaml_MAP_TAG // The default mapping tag is !!map. +) + +type yaml_node_type_t int + +// Node types. +const ( + // An empty node. + yaml_NO_NODE yaml_node_type_t = iota + + yaml_SCALAR_NODE // A scalar node. + yaml_SEQUENCE_NODE // A sequence node. + yaml_MAPPING_NODE // A mapping node. +) + +// An element of a sequence node. +type yaml_node_item_t int + +// An element of a mapping node. +type yaml_node_pair_t struct { + key int // The key of the element. + value int // The value of the element. +} + +// The node structure. +type yaml_node_t struct { + typ yaml_node_type_t // The node type. + tag []byte // The node tag. + + // The node data. + + // The scalar parameters (for yaml_SCALAR_NODE). + scalar struct { + value []byte // The scalar value. + length int // The length of the scalar value. + style yaml_scalar_style_t // The scalar style. + } + + // The sequence parameters (for YAML_SEQUENCE_NODE). + sequence struct { + items_data []yaml_node_item_t // The stack of sequence items. + style yaml_sequence_style_t // The sequence style. + } + + // The mapping parameters (for yaml_MAPPING_NODE). + mapping struct { + pairs_data []yaml_node_pair_t // The stack of mapping pairs (key, value). + pairs_start *yaml_node_pair_t // The beginning of the stack. + pairs_end *yaml_node_pair_t // The end of the stack. + pairs_top *yaml_node_pair_t // The top of the stack. + style yaml_mapping_style_t // The mapping style. + } + + start_mark yaml_mark_t // The beginning of the node. + end_mark yaml_mark_t // The end of the node. + +} + +// The document structure. +type yaml_document_t struct { + + // The document nodes. + nodes []yaml_node_t + + // The version directive. + version_directive *yaml_version_directive_t + + // The list of tag directives. + tag_directives_data []yaml_tag_directive_t + tag_directives_start int // The beginning of the tag directives list. + tag_directives_end int // The end of the tag directives list. + + start_implicit int // Is the document start indicator implicit? + end_implicit int // Is the document end indicator implicit? + + // The start/end of the document. + start_mark, end_mark yaml_mark_t +} + +// The prototype of a read handler. +// +// The read handler is called when the parser needs to read more bytes from the +// source. The handler should write not more than size bytes to the buffer. +// The number of written bytes should be set to the size_read variable. +// +// [in,out] data A pointer to an application data specified by +// yaml_parser_set_input(). +// [out] buffer The buffer to write the data from the source. +// [in] size The size of the buffer. +// [out] size_read The actual number of bytes read from the source. +// +// On success, the handler should return 1. If the handler failed, +// the returned value should be 0. On EOF, the handler should set the +// size_read to 0 and return 1. +type yaml_read_handler_t func(parser *yaml_parser_t, buffer []byte) (n int, err error) + +// This structure holds information about a potential simple key. +type yaml_simple_key_t struct { + possible bool // Is a simple key possible? + required bool // Is a simple key required? + token_number int // The number of the token. + mark yaml_mark_t // The position mark. +} + +// The states of the parser. +type yaml_parser_state_t int + +const ( + yaml_PARSE_STREAM_START_STATE yaml_parser_state_t = iota + + yaml_PARSE_IMPLICIT_DOCUMENT_START_STATE // Expect the beginning of an implicit document. + yaml_PARSE_DOCUMENT_START_STATE // Expect DOCUMENT-START. + yaml_PARSE_DOCUMENT_CONTENT_STATE // Expect the content of a document. + yaml_PARSE_DOCUMENT_END_STATE // Expect DOCUMENT-END. + yaml_PARSE_BLOCK_NODE_STATE // Expect a block node. + yaml_PARSE_BLOCK_NODE_OR_INDENTLESS_SEQUENCE_STATE // Expect a block node or indentless sequence. + yaml_PARSE_FLOW_NODE_STATE // Expect a flow node. + yaml_PARSE_BLOCK_SEQUENCE_FIRST_ENTRY_STATE // Expect the first entry of a block sequence. + yaml_PARSE_BLOCK_SEQUENCE_ENTRY_STATE // Expect an entry of a block sequence. + yaml_PARSE_INDENTLESS_SEQUENCE_ENTRY_STATE // Expect an entry of an indentless sequence. + yaml_PARSE_BLOCK_MAPPING_FIRST_KEY_STATE // Expect the first key of a block mapping. + yaml_PARSE_BLOCK_MAPPING_KEY_STATE // Expect a block mapping key. + yaml_PARSE_BLOCK_MAPPING_VALUE_STATE // Expect a block mapping value. + yaml_PARSE_FLOW_SEQUENCE_FIRST_ENTRY_STATE // Expect the first entry of a flow sequence. + yaml_PARSE_FLOW_SEQUENCE_ENTRY_STATE // Expect an entry of a flow sequence. + yaml_PARSE_FLOW_SEQUENCE_ENTRY_MAPPING_KEY_STATE // Expect a key of an ordered mapping. + yaml_PARSE_FLOW_SEQUENCE_ENTRY_MAPPING_VALUE_STATE // Expect a value of an ordered mapping. + yaml_PARSE_FLOW_SEQUENCE_ENTRY_MAPPING_END_STATE // Expect the and of an ordered mapping entry. + yaml_PARSE_FLOW_MAPPING_FIRST_KEY_STATE // Expect the first key of a flow mapping. + yaml_PARSE_FLOW_MAPPING_KEY_STATE // Expect a key of a flow mapping. + yaml_PARSE_FLOW_MAPPING_VALUE_STATE // Expect a value of a flow mapping. + yaml_PARSE_FLOW_MAPPING_EMPTY_VALUE_STATE // Expect an empty value of a flow mapping. + yaml_PARSE_END_STATE // Expect nothing. +) + +func (ps yaml_parser_state_t) String() string { + switch ps { + case yaml_PARSE_STREAM_START_STATE: + return "yaml_PARSE_STREAM_START_STATE" + case yaml_PARSE_IMPLICIT_DOCUMENT_START_STATE: + return "yaml_PARSE_IMPLICIT_DOCUMENT_START_STATE" + case yaml_PARSE_DOCUMENT_START_STATE: + return "yaml_PARSE_DOCUMENT_START_STATE" + case yaml_PARSE_DOCUMENT_CONTENT_STATE: + return "yaml_PARSE_DOCUMENT_CONTENT_STATE" + case yaml_PARSE_DOCUMENT_END_STATE: + return "yaml_PARSE_DOCUMENT_END_STATE" + case yaml_PARSE_BLOCK_NODE_STATE: + return "yaml_PARSE_BLOCK_NODE_STATE" + case yaml_PARSE_BLOCK_NODE_OR_INDENTLESS_SEQUENCE_STATE: + return "yaml_PARSE_BLOCK_NODE_OR_INDENTLESS_SEQUENCE_STATE" + case yaml_PARSE_FLOW_NODE_STATE: + return "yaml_PARSE_FLOW_NODE_STATE" + case yaml_PARSE_BLOCK_SEQUENCE_FIRST_ENTRY_STATE: + return "yaml_PARSE_BLOCK_SEQUENCE_FIRST_ENTRY_STATE" + case yaml_PARSE_BLOCK_SEQUENCE_ENTRY_STATE: + return "yaml_PARSE_BLOCK_SEQUENCE_ENTRY_STATE" + case yaml_PARSE_INDENTLESS_SEQUENCE_ENTRY_STATE: + return "yaml_PARSE_INDENTLESS_SEQUENCE_ENTRY_STATE" + case yaml_PARSE_BLOCK_MAPPING_FIRST_KEY_STATE: + return "yaml_PARSE_BLOCK_MAPPING_FIRST_KEY_STATE" + case yaml_PARSE_BLOCK_MAPPING_KEY_STATE: + return "yaml_PARSE_BLOCK_MAPPING_KEY_STATE" + case yaml_PARSE_BLOCK_MAPPING_VALUE_STATE: + return "yaml_PARSE_BLOCK_MAPPING_VALUE_STATE" + case yaml_PARSE_FLOW_SEQUENCE_FIRST_ENTRY_STATE: + return "yaml_PARSE_FLOW_SEQUENCE_FIRST_ENTRY_STATE" + case yaml_PARSE_FLOW_SEQUENCE_ENTRY_STATE: + return "yaml_PARSE_FLOW_SEQUENCE_ENTRY_STATE" + case yaml_PARSE_FLOW_SEQUENCE_ENTRY_MAPPING_KEY_STATE: + return "yaml_PARSE_FLOW_SEQUENCE_ENTRY_MAPPING_KEY_STATE" + case yaml_PARSE_FLOW_SEQUENCE_ENTRY_MAPPING_VALUE_STATE: + return "yaml_PARSE_FLOW_SEQUENCE_ENTRY_MAPPING_VALUE_STATE" + case yaml_PARSE_FLOW_SEQUENCE_ENTRY_MAPPING_END_STATE: + return "yaml_PARSE_FLOW_SEQUENCE_ENTRY_MAPPING_END_STATE" + case yaml_PARSE_FLOW_MAPPING_FIRST_KEY_STATE: + return "yaml_PARSE_FLOW_MAPPING_FIRST_KEY_STATE" + case yaml_PARSE_FLOW_MAPPING_KEY_STATE: + return "yaml_PARSE_FLOW_MAPPING_KEY_STATE" + case yaml_PARSE_FLOW_MAPPING_VALUE_STATE: + return "yaml_PARSE_FLOW_MAPPING_VALUE_STATE" + case yaml_PARSE_FLOW_MAPPING_EMPTY_VALUE_STATE: + return "yaml_PARSE_FLOW_MAPPING_EMPTY_VALUE_STATE" + case yaml_PARSE_END_STATE: + return "yaml_PARSE_END_STATE" + } + return "" +} + +// This structure holds aliases data. +type yaml_alias_data_t struct { + anchor []byte // The anchor. + index int // The node id. + mark yaml_mark_t // The anchor mark. +} + +// The parser structure. +// +// All members are internal. Manage the structure using the +// yaml_parser_ family of functions. +type yaml_parser_t struct { + + // Error handling + + error yaml_error_type_t // Error type. + + problem string // Error description. + + // The byte about which the problem occurred. + problem_offset int + problem_value int + problem_mark yaml_mark_t + + // The error context. + context string + context_mark yaml_mark_t + + // Reader stuff + + read_handler yaml_read_handler_t // Read handler. + + input_reader io.Reader // File input data. + input []byte // String input data. + input_pos int + + eof bool // EOF flag + + buffer []byte // The working buffer. + buffer_pos int // The current position of the buffer. + + unread int // The number of unread characters in the buffer. + + newlines int // The number of line breaks since last non-break/non-blank character + + raw_buffer []byte // The raw buffer. + raw_buffer_pos int // The current position of the buffer. + + encoding yaml_encoding_t // The input encoding. + + offset int // The offset of the current position (in bytes). + mark yaml_mark_t // The mark of the current position. + + // Comments + + head_comment []byte // The current head comments + line_comment []byte // The current line comments + foot_comment []byte // The current foot comments + tail_comment []byte // Foot comment that happens at the end of a block. + stem_comment []byte // Comment in item preceding a nested structure (list inside list item, etc) + + comments []yaml_comment_t // The folded comments for all parsed tokens + comments_head int + + // Scanner stuff + + stream_start_produced bool // Have we started to scan the input stream? + stream_end_produced bool // Have we reached the end of the input stream? + + flow_level int // The number of unclosed '[' and '{' indicators. + + tokens []yaml_token_t // The tokens queue. + tokens_head int // The head of the tokens queue. + tokens_parsed int // The number of tokens fetched from the queue. + token_available bool // Does the tokens queue contain a token ready for dequeueing. + + indent int // The current indentation level. + indents []int // The indentation levels stack. + + simple_key_allowed bool // May a simple key occur at the current position? + simple_keys []yaml_simple_key_t // The stack of simple keys. + simple_keys_by_tok map[int]int // possible simple_key indexes indexed by token_number + + // Parser stuff + + state yaml_parser_state_t // The current parser state. + states []yaml_parser_state_t // The parser states stack. + marks []yaml_mark_t // The stack of marks. + tag_directives []yaml_tag_directive_t // The list of TAG directives. + + // Dumper stuff + + aliases []yaml_alias_data_t // The alias data. + + document *yaml_document_t // The currently parsed document. +} + +type yaml_comment_t struct { + + scan_mark yaml_mark_t // Position where scanning for comments started + token_mark yaml_mark_t // Position after which tokens will be associated with this comment + start_mark yaml_mark_t // Position of '#' comment mark + end_mark yaml_mark_t // Position where comment terminated + + head []byte + line []byte + foot []byte +} + +// Emitter Definitions + +// The prototype of a write handler. +// +// The write handler is called when the emitter needs to flush the accumulated +// characters to the output. The handler should write @a size bytes of the +// @a buffer to the output. +// +// @param[in,out] data A pointer to an application data specified by +// yaml_emitter_set_output(). +// @param[in] buffer The buffer with bytes to be written. +// @param[in] size The size of the buffer. +// +// @returns On success, the handler should return @c 1. If the handler failed, +// the returned value should be @c 0. +// +type yaml_write_handler_t func(emitter *yaml_emitter_t, buffer []byte) error + +type yaml_emitter_state_t int + +// The emitter states. +const ( + // Expect STREAM-START. + yaml_EMIT_STREAM_START_STATE yaml_emitter_state_t = iota + + yaml_EMIT_FIRST_DOCUMENT_START_STATE // Expect the first DOCUMENT-START or STREAM-END. + yaml_EMIT_DOCUMENT_START_STATE // Expect DOCUMENT-START or STREAM-END. + yaml_EMIT_DOCUMENT_CONTENT_STATE // Expect the content of a document. + yaml_EMIT_DOCUMENT_END_STATE // Expect DOCUMENT-END. + yaml_EMIT_FLOW_SEQUENCE_FIRST_ITEM_STATE // Expect the first item of a flow sequence. + yaml_EMIT_FLOW_SEQUENCE_TRAIL_ITEM_STATE // Expect the next item of a flow sequence, with the comma already written out + yaml_EMIT_FLOW_SEQUENCE_ITEM_STATE // Expect an item of a flow sequence. + yaml_EMIT_FLOW_MAPPING_FIRST_KEY_STATE // Expect the first key of a flow mapping. + yaml_EMIT_FLOW_MAPPING_TRAIL_KEY_STATE // Expect the next key of a flow mapping, with the comma already written out + yaml_EMIT_FLOW_MAPPING_KEY_STATE // Expect a key of a flow mapping. + yaml_EMIT_FLOW_MAPPING_SIMPLE_VALUE_STATE // Expect a value for a simple key of a flow mapping. + yaml_EMIT_FLOW_MAPPING_VALUE_STATE // Expect a value of a flow mapping. + yaml_EMIT_BLOCK_SEQUENCE_FIRST_ITEM_STATE // Expect the first item of a block sequence. + yaml_EMIT_BLOCK_SEQUENCE_ITEM_STATE // Expect an item of a block sequence. + yaml_EMIT_BLOCK_MAPPING_FIRST_KEY_STATE // Expect the first key of a block mapping. + yaml_EMIT_BLOCK_MAPPING_KEY_STATE // Expect the key of a block mapping. + yaml_EMIT_BLOCK_MAPPING_SIMPLE_VALUE_STATE // Expect a value for a simple key of a block mapping. + yaml_EMIT_BLOCK_MAPPING_VALUE_STATE // Expect a value of a block mapping. + yaml_EMIT_END_STATE // Expect nothing. +) + +// The emitter structure. +// +// All members are internal. Manage the structure using the @c yaml_emitter_ +// family of functions. +type yaml_emitter_t struct { + + // Error handling + + error yaml_error_type_t // Error type. + problem string // Error description. + + // Writer stuff + + write_handler yaml_write_handler_t // Write handler. + + output_buffer *[]byte // String output data. + output_writer io.Writer // File output data. + + buffer []byte // The working buffer. + buffer_pos int // The current position of the buffer. + + raw_buffer []byte // The raw buffer. + raw_buffer_pos int // The current position of the buffer. + + encoding yaml_encoding_t // The stream encoding. + + // Emitter stuff + + canonical bool // If the output is in the canonical style? + best_indent int // The number of indentation spaces. + best_width int // The preferred width of the output lines. + unicode bool // Allow unescaped non-ASCII characters? + line_break yaml_break_t // The preferred line break. + + state yaml_emitter_state_t // The current emitter state. + states []yaml_emitter_state_t // The stack of states. + + events []yaml_event_t // The event queue. + events_head int // The head of the event queue. + + indents []int // The stack of indentation levels. + + tag_directives []yaml_tag_directive_t // The list of tag directives. + + indent int // The current indentation level. + + flow_level int // The current flow level. + + root_context bool // Is it the document root context? + sequence_context bool // Is it a sequence context? + mapping_context bool // Is it a mapping context? + simple_key_context bool // Is it a simple mapping key context? + + line int // The current line. + column int // The current column. + whitespace bool // If the last character was a whitespace? + indention bool // If the last character was an indentation character (' ', '-', '?', ':')? + open_ended bool // If an explicit document end is required? + + space_above bool // Is there's an empty line above? + foot_indent int // The indent used to write the foot comment above, or -1 if none. + + // Anchor analysis. + anchor_data struct { + anchor []byte // The anchor value. + alias bool // Is it an alias? + } + + // Tag analysis. + tag_data struct { + handle []byte // The tag handle. + suffix []byte // The tag suffix. + } + + // Scalar analysis. + scalar_data struct { + value []byte // The scalar value. + multiline bool // Does the scalar contain line breaks? + flow_plain_allowed bool // Can the scalar be expessed in the flow plain style? + block_plain_allowed bool // Can the scalar be expressed in the block plain style? + single_quoted_allowed bool // Can the scalar be expressed in the single quoted style? + block_allowed bool // Can the scalar be expressed in the literal or folded styles? + style yaml_scalar_style_t // The output style. + } + + // Comments + head_comment []byte + line_comment []byte + foot_comment []byte + tail_comment []byte + + key_line_comment []byte + + // Dumper stuff + + opened bool // If the stream was already opened? + closed bool // If the stream was already closed? + + // The information associated with the document nodes. + anchors *struct { + references int // The number of references. + anchor int // The anchor id. + serialized bool // If the node has been emitted? + } + + last_anchor_id int // The last assigned anchor id. + + document *yaml_document_t // The currently emitted document. +} diff --git a/mcp/dsx-exchange-mcp/vendor/gopkg.in/yaml.v3/yamlprivateh.go b/mcp/dsx-exchange-mcp/vendor/gopkg.in/yaml.v3/yamlprivateh.go new file mode 100644 index 0000000..e88f9c5 --- /dev/null +++ b/mcp/dsx-exchange-mcp/vendor/gopkg.in/yaml.v3/yamlprivateh.go @@ -0,0 +1,198 @@ +// +// Copyright (c) 2011-2019 Canonical Ltd +// Copyright (c) 2006-2010 Kirill Simonov +// +// Permission is hereby granted, free of charge, to any person obtaining a copy of +// this software and associated documentation files (the "Software"), to deal in +// the Software without restriction, including without limitation the rights to +// use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies +// of the Software, and to permit persons to whom the Software is furnished to do +// so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in all +// copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. + +package yaml + +const ( + // The size of the input raw buffer. + input_raw_buffer_size = 512 + + // The size of the input buffer. + // It should be possible to decode the whole raw buffer. + input_buffer_size = input_raw_buffer_size * 3 + + // The size of the output buffer. + output_buffer_size = 128 + + // The size of the output raw buffer. + // It should be possible to encode the whole output buffer. + output_raw_buffer_size = (output_buffer_size*2 + 2) + + // The size of other stacks and queues. + initial_stack_size = 16 + initial_queue_size = 16 + initial_string_size = 16 +) + +// Check if the character at the specified position is an alphabetical +// character, a digit, '_', or '-'. +func is_alpha(b []byte, i int) bool { + return b[i] >= '0' && b[i] <= '9' || b[i] >= 'A' && b[i] <= 'Z' || b[i] >= 'a' && b[i] <= 'z' || b[i] == '_' || b[i] == '-' +} + +// Check if the character at the specified position is a digit. +func is_digit(b []byte, i int) bool { + return b[i] >= '0' && b[i] <= '9' +} + +// Get the value of a digit. +func as_digit(b []byte, i int) int { + return int(b[i]) - '0' +} + +// Check if the character at the specified position is a hex-digit. +func is_hex(b []byte, i int) bool { + return b[i] >= '0' && b[i] <= '9' || b[i] >= 'A' && b[i] <= 'F' || b[i] >= 'a' && b[i] <= 'f' +} + +// Get the value of a hex-digit. +func as_hex(b []byte, i int) int { + bi := b[i] + if bi >= 'A' && bi <= 'F' { + return int(bi) - 'A' + 10 + } + if bi >= 'a' && bi <= 'f' { + return int(bi) - 'a' + 10 + } + return int(bi) - '0' +} + +// Check if the character is ASCII. +func is_ascii(b []byte, i int) bool { + return b[i] <= 0x7F +} + +// Check if the character at the start of the buffer can be printed unescaped. +func is_printable(b []byte, i int) bool { + return ((b[i] == 0x0A) || // . == #x0A + (b[i] >= 0x20 && b[i] <= 0x7E) || // #x20 <= . <= #x7E + (b[i] == 0xC2 && b[i+1] >= 0xA0) || // #0xA0 <= . <= #xD7FF + (b[i] > 0xC2 && b[i] < 0xED) || + (b[i] == 0xED && b[i+1] < 0xA0) || + (b[i] == 0xEE) || + (b[i] == 0xEF && // #xE000 <= . <= #xFFFD + !(b[i+1] == 0xBB && b[i+2] == 0xBF) && // && . != #xFEFF + !(b[i+1] == 0xBF && (b[i+2] == 0xBE || b[i+2] == 0xBF)))) +} + +// Check if the character at the specified position is NUL. +func is_z(b []byte, i int) bool { + return b[i] == 0x00 +} + +// Check if the beginning of the buffer is a BOM. +func is_bom(b []byte, i int) bool { + return b[0] == 0xEF && b[1] == 0xBB && b[2] == 0xBF +} + +// Check if the character at the specified position is space. +func is_space(b []byte, i int) bool { + return b[i] == ' ' +} + +// Check if the character at the specified position is tab. +func is_tab(b []byte, i int) bool { + return b[i] == '\t' +} + +// Check if the character at the specified position is blank (space or tab). +func is_blank(b []byte, i int) bool { + //return is_space(b, i) || is_tab(b, i) + return b[i] == ' ' || b[i] == '\t' +} + +// Check if the character at the specified position is a line break. +func is_break(b []byte, i int) bool { + return (b[i] == '\r' || // CR (#xD) + b[i] == '\n' || // LF (#xA) + b[i] == 0xC2 && b[i+1] == 0x85 || // NEL (#x85) + b[i] == 0xE2 && b[i+1] == 0x80 && b[i+2] == 0xA8 || // LS (#x2028) + b[i] == 0xE2 && b[i+1] == 0x80 && b[i+2] == 0xA9) // PS (#x2029) +} + +func is_crlf(b []byte, i int) bool { + return b[i] == '\r' && b[i+1] == '\n' +} + +// Check if the character is a line break or NUL. +func is_breakz(b []byte, i int) bool { + //return is_break(b, i) || is_z(b, i) + return ( + // is_break: + b[i] == '\r' || // CR (#xD) + b[i] == '\n' || // LF (#xA) + b[i] == 0xC2 && b[i+1] == 0x85 || // NEL (#x85) + b[i] == 0xE2 && b[i+1] == 0x80 && b[i+2] == 0xA8 || // LS (#x2028) + b[i] == 0xE2 && b[i+1] == 0x80 && b[i+2] == 0xA9 || // PS (#x2029) + // is_z: + b[i] == 0) +} + +// Check if the character is a line break, space, or NUL. +func is_spacez(b []byte, i int) bool { + //return is_space(b, i) || is_breakz(b, i) + return ( + // is_space: + b[i] == ' ' || + // is_breakz: + b[i] == '\r' || // CR (#xD) + b[i] == '\n' || // LF (#xA) + b[i] == 0xC2 && b[i+1] == 0x85 || // NEL (#x85) + b[i] == 0xE2 && b[i+1] == 0x80 && b[i+2] == 0xA8 || // LS (#x2028) + b[i] == 0xE2 && b[i+1] == 0x80 && b[i+2] == 0xA9 || // PS (#x2029) + b[i] == 0) +} + +// Check if the character is a line break, space, tab, or NUL. +func is_blankz(b []byte, i int) bool { + //return is_blank(b, i) || is_breakz(b, i) + return ( + // is_blank: + b[i] == ' ' || b[i] == '\t' || + // is_breakz: + b[i] == '\r' || // CR (#xD) + b[i] == '\n' || // LF (#xA) + b[i] == 0xC2 && b[i+1] == 0x85 || // NEL (#x85) + b[i] == 0xE2 && b[i+1] == 0x80 && b[i+2] == 0xA8 || // LS (#x2028) + b[i] == 0xE2 && b[i+1] == 0x80 && b[i+2] == 0xA9 || // PS (#x2029) + b[i] == 0) +} + +// Determine the width of the character. +func width(b byte) int { + // Don't replace these by a switch without first + // confirming that it is being inlined. + if b&0x80 == 0x00 { + return 1 + } + if b&0xE0 == 0xC0 { + return 2 + } + if b&0xF0 == 0xE0 { + return 3 + } + if b&0xF8 == 0xF0 { + return 4 + } + return 0 + +} diff --git a/mcp/dsx-exchange-mcp/vendor/modules.txt b/mcp/dsx-exchange-mcp/vendor/modules.txt new file mode 100644 index 0000000..a0d3b00 --- /dev/null +++ b/mcp/dsx-exchange-mcp/vendor/modules.txt @@ -0,0 +1,57 @@ +# github.com/eclipse/paho.mqtt.golang v1.5.1 +## explicit; go 1.24.0 +github.com/eclipse/paho.mqtt.golang +github.com/eclipse/paho.mqtt.golang/packets +# github.com/google/jsonschema-go v0.4.2 +## explicit; go 1.23.0 +github.com/google/jsonschema-go/jsonschema +# github.com/gorilla/websocket v1.5.3 +## explicit; go 1.12 +github.com/gorilla/websocket +# github.com/modelcontextprotocol/go-sdk v1.4.0 +## explicit; go 1.24.0 +github.com/modelcontextprotocol/go-sdk/auth +github.com/modelcontextprotocol/go-sdk/internal/json +github.com/modelcontextprotocol/go-sdk/internal/jsonrpc2 +github.com/modelcontextprotocol/go-sdk/internal/mcpgodebug +github.com/modelcontextprotocol/go-sdk/internal/util +github.com/modelcontextprotocol/go-sdk/internal/xcontext +github.com/modelcontextprotocol/go-sdk/jsonrpc +github.com/modelcontextprotocol/go-sdk/mcp +github.com/modelcontextprotocol/go-sdk/oauthex +# github.com/segmentio/asm v1.1.3 +## explicit; go 1.17 +github.com/segmentio/asm/ascii +github.com/segmentio/asm/base64 +github.com/segmentio/asm/cpu +github.com/segmentio/asm/cpu/arm +github.com/segmentio/asm/cpu/arm64 +github.com/segmentio/asm/cpu/cpuid +github.com/segmentio/asm/cpu/x86 +github.com/segmentio/asm/internal/unsafebytes +github.com/segmentio/asm/keyset +# github.com/segmentio/encoding v0.5.3 +## explicit; go 1.23 +github.com/segmentio/encoding/ascii +github.com/segmentio/encoding/iso8601 +github.com/segmentio/encoding/json +# github.com/yosida95/uritemplate/v3 v3.0.2 +## explicit; go 1.14 +github.com/yosida95/uritemplate/v3 +# golang.org/x/net v0.44.0 +## explicit; go 1.24.0 +golang.org/x/net/internal/socks +golang.org/x/net/proxy +# golang.org/x/oauth2 v0.34.0 +## explicit; go 1.24.0 +golang.org/x/oauth2 +golang.org/x/oauth2/internal +# golang.org/x/sync v0.17.0 +## explicit; go 1.24.0 +golang.org/x/sync/semaphore +# golang.org/x/sys v0.40.0 +## explicit; go 1.24.0 +golang.org/x/sys/cpu +# gopkg.in/yaml.v3 v3.0.1 +## explicit +gopkg.in/yaml.v3 From 589dd60a57278f3cf70f5a9b9f56ac9b71b036e2 Mon Sep 17 00:00:00 2001 From: Daniyal Rana Date: Tue, 9 Jun 2026 10:53:35 -0700 Subject: [PATCH 02/27] feat(mcp): harden dsx exchange mcp v1 Add bounded MQTT admission controls, pod-local watch status aggregation, load validation tooling, and concise upstream docs for the DSX Exchange MCP server. Validation: go test -mod=vendor ./...; go vet -mod=vendor ./...; go build server/load binaries; helm lint/template; bash -n load wrapper; git diff --check. Signed-off-by: Daniyal Rana --- mcp/dsx-exchange-mcp/.gitignore | 1 + mcp/dsx-exchange-mcp/Dockerfile.load | 19 + mcp/dsx-exchange-mcp/Makefile | 9 +- mcp/dsx-exchange-mcp/README.md | 171 +- .../cmd/dsx-exchange-mcp-load/main.go | 1932 +++++++++++++++++ .../cmd/dsx-exchange-mcp-load/main_test.go | 385 ++++ .../cmd/dsx-exchange-mcp/main.go | 34 +- .../templates/deployment.yaml | 4 + .../deploy/helm/dsx-exchange-mcp/values.yaml | 3 + ...gatewaybackend-stateful-routing-patch.yaml | 9 + .../loadtest/gateway-high-rate-values.yaml | 10 + .../gateway-ratelimit-1000-configmap.yaml | 16 + .../gateway-ratelimit-5000-configmap.yaml | 16 + .../loadtest/run-kind-load-experiment.sh | 390 ++++ mcp/dsx-exchange-mcp/docs/current-v1-scope.md | 103 + mcp/dsx-exchange-mcp/docs/load-testing.md | 231 ++ mcp/dsx-exchange-mcp/internal/auth/context.go | 36 +- .../internal/auth/context_test.go | 23 + .../internal/metrics/metrics.go | 111 +- .../internal/metrics/metrics_test.go | 60 + .../internal/mqttbus/client.go | 22 +- .../internal/server/admission.go | 37 + .../internal/server/e2e_test.go | 418 ++++ .../internal/server/llm_eval_test.go | 13 +- .../internal/server/server.go | 69 +- .../testdata/tool_call_expectations.json | 27 + mcp/dsx-exchange-mcp/internal/server/tools.go | 76 +- .../internal/server/tools_test.go | 66 +- mcp/dsx-exchange-mcp/internal/server/watch.go | 287 ++- .../internal/server/watch_test.go | 176 ++ .../internal/server/watch_tools.go | 25 +- 31 files changed, 4640 insertions(+), 139 deletions(-) create mode 100644 mcp/dsx-exchange-mcp/Dockerfile.load create mode 100644 mcp/dsx-exchange-mcp/cmd/dsx-exchange-mcp-load/main.go create mode 100644 mcp/dsx-exchange-mcp/cmd/dsx-exchange-mcp-load/main_test.go create mode 100644 mcp/dsx-exchange-mcp/deploy/loadtest/agentgatewaybackend-stateful-routing-patch.yaml create mode 100644 mcp/dsx-exchange-mcp/deploy/loadtest/gateway-high-rate-values.yaml create mode 100644 mcp/dsx-exchange-mcp/deploy/loadtest/gateway-ratelimit-1000-configmap.yaml create mode 100644 mcp/dsx-exchange-mcp/deploy/loadtest/gateway-ratelimit-5000-configmap.yaml create mode 100755 mcp/dsx-exchange-mcp/deploy/loadtest/run-kind-load-experiment.sh create mode 100644 mcp/dsx-exchange-mcp/docs/current-v1-scope.md create mode 100644 mcp/dsx-exchange-mcp/docs/load-testing.md create mode 100644 mcp/dsx-exchange-mcp/internal/metrics/metrics_test.go create mode 100644 mcp/dsx-exchange-mcp/internal/server/admission.go diff --git a/mcp/dsx-exchange-mcp/.gitignore b/mcp/dsx-exchange-mcp/.gitignore index db13740..31d0004 100644 --- a/mcp/dsx-exchange-mcp/.gitignore +++ b/mcp/dsx-exchange-mcp/.gitignore @@ -1,4 +1,5 @@ /bin/ +/reports/ *.test *.out .env diff --git a/mcp/dsx-exchange-mcp/Dockerfile.load b/mcp/dsx-exchange-mcp/Dockerfile.load new file mode 100644 index 0000000..2fdb0d5 --- /dev/null +++ b/mcp/dsx-exchange-mcp/Dockerfile.load @@ -0,0 +1,19 @@ +# Copyright 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +ARG BUILDER_IMG=golang +ARG BUILDER_TAG=1.25.5 +ARG FINAL_IMG=gcr.io/distroless/static-debian12 +ARG FINAL_TAG=nonroot + +FROM ${BUILDER_IMG}:${BUILDER_TAG} AS build +WORKDIR /src +COPY . . +RUN --mount=type=cache,target=/go/pkg/mod \ + --mount=type=cache,target=/root/.cache/go-build \ + CGO_ENABLED=0 GOOS=linux go build -trimpath -ldflags="-s -w" -mod=vendor -o /out/dsx-exchange-mcp-load ./cmd/dsx-exchange-mcp-load + +FROM ${FINAL_IMG}:${FINAL_TAG} +COPY --from=build /out/dsx-exchange-mcp-load /dsx-exchange-mcp-load +USER nonroot:nonroot +ENTRYPOINT ["/dsx-exchange-mcp-load"] diff --git a/mcp/dsx-exchange-mcp/Makefile b/mcp/dsx-exchange-mcp/Makefile index 024d60f..f5bc8a0 100644 --- a/mcp/dsx-exchange-mcp/Makefile +++ b/mcp/dsx-exchange-mcp/Makefile @@ -2,15 +2,19 @@ # SPDX-License-Identifier: Apache-2.0 BINARY := dsx-exchange-mcp +LOAD_BINARY := dsx-exchange-mcp-load PKG := github.com/NVIDIA/dsx-exchange/mcp/dsx-exchange-mcp SCHEMA_SRC ?= ../../schemas GOFLAGS ?= -mod=vendor -.PHONY: build run test tidy vendor lint sync-specs verify-specs image clean +.PHONY: build build-load run test tidy vendor lint sync-specs verify-specs image load-image clean build: go build $(GOFLAGS) -o bin/$(BINARY) ./cmd/$(BINARY) +build-load: + go build $(GOFLAGS) -o bin/$(LOAD_BINARY) ./cmd/$(LOAD_BINARY) + run: sync-specs build ./bin/$(BINARY) @@ -38,5 +42,8 @@ verify-specs: sync-specs image: docker build -t $(BINARY):dev . +load-image: + docker build -f Dockerfile.load -t $(LOAD_BINARY):dev . + clean: rm -rf bin diff --git a/mcp/dsx-exchange-mcp/README.md b/mcp/dsx-exchange-mcp/README.md index 3232d20..0347e13 100644 --- a/mcp/dsx-exchange-mcp/README.md +++ b/mcp/dsx-exchange-mcp/README.md @@ -13,19 +13,54 @@ Runs as one of the upstream MCP servers behind the Latinum MCP Gateway - `dsx-exchange://specs/{domain}` — raw YAML for one domain (e.g. `bms`, `nico`, `power-management`, `spiffe-exchange`) -**Tools** — read-only MQTT against the DSX Event Bus: +**Tools** — schema discovery plus read-only MQTT against the DSX Event Bus: + +Schema discovery tools do not connect to MQTT. They inspect the embedded +AsyncAPI bundle so the client can choose a valid topic before touching the +broker: + +- `dsx_exchange_find_topics(query, domain, limit)` — search the embedded + AsyncAPI index for relevant Exchange topics before choosing a concrete + broker read. - `dsx_exchange_describe_topic(topic_filter)` — parse embedded AsyncAPI specs and describe the matching schema channel, payload shape, retained/live behavior, examples, and related metadata/value topics. + +Bounded MQTT tools create a short-lived broker connection for one request and +return within configured message, duration, and byte limits: + - `dsx_exchange_subscribe(topic_filter, max_messages, max_duration_s)` — subscribe and collect messages over a window. Use this for live values. - `dsx_exchange_read_retained(topic_filter, max_messages)` — drain retained messages currently held by the broker. Use this for metadata; BMS values are not retained (republished on change every ~100 s). +Background watch tools are the v1 stand-in for long MQTT subscriptions. They +start a pod-local MQTT watch, then let the client poll status or bounded raw +buffer reads instead of blocking one MCP request for a long time: + +- `dsx_exchange_start_subscription(topic_filter|selector, ttl_seconds, ...)` — + start a pod-local background MQTT watch and return a `subscription_id`. +- `dsx_exchange_read_subscription(subscription_id, cursor, max_messages, max_bytes)` — + read a bounded raw batch from the watch buffer for detail/debug use. +- `dsx_exchange_subscription_status(subscription_id)` — inspect watch status, + counters, bounded per-topic update summaries, watermarks, expiry, and last + error. +- `dsx_exchange_stop_subscription(subscription_id)` — stop a watch and release + its local buffer. + Topic filters use standard MQTT wildcards: `+` (single level), `#` (multi-level, end of filter only). +Why background watches exist: MCP tool calls are fundamentally request/response. +A long MQTT subscription inside one tool call can tie up the MCP client while it +waits for stream data, which is a poor fit for sparse or ongoing telemetry. +MCP Tasks may eventually be a cleaner protocol-level answer, but that feature is +still experimental. The current watch tools provide a bounded, explicit v1 +pattern: start the MQTT work, return quickly, poll `subscription_status` for +aggregated updates, and stop the watch when done. Watches remain pod-local, +TTL-limited, buffer-limited, and session-pinned. + ## Auth The server holds **no credentials of its own**. The caller's JWT flows through @@ -58,11 +93,24 @@ schemas/ generated embedded copy of monorepo root schemas/ ## Build & run ```sh +cd mcp/dsx-exchange-mcp make sync-specs # copies ../../schemas/ into ./schemas +make test make build make run # listens on :8080, expects NATS at tcp://nats:1883 ``` +Images: + +```sh +make image # builds dsx-exchange-mcp:dev +make load-image # builds dsx-exchange-mcp-load:dev +``` + +Run `make sync-specs` before building the server binary or image when the +monorepo `schemas/` tree has changed. The image uses the already-synced +`./schemas` tree and does not fetch schemas at runtime. + Environment: | Var | Default | Notes | @@ -70,6 +118,8 @@ Environment: | `MCP_ADDR` | `:8080` | listener for `/mcp` (Streamable HTTP) | | `NATS_URL` | `tcp://nats:1883` | MQTT 3.1.1 facade on the NATS broker | | `MQTT_USERNAME` | `oauthtoken` | MQTT username for OAuth2 bearer auth | +| `MQTT_CONNECT_TIMEOUT_S` | `5` | timeout for MQTT CONNECT | +| `MQTT_SUBSCRIBE_TIMEOUT_S` | `5` | timeout for MQTT SUBSCRIBE | | `MQTT_TLS_CA_FILE` | (unset) | optional root CA bundle for private broker CA | | `MQTT_TLS_SERVER_NAME` | (unset) | optional TLS server name override | | `MQTT_TLS_INSECURE_SKIP_VERIFY` | `false` | local-dev only; rejected by Helm unless acknowledged | @@ -78,6 +128,18 @@ Environment: | `MCP_DEFAULT_MAX_DURATION_S` | `30` | default subscribe window | | `MCP_MAX_DURATION_S` | `30` | hard subscribe window cap | | `MQTT_MAX_RESULT_BYTES` | `1048576` | max returned topic+payload bytes | +| `MCP_MQTT_COLLECT_MAX_CONCURRENT_PER_POD` | `100` | per-pod admission limit for bounded MQTT collectors | +| `MCP_MQTT_WATCH_START_MAX_CONCURRENT_PER_POD` | `500` | per-pod admission limit for watch start MQTT setup | +| `MCP_WATCH_DEFAULT_TTL_S` | `300` | default background watch TTL | +| `MCP_WATCH_MAX_TTL_S` | `900` | hard background watch TTL cap | +| `MCP_WATCH_DEFAULT_BUFFER_MESSAGES` | `100` | default watch ring-buffer message cap | +| `MCP_WATCH_MAX_BUFFER_MESSAGES` | `1000` | hard watch ring-buffer message cap | +| `MCP_WATCH_DEFAULT_BUFFER_BYTES` | `262144` | default watch ring-buffer byte cap | +| `MCP_WATCH_MAX_BUFFER_BYTES` | `1048576` | hard watch ring-buffer byte cap | +| `MCP_WATCH_MAX_PER_SESSION` | `10` | active background watch cap per MCP session | +| `MCP_WATCH_MAX_PER_POD` | `1000` | active background watch cap per pod | +| `MCP_FIND_TOPICS_DEFAULT_LIMIT` | `20` | default schema search result cap | +| `MCP_FIND_TOPICS_MAX_LIMIT` | `100` | hard schema search result cap | | `LOG_FORMAT` | `json` | structured logs | | `OTEL_EXPORTER_OTLP_ENDPOINT` | (unset) | reserved for future OTLP push export; scrape `/metrics` today | @@ -133,45 +195,75 @@ The derived gateway target name is `dsx-exchange-mcp-mcp`, so tools are prefixed as `dsx-exchange-mcp-mcp_dsx_exchange_subscribe` in multi-upstream gateway deployments. -## E2E against deployed bus +## Using it locally or behind the gateway -Do not deploy a local NATS/event bus for this path. Gate deployed-bus tests -behind explicit environment variables and point to the shared dev bus: +For a local backend-only loop, run the MCP server with a broker URL, MQTT +username, and any required broker CA trust. The MCP client must provide a bearer +token in the `Authorization` header for broker-backed tools. -Stage 1 tests the MQTT bridge directly: +For the production-style path, put this server behind the Latinum MCP Gateway +and verify the setup checklist below. -```sh -RUN_EXCHANGE_E2E_DEPLOYED_BUS=1 \ -DSX_EXCHANGE_MQTT_URL=tls://event-bus-ytl-dev2.dev.dsx.nvidia.com:1883 \ -DSX_EXCHANGE_MQTT_USERNAME=oauth \ -DSX_EXCHANGE_MQTT_CA_FILE=/path/to/root-ca.crt \ -DSX_EXCHANGE_MQTT_SERVER_NAME=event-bus-ytl-dev2.dev.dsx.nvidia.com \ -DSX_EXCHANGE_E2E_BEARER="$TOKEN" \ -DSX_EXCHANGE_E2E_ALLOWED_TOPIC='...' \ -DSX_EXCHANGE_E2E_DENIED_TOPIC='...' \ -go test ./... -``` +## Setup checklist -Stage 2 tests the MCP protocol path through either this server directly or the -gateway. Run this after the server is configured with the same deployed-bus -`NATS_URL`/TLS settings: +Before an MCP client or load test can call broker-backed tools, verify: -```sh -RUN_EXCHANGE_MCP_E2E=1 \ -DSX_EXCHANGE_MCP_URL=http://localhost:8080/mcp \ -DSX_EXCHANGE_E2E_BEARER="$TOKEN" \ -DSX_EXCHANGE_E2E_ALLOWED_TOPIC='...' \ -DSX_EXCHANGE_E2E_DENIED_TOPIC='...' \ -go test ./internal/server -run TestStagedMCPE2EDeployedBus -count=1 -v -``` +| Item | What the operator provides | Where this MCP expects it | +| --- | --- | --- | +| Gateway route | A reachable Latinum MCP Gateway `/mcp` endpoint | `DSX_EXCHANGE_MCP_URL` for tests/tools | +| Stateful routing | Gateway routes the same `Mcp-Session-Id` to the same backend pod | Required for `start/read/status/stop_subscription` | +| Broker endpoint | MQTT endpoint for the DSX Event Bus | Helm `natsURL`, runtime `NATS_URL` | +| Broker username | OAuth profile username for MQTT CONNECT | Helm `mqtt.username`, runtime `MQTT_USERNAME` | +| Broker CA | Root/intermediate CA bundle for broker TLS | Secret referenced by `mqtt.tls.caCertSecret.name/key` | +| TLS server name | Broker certificate server name, if needed | Helm `mqtt.tls.serverName`, runtime `MQTT_TLS_SERVER_NAME` | +| Caller JWT | Fresh user/service bearer from approved secret manager flow | MCP `Authorization: Bearer ...`; load secret key `bearer` | +| Allowed topics | Topics the caller JWT is authorized to read | E2E/load env topic inputs | + +If schema tools work but broker-backed tools return auth or subscribe errors, +debug in this order: bearer freshness, broker CA trust, broker URL/server name, +topic ACLs, then gateway bearer passthrough. + +Do not commit bearer tokens, CA files, cluster snapshots, or environment-specific +broker/gateway endpoints. + +## E2E against deployed bus -When running through the gateway, set `DSX_EXCHANGE_MCP_URL` to the gateway -`/mcp` endpoint. If the gateway prefixes tools, either let the test discover the -`*_dsx_exchange_subscribe` tool or set `DSX_EXCHANGE_E2E_TOOL_NAME` explicitly. +Deployed-bus tests are opt-in because they require external broker, gateway, +JWT, topic, and CA setup. Stage 1 tests the MQTT bridge directly. Stage 2 tests +the MCP protocol path through either this server directly or the gateway. When +running through the gateway, point `DSX_EXCHANGE_MCP_URL` at the gateway `/mcp` +endpoint. If the gateway prefixes tools, either let the test discover the +`*_dsx_exchange_subscribe` tool or set `DSX_EXCHANGE_E2E_TOOL_NAME`. Never commit bearer tokens, CA material, or topic names that are environment specific or sensitive. +Validation ladder: + +```sh +# Direct MQTT bridge to the deployed broker. +RUN_EXCHANGE_E2E_DEPLOYED_BUS=1 go test -mod=vendor ./internal/mqttbus -run TestDeployedBusE2E + +# MCP schema/tool path through a direct backend or gateway /mcp endpoint. +RUN_EXCHANGE_MCP_SCHEMA_E2E=1 go test -mod=vendor ./internal/server -run TestStagedMCPSchemaDescribeThroughEndpoint + +# MCP bounded broker-backed tool path. +RUN_EXCHANGE_MCP_E2E=1 go test -mod=vendor ./internal/server -run TestStagedMCPE2EDeployedBus + +# MCP async watch start/read/status/stop path. +RUN_EXCHANGE_MCP_WATCH_E2E=1 go test -mod=vendor ./internal/server -run TestStagedMCPWatchThroughEndpoint + +# Curated prompt-to-tool fixture replay through the endpoint. +RUN_EXCHANGE_MCP_QUALITY_E2E=1 go test -mod=vendor ./internal/server -run TestStagedMCPQualityFixturesThroughEndpoint +``` + +Required environment for the staged MCP tests is the setup checklist above plus +`DSX_EXCHANGE_MCP_URL`, `DSX_EXCHANGE_E2E_BEARER`, and the allowed topic inputs +used by the selected test. For direct MQTT tests, provide the broker URL, +username if non-default, CA/server-name settings, bearer, and allowed/denied +topic inputs through the `DSX_EXCHANGE_MQTT_*` and `DSX_EXCHANGE_E2E_*` +environment variables. + ## Local LLM prompt eval `TestLocalLLMMCPPromptEval` is an opt-in local harness that runs fixture prompts @@ -180,21 +272,34 @@ logs the tool trace, and compares the model's final tool plan with `internal/server/testdata/tool_call_expectations.json`. For the gateway path, set `DSX_EXCHANGE_MCP_URL` to the Latinum MCP Gateway -`/mcp` endpoint, for example `http://localhost:18180/mcp`. If it is unset, the -test starts an in-process MCP server. +`/mcp` endpoint. If it is unset, the test starts an in-process MCP server. See `docs/local-llm-mcp-eval.md`. +## Quality and load validation + +`cmd/dsx-exchange-mcp-load` creates many MCP sessions and records JSON, text, +and CSV reports with per-operation latency and error attribution. Prompt quality +is covered by fixture-based Go tests and the opt-in local LLM eval. + +Use `docs/load-testing.md` for load-test scenarios, reproduction requirements, +and summarized findings from the current branch. Raw report bundles are local +evidence and should stay under ignored `reports/`. + ## Status Alpha. Populated specs load and surface as resources when synced into the embedded bundle. The MQTT tools use paho v3 and pass OAuth2 bearer credentials to the broker as `username=`, `password=`. Broker-side -auth-callout remains the source of truth for topic ACLs. +auth-callout remains the source of truth for topic ACLs. Background watches are +pod-local, session-pinned, and intentionally limited to start/read/status/stop +for v1. ## References - Latinum MCP Gateway SDD — `context/Latinum MCP Gateway - SDD (1).pdf` - Schema repo — `gitlab-master.nvidia.com/ncp/dsx/event-bus/schema` +- Current v1 scope — `docs/current-v1-scope.md` +- Load validation findings — `docs/load-testing.md` - MCP spec — https://modelcontextprotocol.io/specification/2025-06-18/ - Go SDK — https://github.com/modelcontextprotocol/go-sdk diff --git a/mcp/dsx-exchange-mcp/cmd/dsx-exchange-mcp-load/main.go b/mcp/dsx-exchange-mcp/cmd/dsx-exchange-mcp-load/main.go new file mode 100644 index 0000000..5535607 --- /dev/null +++ b/mcp/dsx-exchange-mcp/cmd/dsx-exchange-mcp-load/main.go @@ -0,0 +1,1932 @@ +// Copyright 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +package main + +import ( + "bytes" + "context" + "crypto/sha256" + "encoding/base64" + "encoding/csv" + "encoding/json" + "errors" + "flag" + "fmt" + "io" + "math/rand" + "net/http" + "os" + "path/filepath" + "sort" + "strconv" + "strings" + "sync" + "time" +) + +const ( + toolSubscribe = "dsx_exchange_subscribe" + toolReadRetained = "dsx_exchange_read_retained" + toolDescribeTopic = "dsx_exchange_describe_topic" + toolFindTopics = "dsx_exchange_find_topics" + toolStartSubscription = "dsx_exchange_start_subscription" + toolReadSubscription = "dsx_exchange_read_subscription" + toolStatusSubscription = "dsx_exchange_subscription_status" + toolStopSubscription = "dsx_exchange_stop_subscription" + + maxErrorSamples = 5 +) + +type config struct { + endpoint string + bearer string + experiment string + experimentDetail string + scenario string + sessions int + sessionSweep string + backendReplicas int + stickySessionCheck string + duration time.Duration + startupRamp time.Duration + pollInterval time.Duration + rateLimit int + gatewayRateLimit int + manifestName string + backendImageID string + loadImageID string + topic string + retainedTopic string + deniedTopic string + subscribeDuration int + maxMessages int + maxBytes int + watchTTL int + httpTimeout time.Duration + backendConnectS int + backendSubscribeS int + backendCollectMax int + backendWatchMax int + reportDir string +} + +type mcpClient struct { + endpoint string + bearer string + httpc *http.Client + nextID int + limiter *rateLimiter +} + +type rpcResponse struct { + Result json.RawMessage `json:"result"` + Error *rpcError `json:"error"` +} + +type rpcError struct { + Code int `json:"code"` + Message string `json:"message"` +} + +type toolCallResult struct { + IsError bool `json:"isError"` + Content []struct { + Type string `json:"type"` + Text string `json:"text"` + } `json:"content"` +} + +type runReport struct { + StartedAt time.Time `json:"started_at"` + EndedAt time.Time `json:"ended_at"` + DurationSeconds float64 `json:"duration_seconds"` + ThroughputRPS float64 `json:"throughput_requests_per_second"` + SuccessRate float64 `json:"success_rate_percent"` + Endpoint string `json:"endpoint"` + Experiment string `json:"experiment,omitempty"` + ExperimentDetail string `json:"experiment_detail,omitempty"` + Scenario string `json:"scenario"` + Sessions int `json:"sessions"` + BackendReplicas int `json:"backend_replicas,omitempty"` + StickySessionCheck string `json:"sticky_session_check,omitempty"` + RateLimit int `json:"rate_limit_per_second,omitempty"` + GatewayRateLimit int `json:"gateway_rate_limit_rps,omitempty"` + ManifestName string `json:"manifest_name,omitempty"` + BackendImageID string `json:"backend_image_id,omitempty"` + LoadImageID string `json:"load_image_id,omitempty"` + ExperimentConfigHash string `json:"experiment_config_hash,omitempty"` + TokenTTLSecondsAtStart int `json:"token_ttl_seconds_at_start,omitempty"` + Topic string `json:"topic"` + RetainedTopic string `json:"retained_topic"` + DeniedTopic string `json:"denied_topic,omitempty"` + HTTPTimeoutSeconds float64 `json:"http_timeout_seconds"` + StartupRampSeconds float64 `json:"startup_ramp_seconds,omitempty"` + PollIntervalSeconds float64 `json:"poll_interval_seconds,omitempty"` + SubscribeDurationS int `json:"subscribe_duration_seconds"` + MaxMessages int `json:"max_messages"` + MaxBytes int `json:"max_bytes"` + WatchTTLS int `json:"watch_ttl_seconds"` + BackendConnectS int `json:"backend_mqtt_connect_timeout_seconds,omitempty"` + BackendSubscribeS int `json:"backend_mqtt_subscribe_timeout_seconds,omitempty"` + BackendCollectMax int `json:"backend_mqtt_collect_max_concurrent_per_pod,omitempty"` + BackendWatchStartMax int `json:"backend_mqtt_watch_start_max_concurrent_per_pod,omitempty"` + TotalRequests uint64 `json:"total_requests"` + Successes uint64 `json:"successes"` + Failures uint64 `json:"failures"` + ExpectedToolErrors uint64 `json:"expected_tool_errors"` + InitializedSessions uint64 `json:"initialized_sessions"` + StartedWatches uint64 `json:"started_watches"` + StoppedWatches uint64 `json:"stopped_watches"` + SessionNotFoundErrors uint64 `json:"session_not_found_errors,omitempty"` + SubscriptionNotFoundErrors uint64 `json:"subscription_not_found_errors,omitempty"` + ByOperation map[string]operationSnapshot `json:"by_operation"` + Errors map[string]uint64 `json:"errors"` + ErrorSamples map[string][]string `json:"error_samples,omitempty"` +} + +type operationSnapshot struct { + Phase string `json:"phase"` + Count uint64 `json:"count"` + Successes uint64 `json:"successes"` + Failures uint64 `json:"failures"` + P50Milliseconds float64 `json:"p50_ms"` + P95Milliseconds float64 `json:"p95_ms"` + P99Milliseconds float64 `json:"p99_ms"` + Errors map[string]uint64 `json:"errors,omitempty"` +} + +type operationStats struct { + count uint64 + successes uint64 + failures uint64 + latencies []time.Duration + errors map[string]uint64 +} + +type recorder struct { + mu sync.Mutex + startedAt time.Time + endpoint string + experiment string + experimentDetail string + scenario string + sessions int + backendReplicas int + stickySessionCheck string + rateLimit int + gatewayRateLimit int + manifestName string + backendImageID string + loadImageID string + experimentConfigHash string + tokenTTLSecondsAtStart int + topic string + retainedTopic string + deniedTopic string + httpTimeout time.Duration + startupRamp time.Duration + pollInterval time.Duration + subscribeDuration int + maxMessages int + maxBytes int + watchTTL int + backendConnectS int + backendSubscribeS int + backendCollectMax int + backendWatchStartMax int + totalRequests uint64 + successes uint64 + failures uint64 + expectedToolErrors uint64 + initializedSessions uint64 + startedWatches uint64 + stoppedWatches uint64 + sessionNotFoundErrors uint64 + subscriptionNotFoundErrors uint64 + byOperation map[string]*operationStats + errors map[string]uint64 + errorSamples map[string][]string +} + +type rateLimiter struct { + ch <-chan struct{} +} + +func main() { + cfg := parseConfig() + sessionCounts, err := parseSessionCounts(cfg) + if err != nil { + fmt.Fprintf(os.Stderr, "configuration error: %v\n", err) + os.Exit(2) + } + + reports := make([]runReport, 0, len(sessionCounts)) + failed := false + for _, sessions := range sessionCounts { + runCfg := cfg + runCfg.sessions = sessions + if err := validateConfig(runCfg); err != nil { + fmt.Fprintf(os.Stderr, "configuration error: %v\n", err) + os.Exit(2) + } + report := runLoad(runCfg) + printTextReport(os.Stderr, report) + reports = append(reports, report) + if report.Failures > 0 { + failed = true + } + } + + enc := json.NewEncoder(os.Stdout) + enc.SetIndent("", " ") + if len(reports) == 1 { + err = enc.Encode(reports[0]) + } else { + err = enc.Encode(reports) + } + if err != nil { + fmt.Fprintf(os.Stderr, "write report JSON: %v\n", err) + os.Exit(1) + } + if cfg.reportDir != "" { + if err := writeReports(cfg.reportDir, reports); err != nil { + fmt.Fprintf(os.Stderr, "write report files: %v\n", err) + os.Exit(1) + } + } + if failed { + os.Exit(1) + } +} + +func runLoad(cfg config) runReport { + if err := validateConfig(cfg); err != nil { + fmt.Fprintf(os.Stderr, "configuration error: %v\n", err) + os.Exit(2) + } + + limiter := newRateLimiter(cfg.rateLimit) + rec := &recorder{ + startedAt: time.Now().UTC(), + endpoint: cfg.endpoint, + experiment: cfg.experiment, + experimentDetail: cfg.experimentDetail, + scenario: cfg.scenario, + sessions: cfg.sessions, + backendReplicas: cfg.backendReplicas, + stickySessionCheck: cfg.stickySessionCheck, + rateLimit: cfg.rateLimit, + gatewayRateLimit: cfg.gatewayRateLimit, + manifestName: cfg.manifestName, + backendImageID: cfg.backendImageID, + loadImageID: cfg.loadImageID, + experimentConfigHash: experimentConfigHash(cfg), + tokenTTLSecondsAtStart: tokenTTLSeconds(cfg.bearer), + topic: cfg.topic, + retainedTopic: cfg.retainedTopic, + deniedTopic: cfg.deniedTopic, + httpTimeout: cfg.httpTimeout, + startupRamp: cfg.startupRamp, + pollInterval: effectivePollInterval(cfg), + subscribeDuration: cfg.subscribeDuration, + maxMessages: cfg.maxMessages, + maxBytes: cfg.maxBytes, + watchTTL: cfg.watchTTL, + backendConnectS: cfg.backendConnectS, + backendSubscribeS: cfg.backendSubscribeS, + backendCollectMax: cfg.backendCollectMax, + backendWatchStartMax: cfg.backendWatchMax, + byOperation: map[string]*operationStats{}, + errors: map[string]uint64{}, + errorSamples: map[string][]string{}, + } + + ctx, cancel := context.WithTimeout(context.Background(), cfg.duration) + defer cancel() + + var wg sync.WaitGroup + for i := 0; i < cfg.sessions; i++ { + wg.Add(1) + go func(sessionIndex int) { + defer wg.Done() + if !waitStartupRamp(ctx, cfg.startupRamp, sessionIndex, cfg.sessions) { + return + } + client := &mcpClient{ + endpoint: cfg.endpoint, + bearer: cfg.bearer, + httpc: &http.Client{Timeout: cfg.httpTimeout}, + limiter: limiter, + } + runSession(ctx, cfg, rec, client, sessionIndex) + }(i) + } + wg.Wait() + + report := rec.snapshot(time.Now().UTC()) + return report +} + +func parseConfig() config { + cfg := config{} + flag.StringVar(&cfg.endpoint, "endpoint", env("DSX_EXCHANGE_MCP_URL", ""), "MCP endpoint URL") + flag.StringVar(&cfg.bearer, "bearer", firstEnv("DSX_EXCHANGE_E2E_BEARER", "DSX_EXCHANGE_BEARER"), "Bearer token for MCP Authorization") + flag.StringVar(&cfg.experiment, "experiment", env("DSX_EXCHANGE_MCP_LOAD_EXPERIMENT", ""), "experiment label recorded in JSON/CSV reports") + flag.StringVar(&cfg.experimentDetail, "experiment-detail", env("DSX_EXCHANGE_MCP_LOAD_EXPERIMENT_DETAIL", ""), "free-form experiment detail recorded in JSON/CSV reports") + flag.StringVar(&cfg.scenario, "scenario", env("DSX_EXCHANGE_MCP_LOAD_SCENARIO", "discovery"), "scenario: discovery, discovery-hold, schema-resources, bounded-read, watch, watch-hold, watch-status-hold, sticky-check, mixed") + flag.IntVar(&cfg.sessions, "sessions", envInt("DSX_EXCHANGE_MCP_LOAD_SESSIONS", 50), "concurrent MCP sessions") + flag.StringVar(&cfg.sessionSweep, "session-sweep", env("DSX_EXCHANGE_MCP_LOAD_SESSION_SWEEP", ""), "comma-separated concurrent MCP session counts; overrides -sessions when set") + flag.IntVar(&cfg.backendReplicas, "backend-replicas", envInt("DSX_EXCHANGE_MCP_LOAD_BACKEND_REPLICAS", 0), "metadata: MCP backend replica count for this experiment") + flag.StringVar(&cfg.stickySessionCheck, "sticky-session-check", env("DSX_EXCHANGE_MCP_LOAD_STICKY_SESSION_CHECK", ""), "metadata: sticky-session validation state such as not_run, planned, running, pass, or fail") + flag.DurationVar(&cfg.duration, "duration", envDuration("DSX_EXCHANGE_MCP_LOAD_DURATION", time.Minute), "load-test duration") + flag.DurationVar(&cfg.startupRamp, "startup-ramp", envDuration("DSX_EXCHANGE_MCP_LOAD_STARTUP_RAMP", 0), "spread MCP session startup across this duration; 0 starts all sessions immediately") + flag.DurationVar(&cfg.pollInterval, "poll-interval", envDuration("DSX_EXCHANGE_MCP_LOAD_POLL_INTERVAL", 0), "override watch/status poll interval; 0 uses scenario default") + flag.IntVar(&cfg.rateLimit, "rate-limit", envInt("DSX_EXCHANGE_MCP_LOAD_RATE_LIMIT", 0), "global request rate limit per second; 0 means unlimited") + flag.IntVar(&cfg.gatewayRateLimit, "gateway-rate-limit-rps", envInt("DSX_EXCHANGE_MCP_LOAD_GATEWAY_RATE_LIMIT_RPS", -1), "metadata: configured gateway tenant rate limit in requests per second; -1 uses -rate-limit") + flag.StringVar(&cfg.manifestName, "manifest-name", env("DSX_EXCHANGE_MCP_LOAD_MANIFEST_NAME", ""), "metadata: Kubernetes manifest or job name used for this run") + flag.StringVar(&cfg.backendImageID, "backend-image-id", env("DSX_EXCHANGE_MCP_LOAD_BACKEND_IMAGE_ID", ""), "metadata: backend image ID or digest used for this run") + flag.StringVar(&cfg.loadImageID, "load-image-id", env("DSX_EXCHANGE_MCP_LOAD_IMAGE_ID", ""), "metadata: load generator image ID or digest used for this run") + flag.StringVar(&cfg.topic, "topic", env("DSX_EXCHANGE_E2E_ALLOWED_TOPIC", "BMS/v1/PUB/Value/Rack/RackPower/#"), "allowed live topic filter") + flag.StringVar(&cfg.retainedTopic, "retained-topic", env("DSX_EXCHANGE_E2E_RETAINED_TOPIC", "BMS/v1/PUB/Metadata/Rack/RackPower/#"), "allowed retained metadata topic filter") + flag.StringVar(&cfg.deniedTopic, "denied-topic", env("DSX_EXCHANGE_E2E_DENIED_TOPIC", ""), "optional denied topic filter; expected to return MCP tool error") + flag.IntVar(&cfg.subscribeDuration, "subscribe-duration-s", envInt("DSX_EXCHANGE_MCP_LOAD_SUBSCRIBE_DURATION_S", 1), "bounded subscribe duration in seconds") + flag.IntVar(&cfg.maxMessages, "max-messages", envInt("DSX_EXCHANGE_MCP_LOAD_MAX_MESSAGES", 10), "max messages per read/subscribe call") + flag.IntVar(&cfg.maxBytes, "max-bytes", envInt("DSX_EXCHANGE_MCP_LOAD_MAX_BYTES", 32768), "max bytes per watch read") + flag.IntVar(&cfg.watchTTL, "watch-ttl-s", envInt("DSX_EXCHANGE_MCP_LOAD_WATCH_TTL_S", 30), "watch TTL in seconds") + flag.DurationVar(&cfg.httpTimeout, "http-timeout", envDuration("DSX_EXCHANGE_MCP_LOAD_HTTP_TIMEOUT", 30*time.Second), "HTTP request timeout") + flag.IntVar(&cfg.backendConnectS, "backend-mqtt-connect-timeout-s", envInt("DSX_EXCHANGE_MCP_LOAD_BACKEND_MQTT_CONNECT_TIMEOUT_S", 0), "metadata: backend MQTT connect timeout in seconds") + flag.IntVar(&cfg.backendSubscribeS, "backend-mqtt-subscribe-timeout-s", envInt("DSX_EXCHANGE_MCP_LOAD_BACKEND_MQTT_SUBSCRIBE_TIMEOUT_S", 0), "metadata: backend MQTT subscribe timeout in seconds") + flag.IntVar(&cfg.backendCollectMax, "backend-mqtt-collect-max-concurrent-per-pod", envInt("DSX_EXCHANGE_MCP_LOAD_BACKEND_MQTT_COLLECT_MAX_CONCURRENT_PER_POD", 0), "metadata: backend bounded MQTT tool admission limit per pod") + flag.IntVar(&cfg.backendWatchMax, "backend-mqtt-watch-start-max-concurrent-per-pod", envInt("DSX_EXCHANGE_MCP_LOAD_BACKEND_MQTT_WATCH_START_MAX_CONCURRENT_PER_POD", 0), "metadata: backend watch-start MQTT admission limit per pod") + flag.StringVar(&cfg.reportDir, "report-dir", env("DSX_EXCHANGE_MCP_LOAD_REPORT_DIR", ""), "optional directory for JSON, text, and CSV reports") + flag.Parse() + cfg.endpoint = strings.TrimSpace(cfg.endpoint) + cfg.bearer = strings.TrimSpace(cfg.bearer) + cfg.experiment = strings.TrimSpace(cfg.experiment) + cfg.experimentDetail = strings.TrimSpace(cfg.experimentDetail) + cfg.scenario = strings.TrimSpace(cfg.scenario) + cfg.stickySessionCheck = strings.TrimSpace(cfg.stickySessionCheck) + cfg.manifestName = strings.TrimSpace(cfg.manifestName) + cfg.backendImageID = strings.TrimSpace(cfg.backendImageID) + cfg.loadImageID = strings.TrimSpace(cfg.loadImageID) + cfg.topic = strings.TrimSpace(cfg.topic) + cfg.retainedTopic = strings.TrimSpace(cfg.retainedTopic) + cfg.deniedTopic = strings.TrimSpace(cfg.deniedTopic) + if cfg.gatewayRateLimit < 0 { + cfg.gatewayRateLimit = cfg.rateLimit + } + return cfg +} + +func validateConfig(cfg config) error { + if cfg.endpoint == "" { + return errors.New("-endpoint or DSX_EXCHANGE_MCP_URL is required") + } + if cfg.bearer == "" { + return errors.New("-bearer, DSX_EXCHANGE_E2E_BEARER, or DSX_EXCHANGE_BEARER is required") + } + if cfg.sessions <= 0 { + return errors.New("-sessions must be greater than zero") + } + if cfg.duration <= 0 { + return errors.New("-duration must be greater than zero") + } + if cfg.startupRamp < 0 { + return errors.New("-startup-ramp must be zero or greater") + } + if cfg.pollInterval < 0 { + return errors.New("-poll-interval must be zero or greater") + } + if cfg.gatewayRateLimit < 0 { + return errors.New("-gateway-rate-limit-rps must be zero or greater") + } + switch cfg.scenario { + case "discovery", "discovery-hold", "schema-resources", "bounded-read", "watch", "watch-hold", "watch-status-hold", "sticky-check", "mixed": + default: + return fmt.Errorf("unknown scenario %q", cfg.scenario) + } + if cfg.backendReplicas < 0 { + return errors.New("-backend-replicas must be zero or greater") + } + if cfg.topic == "" { + return errors.New("-topic is required") + } + if cfg.retainedTopic == "" { + return errors.New("-retained-topic is required") + } + if cfg.subscribeDuration <= 0 { + return errors.New("-subscribe-duration-s must be greater than zero") + } + if cfg.maxMessages <= 0 { + return errors.New("-max-messages must be greater than zero") + } + if cfg.maxBytes <= 0 { + return errors.New("-max-bytes must be greater than zero") + } + if cfg.watchTTL <= 0 { + return errors.New("-watch-ttl-s must be greater than zero") + } + if cfg.backendCollectMax < 0 { + return errors.New("-backend-mqtt-collect-max-concurrent-per-pod must be zero or greater") + } + if cfg.backendWatchMax < 0 { + return errors.New("-backend-mqtt-watch-start-max-concurrent-per-pod must be zero or greater") + } + return nil +} + +func parseSessionCounts(cfg config) ([]int, error) { + if strings.TrimSpace(cfg.sessionSweep) == "" { + return []int{cfg.sessions}, nil + } + parts := strings.Split(cfg.sessionSweep, ",") + out := make([]int, 0, len(parts)) + for _, part := range parts { + part = strings.TrimSpace(part) + if part == "" { + continue + } + n, err := strconv.Atoi(part) + if err != nil || n <= 0 { + return nil, fmt.Errorf("-session-sweep contains invalid session count %q", part) + } + out = append(out, n) + } + if len(out) == 0 { + return nil, errors.New("-session-sweep did not contain any session counts") + } + return out, nil +} + +func effectivePollInterval(cfg config) time.Duration { + if cfg.pollInterval > 0 { + return cfg.pollInterval + } + switch cfg.scenario { + case "sticky-check": + return 250 * time.Millisecond + case "watch-hold", "watch-status-hold": + return time.Second + default: + return 0 + } +} + +func waitStartupRamp(ctx context.Context, ramp time.Duration, sessionIndex, sessions int) bool { + if ramp <= 0 || sessionIndex <= 0 || sessions <= 1 { + return ctx.Err() == nil + } + delay := time.Duration(int64(ramp) * int64(sessionIndex) / int64(sessions)) + if delay <= 0 { + return ctx.Err() == nil + } + timer := time.NewTimer(delay) + defer timer.Stop() + select { + case <-ctx.Done(): + return false + case <-timer.C: + return true + } +} + +func experimentConfigHash(cfg config) string { + normalized := map[string]any{ + "endpoint": cfg.endpoint, + "experiment": cfg.experiment, + "experiment_detail": cfg.experimentDetail, + "scenario": cfg.scenario, + "sessions": cfg.sessions, + "session_sweep": cfg.sessionSweep, + "backend_replicas": cfg.backendReplicas, + "sticky_session_check": cfg.stickySessionCheck, + "duration": cfg.duration.String(), + "startup_ramp": cfg.startupRamp.String(), + "poll_interval": effectivePollInterval(cfg).String(), + "client_rate_limit_per_second": cfg.rateLimit, + "gateway_rate_limit_rps": cfg.gatewayRateLimit, + "topic": cfg.topic, + "retained_topic": cfg.retainedTopic, + "denied_topic": cfg.deniedTopic, + "subscribe_duration_seconds": cfg.subscribeDuration, + "max_messages": cfg.maxMessages, + "max_bytes": cfg.maxBytes, + "watch_ttl_seconds": cfg.watchTTL, + "http_timeout": cfg.httpTimeout.String(), + "backend_mqtt_connect_timeout_seconds": cfg.backendConnectS, + "backend_mqtt_subscribe_timeout_seconds": cfg.backendSubscribeS, + "backend_mqtt_collect_max_concurrent_per_pod": cfg.backendCollectMax, + "backend_mqtt_watch_start_max_concurrent_per_pod": cfg.backendWatchMax, + "manifest_name": cfg.manifestName, + "backend_image_id": cfg.backendImageID, + "load_image_id": cfg.loadImageID, + } + raw, err := json.Marshal(normalized) + if err != nil { + return "" + } + sum := sha256.Sum256(raw) + return fmt.Sprintf("sha256:%x", sum) +} + +func tokenTTLSeconds(bearer string) int { + parts := strings.Split(strings.TrimSpace(bearer), ".") + if len(parts) < 2 { + return 0 + } + raw, err := base64.RawURLEncoding.DecodeString(parts[1]) + if err != nil { + return 0 + } + var claims struct { + Exp float64 `json:"exp"` + } + if err := json.Unmarshal(raw, &claims); err != nil { + return 0 + } + if claims.Exp <= 0 { + return 0 + } + ttl := int(claims.Exp - float64(time.Now().Unix())) + if ttl < 0 { + return 0 + } + return ttl +} + +func runSession(ctx context.Context, cfg config, rec *recorder, client *mcpClient, sessionIndex int) { + var sessionID string + if _, err := measure(ctx, rec, "initialize", func(ctx context.Context) error { + var initErr error + sessionID, initErr = client.initialize(ctx) + return initErr + }); err != nil { + return + } + if sessionID == "" { + rec.recordError("initialize_empty_session_id") + return + } + rec.recordInitializedSession() + if _, err := measure(ctx, rec, "notifications_initialized", func(ctx context.Context) error { + return client.initialized(ctx, sessionID) + }); err != nil { + return + } + + var tools []string + if _, err := measure(ctx, rec, "tools_list", func(ctx context.Context) error { + var listErr error + tools, listErr = client.listTools(ctx, sessionID) + return listErr + }); err != nil { + return + } + names := resolveTools(tools) + if err := names.require(cfg.scenario); err != nil { + rec.recordError(err.Error()) + return + } + + rng := rand.New(rand.NewSource(time.Now().UnixNano() + int64(sessionIndex))) + switch cfg.scenario { + case "watch-hold": + runWatchHold(ctx, cfg, rec, client, sessionID, names) + return + case "watch-status-hold": + runWatchStatusHold(ctx, cfg, rec, client, sessionID, names) + return + case "sticky-check": + runStickyCheck(ctx, cfg, rec, client, sessionID, names) + return + } + for ctx.Err() == nil { + switch cfg.scenario { + case "discovery", "discovery-hold": + runDiscovery(ctx, cfg, rec, client, sessionID, names) + case "schema-resources": + runSchemaResources(ctx, rec, client, sessionID) + case "bounded-read": + runBoundedRead(ctx, cfg, rec, client, sessionID, names) + case "watch": + runWatch(ctx, cfg, rec, client, sessionID, names) + case "mixed": + n := rng.Intn(100) + switch { + case n < 60: + runDiscovery(ctx, cfg, rec, client, sessionID, names) + case n < 85: + runBoundedRead(ctx, cfg, rec, client, sessionID, names) + default: + runWatch(ctx, cfg, rec, client, sessionID, names) + } + } + } +} + +func runSchemaResources(ctx context.Context, rec *recorder, client *mcpClient, sessionID string) { + measure(ctx, rec, "resources_list", func(ctx context.Context) error { + uris, err := client.listResources(ctx, sessionID) + if err != nil { + return err + } + if len(uris) == 0 { + return errors.New("resources_list_empty") + } + return nil + }) + measure(ctx, rec, "resources_read_index", func(ctx context.Context) error { + return client.readResource(ctx, sessionID, "dsx-exchange://specs/") + }) + measure(ctx, rec, "resources_read_bms", func(ctx context.Context) error { + return client.readResource(ctx, sessionID, "dsx-exchange://specs/bms") + }) +} + +func runDiscovery(ctx context.Context, cfg config, rec *recorder, client *mcpClient, sessionID string, names toolNames) { + measure(ctx, rec, "find_topics", func(ctx context.Context) error { + res, err := client.callTool(ctx, sessionID, names.find, map[string]any{ + "domain": "bms", + "query": "RackPower", + "role": "value", + "point_type": "RackPower", + "limit": 20, + }) + if err != nil { + return err + } + if res.IsError { + return fmt.Errorf("unexpected_tool_error:%s", compact(res.textSummary())) + } + return nil + }) + measure(ctx, rec, "describe_topic", func(ctx context.Context) error { + res, err := client.callTool(ctx, sessionID, names.describe, map[string]any{ + "topic_filter": cfg.topic, + }) + if err != nil { + return err + } + if res.IsError { + return fmt.Errorf("unexpected_tool_error:%s", compact(res.textSummary())) + } + return nil + }) +} + +func runBoundedRead(ctx context.Context, cfg config, rec *recorder, client *mcpClient, sessionID string, names toolNames) { + measure(ctx, rec, "read_retained", func(ctx context.Context) error { + res, err := client.callTool(ctx, sessionID, names.readRetained, map[string]any{ + "topic_filter": cfg.retainedTopic, + "max_messages": cfg.maxMessages, + }) + if err != nil { + return err + } + if res.IsError { + return fmt.Errorf("unexpected_tool_error:%s", compact(res.textSummary())) + } + return nil + }) + measure(ctx, rec, "subscribe", func(ctx context.Context) error { + res, err := client.callTool(ctx, sessionID, names.subscribe, map[string]any{ + "topic_filter": cfg.topic, + "max_messages": cfg.maxMessages, + "max_duration_s": cfg.subscribeDuration, + }) + if err != nil { + return err + } + if res.IsError { + return fmt.Errorf("unexpected_tool_error:%s", compact(res.textSummary())) + } + return nil + }) + if cfg.deniedTopic != "" { + measure(ctx, rec, "subscribe_denied_expected", func(ctx context.Context) error { + res, err := client.callTool(ctx, sessionID, names.subscribe, map[string]any{ + "topic_filter": cfg.deniedTopic, + "max_messages": 1, + "max_duration_s": cfg.subscribeDuration, + }) + if err != nil { + return err + } + if !res.IsError { + return errors.New("denied_topic_unexpected_success") + } + rec.recordExpectedToolError() + return nil + }) + } +} + +func runWatch(ctx context.Context, cfg config, rec *recorder, client *mcpClient, sessionID string, names toolNames) { + started, err := startSubscription(ctx, cfg, rec, client, sessionID, names) + if err != nil { + return + } + + _, _ = statusSubscription(ctx, rec, client, sessionID, names, started.SubscriptionID) + _, _ = readSubscription(ctx, cfg, rec, client, sessionID, names, started.SubscriptionID, started.Cursor) + _ = stopSubscription(ctx, rec, client, sessionID, names, started.SubscriptionID) +} + +func runWatchHold(ctx context.Context, cfg config, rec *recorder, client *mcpClient, sessionID string, names toolNames) { + started, err := startSubscription(ctx, cfg, rec, client, sessionID, names) + if err != nil { + return + } + cursor := started.Cursor + ticker := time.NewTicker(effectivePollInterval(cfg)) + defer ticker.Stop() + + for ctx.Err() == nil { + _, _ = statusSubscription(ctx, rec, client, sessionID, names, started.SubscriptionID) + if nextCursor, err := readSubscription(ctx, cfg, rec, client, sessionID, names, started.SubscriptionID, cursor); err == nil && nextCursor != "" { + cursor = nextCursor + } + select { + case <-ctx.Done(): + case <-ticker.C: + } + } + + cleanupCtx, cancel := context.WithTimeout(context.Background(), cfg.httpTimeout) + defer cancel() + _ = stopSubscription(cleanupCtx, rec, client, sessionID, names, started.SubscriptionID) +} + +func runWatchStatusHold(ctx context.Context, cfg config, rec *recorder, client *mcpClient, sessionID string, names toolNames) { + started, err := startSubscription(ctx, cfg, rec, client, sessionID, names) + if err != nil { + return + } + ticker := time.NewTicker(effectivePollInterval(cfg)) + defer ticker.Stop() + + for ctx.Err() == nil { + _, _ = statusSubscription(ctx, rec, client, sessionID, names, started.SubscriptionID) + select { + case <-ctx.Done(): + case <-ticker.C: + } + } + + cleanupCtx, cancel := context.WithTimeout(context.Background(), cfg.httpTimeout) + defer cancel() + _ = stopSubscription(cleanupCtx, rec, client, sessionID, names, started.SubscriptionID) +} + +func runStickyCheck(ctx context.Context, cfg config, rec *recorder, client *mcpClient, sessionID string, names toolNames) { + started, err := startSubscription(ctx, cfg, rec, client, sessionID, names) + if err != nil { + return + } + cursor := started.Cursor + ticker := time.NewTicker(effectivePollInterval(cfg)) + defer ticker.Stop() + + for ctx.Err() == nil { + _, _ = statusSubscription(ctx, rec, client, sessionID, names, started.SubscriptionID) + if nextCursor, err := readSubscription(ctx, cfg, rec, client, sessionID, names, started.SubscriptionID, cursor); err == nil && nextCursor != "" { + cursor = nextCursor + } + select { + case <-ctx.Done(): + case <-ticker.C: + } + } + + cleanupCtx, cancel := context.WithTimeout(context.Background(), cfg.httpTimeout) + defer cancel() + _ = stopSubscription(cleanupCtx, rec, client, sessionID, names, started.SubscriptionID) +} + +type startedSubscription struct { + SubscriptionID string `json:"subscription_id"` + Cursor string `json:"cursor"` +} + +func startSubscription(ctx context.Context, cfg config, rec *recorder, client *mcpClient, sessionID string, names toolNames) (startedSubscription, error) { + var out startedSubscription + _, err := measure(ctx, rec, "start_subscription", func(ctx context.Context) error { + res, err := client.callTool(ctx, sessionID, names.start, map[string]any{ + "topic_filter": cfg.topic, + "ttl_seconds": cfg.watchTTL, + "buffer_max_messages": cfg.maxMessages, + "buffer_max_bytes": cfg.maxBytes, + }) + if err != nil { + return err + } + if res.IsError { + return fmt.Errorf("unexpected_tool_error:%s", compact(res.textSummary())) + } + if err := json.Unmarshal([]byte(res.lastText()), &out); err != nil { + return fmt.Errorf("decode_start_subscription:%w", err) + } + if out.SubscriptionID == "" { + return errors.New("start_subscription_missing_id") + } + return nil + }) + if err != nil { + return startedSubscription{}, err + } + rec.recordStartedWatch() + return out, nil +} + +func statusSubscription(ctx context.Context, rec *recorder, client *mcpClient, sessionID string, names toolNames, subscriptionID string) (string, error) { + var status string + _, err := measure(ctx, rec, "subscription_status", func(ctx context.Context) error { + res, err := client.callTool(ctx, sessionID, names.status, map[string]any{ + "subscription_id": subscriptionID, + }) + if err != nil { + return err + } + if res.IsError { + return fmt.Errorf("unexpected_tool_error:%s", compact(res.textSummary())) + } + var out struct { + Status string `json:"status"` + } + if err := json.Unmarshal([]byte(res.lastText()), &out); err != nil { + return fmt.Errorf("decode_subscription_status:%w", err) + } + status = out.Status + return nil + }) + return status, err +} + +func readSubscription(ctx context.Context, cfg config, rec *recorder, client *mcpClient, sessionID string, names toolNames, subscriptionID, cursor string) (string, error) { + nextCursor := cursor + _, err := measure(ctx, rec, "read_subscription", func(ctx context.Context) error { + res, err := client.callTool(ctx, sessionID, names.read, map[string]any{ + "subscription_id": subscriptionID, + "cursor": cursor, + "max_messages": cfg.maxMessages, + "max_bytes": cfg.maxBytes, + }) + if err != nil { + return err + } + if res.IsError { + return fmt.Errorf("unexpected_tool_error:%s", compact(res.textSummary())) + } + var out struct { + NextCursor string `json:"next_cursor"` + } + if err := json.Unmarshal([]byte(res.lastText()), &out); err != nil { + return fmt.Errorf("decode_read_subscription:%w", err) + } + if out.NextCursor != "" { + nextCursor = out.NextCursor + } + return nil + }) + return nextCursor, err +} + +func stopSubscription(ctx context.Context, rec *recorder, client *mcpClient, sessionID string, names toolNames, subscriptionID string) error { + _, err := measure(ctx, rec, "stop_subscription", func(ctx context.Context) error { + res, err := client.callTool(ctx, sessionID, names.stop, map[string]any{ + "subscription_id": subscriptionID, + }) + if err != nil { + return err + } + if res.IsError { + return fmt.Errorf("unexpected_tool_error:%s", compact(res.textSummary())) + } + return nil + }) + if err == nil { + rec.recordStoppedWatch() + } + return err +} + +func measure(ctx context.Context, rec *recorder, operation string, fn func(context.Context) error) (string, error) { + start := time.Now() + err := fn(ctx) + duration := time.Since(start) + if err != nil { + rec.record(operation, duration, false, err) + return "", err + } + rec.record(operation, duration, true, nil) + return "", nil +} + +func (c *mcpClient) initialize(ctx context.Context) (string, error) { + _, sessionID, err := c.request(ctx, "", "initialize", map[string]any{ + "protocolVersion": "2025-06-18", + "capabilities": map[string]any{}, + "clientInfo": map[string]any{ + "name": "dsx-exchange-mcp-load", + "version": "0.1.0", + }, + }) + return sessionID, err +} + +func (c *mcpClient) initialized(ctx context.Context, sessionID string) error { + _, _, err := c.post(ctx, sessionID, map[string]any{ + "jsonrpc": "2.0", + "method": "notifications/initialized", + }) + return err +} + +func (c *mcpClient) listTools(ctx context.Context, sessionID string) ([]string, error) { + raw, _, err := c.request(ctx, sessionID, "tools/list", map[string]any{}) + if err != nil { + return nil, err + } + var result struct { + Tools []struct { + Name string `json:"name"` + } `json:"tools"` + } + if err := json.Unmarshal(raw, &result); err != nil { + return nil, fmt.Errorf("decode_tools_list:%w", err) + } + names := make([]string, 0, len(result.Tools)) + for _, tool := range result.Tools { + names = append(names, tool.Name) + } + return names, nil +} + +func (c *mcpClient) listResources(ctx context.Context, sessionID string) ([]string, error) { + raw, _, err := c.request(ctx, sessionID, "resources/list", map[string]any{}) + if err != nil { + return nil, err + } + var result struct { + Resources []struct { + URI string `json:"uri"` + } `json:"resources"` + } + if err := json.Unmarshal(raw, &result); err != nil { + return nil, fmt.Errorf("decode_resources_list:%w", err) + } + uris := make([]string, 0, len(result.Resources)) + for _, resource := range result.Resources { + if resource.URI != "" { + uris = append(uris, resource.URI) + } + } + return uris, nil +} + +func (c *mcpClient) readResource(ctx context.Context, sessionID string, uri string) error { + raw, _, err := c.request(ctx, sessionID, "resources/read", map[string]any{ + "uri": uri, + }) + if err != nil { + return err + } + var result struct { + Contents []struct { + URI string `json:"uri"` + Text string `json:"text"` + MIMEType string `json:"mimeType"` + } `json:"contents"` + } + if err := json.Unmarshal(raw, &result); err != nil { + return fmt.Errorf("decode_resources_read:%w", err) + } + if len(result.Contents) == 0 || result.Contents[0].Text == "" { + return fmt.Errorf("resource_empty:%s", uri) + } + return nil +} + +func (c *mcpClient) callTool(ctx context.Context, sessionID, name string, args map[string]any) (toolCallResult, error) { + raw, _, err := c.request(ctx, sessionID, "tools/call", map[string]any{ + "name": name, + "arguments": args, + }) + if err != nil { + return toolCallResult{}, err + } + var result toolCallResult + if err := json.Unmarshal(raw, &result); err != nil { + return toolCallResult{}, fmt.Errorf("decode_tools_call:%w", err) + } + return result, nil +} + +func (c *mcpClient) request(ctx context.Context, sessionID, method string, params map[string]any) (json.RawMessage, string, error) { + c.nextID++ + resp, newSessionID, err := c.post(ctx, sessionID, map[string]any{ + "jsonrpc": "2.0", + "id": c.nextID, + "method": method, + "params": params, + }) + if err != nil { + return nil, newSessionID, err + } + if resp.Error != nil { + return nil, newSessionID, fmt.Errorf("jsonrpc_error_%d:%s", resp.Error.Code, resp.Error.Message) + } + return resp.Result, newSessionID, nil +} + +func (c *mcpClient) post(ctx context.Context, sessionID string, payload map[string]any) (rpcResponse, string, error) { + if c.limiter != nil { + if err := c.limiter.wait(ctx); err != nil { + return rpcResponse{}, "", err + } + } + body, err := json.Marshal(payload) + if err != nil { + return rpcResponse{}, "", err + } + req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.endpoint, bytes.NewReader(body)) + if err != nil { + return rpcResponse{}, "", err + } + req.Header.Set("Authorization", "Bearer "+c.bearer) + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Accept", "application/json, text/event-stream") + if sessionID != "" { + req.Header.Set("Mcp-Session-Id", sessionID) + } + + res, err := c.httpc.Do(req) + if err != nil { + return rpcResponse{}, "", fmt.Errorf("http_request:%w", err) + } + defer res.Body.Close() + + raw, err := io.ReadAll(res.Body) + if err != nil { + return rpcResponse{}, res.Header.Get("Mcp-Session-Id"), fmt.Errorf("read_response:%w", err) + } + if res.StatusCode >= http.StatusBadRequest { + return rpcResponse{}, res.Header.Get("Mcp-Session-Id"), fmt.Errorf("http_%d:%s", res.StatusCode, strings.TrimSpace(string(raw))) + } + + data := lastMCPResponseData(raw) + if len(data) == 0 { + return rpcResponse{}, res.Header.Get("Mcp-Session-Id"), nil + } + var decoded rpcResponse + if err := json.Unmarshal(data, &decoded); err != nil { + return rpcResponse{}, res.Header.Get("Mcp-Session-Id"), fmt.Errorf("decode_mcp_response:%w", err) + } + return decoded, res.Header.Get("Mcp-Session-Id"), nil +} + +func lastMCPResponseData(body []byte) []byte { + body = bytes.TrimSpace(body) + if len(body) == 0 || bytes.HasPrefix(body, []byte("{")) { + return body + } + var last []byte + for _, line := range bytes.Split(body, []byte("\n")) { + line = bytes.TrimSpace(line) + if !bytes.HasPrefix(line, []byte("data:")) { + continue + } + data := bytes.TrimSpace(bytes.TrimPrefix(line, []byte("data:"))) + if len(data) == 0 || bytes.Equal(data, []byte("[DONE]")) { + continue + } + last = append(last[:0], data...) + } + return last +} + +func (r toolCallResult) textSummary() string { + var texts []string + for _, item := range r.Content { + if item.Text != "" { + texts = append(texts, item.Text) + } + } + return strings.Join(texts, "\n") +} + +func (r toolCallResult) lastText() string { + for i := len(r.Content) - 1; i >= 0; i-- { + if r.Content[i].Text != "" { + return r.Content[i].Text + } + } + return "" +} + +type toolNames struct { + subscribe string + readRetained string + describe string + find string + start string + read string + status string + stop string +} + +func resolveTools(names []string) toolNames { + return toolNames{ + subscribe: chooseToolName(names, toolSubscribe), + readRetained: chooseToolName(names, toolReadRetained), + describe: chooseToolName(names, toolDescribeTopic), + find: chooseToolName(names, toolFindTopics), + start: chooseToolName(names, toolStartSubscription), + read: chooseToolName(names, toolReadSubscription), + status: chooseToolName(names, toolStatusSubscription), + stop: chooseToolName(names, toolStopSubscription), + } +} + +func (n toolNames) require(scenario string) error { + if scenario == "schema-resources" { + return nil + } + if n.describe == "" { + return errors.New("missing_tool_describe_topic") + } + if n.find == "" { + return errors.New("missing_tool_find_topics") + } + if scenario == "bounded-read" || scenario == "mixed" { + if n.readRetained == "" { + return errors.New("missing_tool_read_retained") + } + if n.subscribe == "" { + return errors.New("missing_tool_subscribe") + } + } + if scenario == "watch" || scenario == "watch-hold" || scenario == "watch-status-hold" || scenario == "sticky-check" || scenario == "mixed" { + if n.start == "" || n.status == "" || n.stop == "" { + return errors.New("missing_watch_tool") + } + } + if scenario == "watch" || scenario == "watch-hold" || scenario == "sticky-check" || scenario == "mixed" { + if n.read == "" { + return errors.New("missing_watch_read_tool") + } + } + return nil +} + +func chooseToolName(names []string, baseName string) string { + for _, name := range names { + if name == baseName { + return name + } + } + for _, name := range names { + if strings.HasSuffix(name, "_"+baseName) { + return name + } + } + return "" +} + +func (r *recorder) record(operation string, duration time.Duration, success bool, err error) { + r.mu.Lock() + defer r.mu.Unlock() + r.totalRequests++ + stats := r.byOperation[operation] + if stats == nil { + stats = &operationStats{} + r.byOperation[operation] = stats + } + stats.count++ + stats.latencies = append(stats.latencies, duration) + if success { + r.successes++ + stats.successes++ + return + } + r.failures++ + stats.failures++ + if err != nil { + code := classifyError(err) + r.errors[code]++ + if stats.errors == nil { + stats.errors = map[string]uint64{} + } + stats.errors[code]++ + r.recordStickyErrorCountersLocked(err.Error()) + r.recordErrorSampleLocked(code, err) + } +} + +func (r *recorder) recordError(code string) { + r.mu.Lock() + defer r.mu.Unlock() + r.failures++ + r.errors[code]++ + r.recordStickyErrorCountersLocked(code) + r.recordErrorSampleLocked(code, errors.New(code)) +} + +func (r *recorder) recordStickyErrorCountersLocked(msg string) { + msg = compact(msg) + if strings.Contains(msg, "session not found") { + r.sessionNotFoundErrors++ + } + if strings.Contains(msg, "subscription_not_found") { + r.subscriptionNotFoundErrors++ + } +} + +func (r *recorder) recordErrorSampleLocked(code string, err error) { + if err == nil { + return + } + samples := r.errorSamples[code] + if len(samples) >= maxErrorSamples { + return + } + sample := compact(err.Error()) + if len(sample) > 300 { + sample = sample[:300] + "..." + } + for _, existing := range samples { + if existing == sample { + return + } + } + r.errorSamples[code] = append(samples, sample) +} + +func (r *recorder) recordExpectedToolError() { + r.mu.Lock() + defer r.mu.Unlock() + r.expectedToolErrors++ +} + +func (r *recorder) recordInitializedSession() { + r.mu.Lock() + defer r.mu.Unlock() + r.initializedSessions++ +} + +func (r *recorder) recordStartedWatch() { + r.mu.Lock() + defer r.mu.Unlock() + r.startedWatches++ +} + +func (r *recorder) recordStoppedWatch() { + r.mu.Lock() + defer r.mu.Unlock() + r.stoppedWatches++ +} + +func (r *recorder) snapshot(endedAt time.Time) runReport { + r.mu.Lock() + defer r.mu.Unlock() + byOperation := map[string]operationSnapshot{} + for op, stats := range r.byOperation { + latencies := append([]time.Duration(nil), stats.latencies...) + sort.Slice(latencies, func(i, j int) bool { return latencies[i] < latencies[j] }) + byOperation[op] = operationSnapshot{ + Phase: operationPhase(op), + Count: stats.count, + Successes: stats.successes, + Failures: stats.failures, + P50Milliseconds: percentileMS(latencies, 0.50), + P95Milliseconds: percentileMS(latencies, 0.95), + P99Milliseconds: percentileMS(latencies, 0.99), + Errors: cloneErrors(stats.errors), + } + } + durationSeconds := endedAt.Sub(r.startedAt).Seconds() + throughput := 0.0 + if durationSeconds > 0 { + throughput = float64(r.totalRequests) / durationSeconds + } + successRate := 0.0 + if r.totalRequests > 0 { + successRate = float64(r.successes) / float64(r.totalRequests) * 100 + } + return runReport{ + StartedAt: r.startedAt, + EndedAt: endedAt, + DurationSeconds: durationSeconds, + ThroughputRPS: throughput, + SuccessRate: successRate, + Endpoint: r.endpoint, + Experiment: r.experiment, + ExperimentDetail: r.experimentDetail, + Scenario: r.scenario, + Sessions: r.sessions, + BackendReplicas: r.backendReplicas, + StickySessionCheck: r.stickySessionCheckResultLocked(), + RateLimit: r.rateLimit, + GatewayRateLimit: r.gatewayRateLimit, + ManifestName: r.manifestName, + BackendImageID: r.backendImageID, + LoadImageID: r.loadImageID, + ExperimentConfigHash: r.experimentConfigHash, + TokenTTLSecondsAtStart: r.tokenTTLSecondsAtStart, + Topic: r.topic, + RetainedTopic: r.retainedTopic, + DeniedTopic: r.deniedTopic, + HTTPTimeoutSeconds: r.httpTimeout.Seconds(), + StartupRampSeconds: r.startupRamp.Seconds(), + PollIntervalSeconds: r.pollInterval.Seconds(), + SubscribeDurationS: r.subscribeDuration, + MaxMessages: r.maxMessages, + MaxBytes: r.maxBytes, + WatchTTLS: r.watchTTL, + BackendConnectS: r.backendConnectS, + BackendSubscribeS: r.backendSubscribeS, + BackendCollectMax: r.backendCollectMax, + BackendWatchStartMax: r.backendWatchStartMax, + TotalRequests: r.totalRequests, + Successes: r.successes, + Failures: r.failures, + ExpectedToolErrors: r.expectedToolErrors, + InitializedSessions: r.initializedSessions, + StartedWatches: r.startedWatches, + StoppedWatches: r.stoppedWatches, + SessionNotFoundErrors: r.sessionNotFoundErrors, + SubscriptionNotFoundErrors: r.subscriptionNotFoundErrors, + ByOperation: byOperation, + Errors: cloneErrors(r.errors), + ErrorSamples: cloneErrorSamples(r.errorSamples), + } +} + +func (r *recorder) stickySessionCheckResultLocked() string { + if r.scenario != "sticky-check" { + return r.stickySessionCheck + } + if r.failures == 0 { + return "pass" + } + return "fail" +} + +func operationPhase(operation string) string { + switch operation { + case "initialize", "notifications_initialized", "tools_list", "start_subscription": + return "startup" + case "stop_subscription": + return "cleanup" + default: + return "steady" + } +} + +func cloneErrors(in map[string]uint64) map[string]uint64 { + out := make(map[string]uint64, len(in)) + for k, v := range in { + out[k] = v + } + return out +} + +func cloneErrorSamples(in map[string][]string) map[string][]string { + out := make(map[string][]string, len(in)) + for k, v := range in { + out[k] = append([]string(nil), v...) + } + return out +} + +func percentileMS(latencies []time.Duration, p float64) float64 { + if len(latencies) == 0 { + return 0 + } + if len(latencies) == 1 { + return float64(latencies[0].Microseconds()) / 1000 + } + idx := int(float64(len(latencies)-1) * p) + return float64(latencies[idx].Microseconds()) / 1000 +} + +func classifyError(err error) string { + if err == nil { + return "" + } + msg := compact(err.Error()) + switch { + case strings.HasPrefix(msg, "http_"): + return strings.SplitN(msg, ":", 2)[0] + case strings.HasPrefix(msg, "jsonrpc_error_"): + return strings.SplitN(msg, ":", 2)[0] + case strings.HasPrefix(msg, "unexpected_tool_error:"): + if code := classifyToolError(msg); code != "" { + return "tool_error_" + code + } + return "unexpected_tool_error" + case strings.HasPrefix(msg, "context deadline exceeded"): + return "context_deadline" + case strings.HasPrefix(msg, "context canceled"): + return "context_canceled" + case strings.HasPrefix(msg, "http_request:"): + return "http_request" + default: + if len(msg) > 80 { + return msg[:80] + } + return msg + } +} + +func classifyToolError(msg string) string { + raw := strings.TrimPrefix(msg, "unexpected_tool_error:") + var body struct { + Error struct { + Code string `json:"code"` + } `json:"error"` + } + if err := json.Unmarshal([]byte(raw), &body); err != nil { + return "" + } + return strings.TrimSpace(body.Error.Code) +} + +func printTextReport(w io.Writer, report runReport) { + fmt.Fprintf(w, "MCP load report\n") + fmt.Fprintf(w, " endpoint: %s\n", report.Endpoint) + if report.Experiment != "" { + fmt.Fprintf(w, " experiment: %s\n", report.Experiment) + } + if report.ExperimentDetail != "" { + fmt.Fprintf(w, " experiment_detail: %s\n", report.ExperimentDetail) + } + fmt.Fprintf(w, " scenario: %s\n", report.Scenario) + fmt.Fprintf(w, " sessions: %d\n", report.Sessions) + if report.BackendReplicas > 0 || report.StickySessionCheck != "" { + fmt.Fprintf(w, " scale_metadata: backend_replicas=%d sticky_session_check=%s\n", report.BackendReplicas, report.StickySessionCheck) + } + fmt.Fprintf(w, " reproducibility: config_hash=%s manifest=%s backend_image=%s load_image=%s token_ttl_start=%ds gateway_rps=%d\n", + report.ExperimentConfigHash, report.ManifestName, report.BackendImageID, report.LoadImageID, report.TokenTTLSecondsAtStart, report.GatewayRateLimit) + fmt.Fprintf(w, " backend_mqtt_timeouts: connect=%ds subscribe=%ds http_timeout=%.1fs\n", report.BackendConnectS, report.BackendSubscribeS, report.HTTPTimeoutSeconds) + fmt.Fprintf(w, " backend_mqtt_admission: collect_max_per_pod=%d watch_start_max_per_pod=%d\n", report.BackendCollectMax, report.BackendWatchStartMax) + fmt.Fprintf(w, " workload_args: startup_ramp=%.1fs poll_interval=%.1fs subscribe_duration=%ds watch_ttl=%ds max_messages=%d max_bytes=%d\n", report.StartupRampSeconds, report.PollIntervalSeconds, report.SubscribeDurationS, report.WatchTTLS, report.MaxMessages, report.MaxBytes) + fmt.Fprintf(w, " duration: %.1fs\n", report.DurationSeconds) + fmt.Fprintf(w, " requests: %d success=%d failures=%d expected_tool_errors=%d throughput=%.2f/s success_rate=%.2f%%\n", report.TotalRequests, report.Successes, report.Failures, report.ExpectedToolErrors, report.ThroughputRPS, report.SuccessRate) + fmt.Fprintf(w, " initialized_sessions=%d started_watches=%d stopped_watches=%d\n", report.InitializedSessions, report.StartedWatches, report.StoppedWatches) + fmt.Fprintf(w, " sticky_errors: session_not_found=%d subscription_not_found=%d\n", report.SessionNotFoundErrors, report.SubscriptionNotFoundErrors) + ops := make([]string, 0, len(report.ByOperation)) + for op := range report.ByOperation { + ops = append(ops, op) + } + sort.Strings(ops) + for _, op := range ops { + s := report.ByOperation[op] + fmt.Fprintf(w, " %-28s phase=%-7s count=%-6d ok=%-6d fail=%-4d p50=%7.2fms p95=%7.2fms p99=%7.2fms\n", + op, s.Phase, s.Count, s.Successes, s.Failures, s.P50Milliseconds, s.P95Milliseconds, s.P99Milliseconds) + } + if len(report.Errors) > 0 { + fmt.Fprintf(w, " errors:\n") + keys := make([]string, 0, len(report.Errors)) + for k := range report.Errors { + keys = append(keys, k) + } + sort.Strings(keys) + for _, k := range keys { + fmt.Fprintf(w, " %s: %d\n", k, report.Errors[k]) + for _, sample := range report.ErrorSamples[k] { + fmt.Fprintf(w, " sample: %s\n", sample) + } + } + } +} + +func writeReports(dir string, reports []runReport) error { + if err := os.MkdirAll(dir, 0755); err != nil { + return err + } + timestamp := time.Now().UTC().Format("20060102-150405") + jsonPath := filepath.Join(dir, "dsx-exchange-mcp-load-"+timestamp+".json") + textPath := filepath.Join(dir, "dsx-exchange-mcp-load-"+timestamp+".txt") + csvPath := filepath.Join(dir, "dsx-exchange-mcp-load-"+timestamp+".csv") + + jsonFile, err := os.Create(jsonPath) + if err != nil { + return err + } + enc := json.NewEncoder(jsonFile) + enc.SetIndent("", " ") + if len(reports) == 1 { + err = enc.Encode(reports[0]) + } else { + err = enc.Encode(reports) + } + closeErr := jsonFile.Close() + if err != nil { + return err + } + if closeErr != nil { + return closeErr + } + + textFile, err := os.Create(textPath) + if err != nil { + return err + } + for i, report := range reports { + if i > 0 { + fmt.Fprintln(textFile) + } + printTextReport(textFile, report) + } + if err := textFile.Close(); err != nil { + return err + } + if err := writeCSVReport(csvPath, reports); err != nil { + return err + } + bundleDir, err := writeReportBundle(dir, timestamp, reports) + if err != nil { + return err + } + fmt.Fprintf(os.Stderr, "report files written: %s %s %s\n", jsonPath, textPath, csvPath) + fmt.Fprintf(os.Stderr, "report bundle written: %s\n", bundleDir) + return nil +} + +func writeReportBundle(dir, timestamp string, reports []runReport) (string, error) { + name := "dsx-exchange-mcp-load" + if len(reports) > 0 && reports[0].Experiment != "" { + name = reports[0].Experiment + } + bundleDir := filepath.Join(dir, sanitizeFilename(name)+"-"+timestamp) + if err := os.MkdirAll(bundleDir, 0755); err != nil { + return "", err + } + if err := writeJSONReport(filepath.Join(bundleDir, "report.json"), reports); err != nil { + return "", err + } + if err := writeTextReport(filepath.Join(bundleDir, "report.txt"), reports); err != nil { + return "", err + } + if err := writeCSVReport(filepath.Join(bundleDir, "report.csv"), reports); err != nil { + return "", err + } + if err := writeConfigYAML(filepath.Join(bundleDir, "config.yaml"), reports); err != nil { + return "", err + } + if err := writeSummaryMarkdown(filepath.Join(bundleDir, "summary.md"), reports); err != nil { + return "", err + } + return bundleDir, nil +} + +func writeJSONReport(path string, reports []runReport) error { + f, err := os.Create(path) + if err != nil { + return err + } + enc := json.NewEncoder(f) + enc.SetIndent("", " ") + if len(reports) == 1 { + err = enc.Encode(reports[0]) + } else { + err = enc.Encode(reports) + } + closeErr := f.Close() + if err != nil { + return err + } + return closeErr +} + +func writeTextReport(path string, reports []runReport) error { + f, err := os.Create(path) + if err != nil { + return err + } + for i, report := range reports { + if i > 0 { + fmt.Fprintln(f) + } + printTextReport(f, report) + } + return f.Close() +} + +func writeConfigYAML(path string, reports []runReport) error { + f, err := os.Create(path) + if err != nil { + return err + } + fmt.Fprintln(f, "runs:") + for _, report := range reports { + fmt.Fprintf(f, " - experiment: %q\n", report.Experiment) + fmt.Fprintf(f, " experiment_config_hash: %q\n", report.ExperimentConfigHash) + fmt.Fprintf(f, " manifest_name: %q\n", report.ManifestName) + fmt.Fprintf(f, " scenario: %q\n", report.Scenario) + fmt.Fprintf(f, " sessions: %d\n", report.Sessions) + fmt.Fprintf(f, " backend_replicas: %d\n", report.BackendReplicas) + fmt.Fprintf(f, " client_rate_limit_per_second: %d\n", report.RateLimit) + fmt.Fprintf(f, " gateway_rate_limit_rps: %d\n", report.GatewayRateLimit) + fmt.Fprintf(f, " duration_seconds: %.3f\n", report.DurationSeconds) + fmt.Fprintf(f, " startup_ramp_seconds: %.3f\n", report.StartupRampSeconds) + fmt.Fprintf(f, " poll_interval_seconds: %.3f\n", report.PollIntervalSeconds) + fmt.Fprintf(f, " http_timeout_seconds: %.3f\n", report.HTTPTimeoutSeconds) + fmt.Fprintf(f, " backend_mqtt_connect_timeout_seconds: %d\n", report.BackendConnectS) + fmt.Fprintf(f, " backend_mqtt_subscribe_timeout_seconds: %d\n", report.BackendSubscribeS) + fmt.Fprintf(f, " backend_mqtt_collect_max_concurrent_per_pod: %d\n", report.BackendCollectMax) + fmt.Fprintf(f, " backend_mqtt_watch_start_max_concurrent_per_pod: %d\n", report.BackendWatchStartMax) + fmt.Fprintf(f, " watch_ttl_seconds: %d\n", report.WatchTTLS) + fmt.Fprintf(f, " max_messages: %d\n", report.MaxMessages) + fmt.Fprintf(f, " max_bytes: %d\n", report.MaxBytes) + fmt.Fprintf(f, " token_ttl_seconds_at_start: %d\n", report.TokenTTLSecondsAtStart) + fmt.Fprintf(f, " backend_image_id: %q\n", report.BackendImageID) + fmt.Fprintf(f, " load_image_id: %q\n", report.LoadImageID) + fmt.Fprintf(f, " topic: %q\n", report.Topic) + fmt.Fprintf(f, " retained_topic: %q\n", report.RetainedTopic) + } + return f.Close() +} + +func writeSummaryMarkdown(path string, reports []runReport) error { + f, err := os.Create(path) + if err != nil { + return err + } + fmt.Fprintln(f, "# MCP Load Test Summary") + fmt.Fprintln(f) + fmt.Fprintln(f, "| Experiment | Scenario | Sessions | Replicas | Ramp | Poll | Success | Failures | Started Watches | Stopped Watches | Session 404 | Subscription Missing |") + fmt.Fprintln(f, "|---|---|---:|---:|---:|---:|---:|---:|---:|---:|---:|---:|") + for _, report := range reports { + fmt.Fprintf(f, "| %s | %s | %d | %d | %.0fs | %.3fs | %.2f%% | %d | %d | %d | %d | %d |\n", + report.Experiment, report.Scenario, report.Sessions, report.BackendReplicas, + report.StartupRampSeconds, report.PollIntervalSeconds, report.SuccessRate, + report.Failures, report.StartedWatches, report.StoppedWatches, + report.SessionNotFoundErrors, report.SubscriptionNotFoundErrors) + } + return f.Close() +} + +func writeCSVReport(path string, reports []runReport) error { + f, err := os.Create(path) + if err != nil { + return err + } + w := csv.NewWriter(f) + header := []string{ + "started_at", + "ended_at", + "experiment", + "experiment_detail", + "scenario", + "sessions", + "backend_replicas", + "sticky_session_check", + "rate_limit_per_second", + "gateway_rate_limit_rps", + "manifest_name", + "backend_image_id", + "load_image_id", + "experiment_config_hash", + "token_ttl_seconds_at_start", + "endpoint", + "topic", + "retained_topic", + "http_timeout_seconds", + "startup_ramp_seconds", + "poll_interval_seconds", + "subscribe_duration_seconds", + "max_messages", + "max_bytes", + "watch_ttl_seconds", + "backend_mqtt_connect_timeout_seconds", + "backend_mqtt_subscribe_timeout_seconds", + "backend_mqtt_collect_max_concurrent_per_pod", + "backend_mqtt_watch_start_max_concurrent_per_pod", + "duration_seconds", + "throughput_requests_per_second", + "success_rate_percent", + "total_requests", + "successes", + "failures", + "expected_tool_errors", + "initialized_sessions", + "started_watches", + "stopped_watches", + "session_not_found_errors", + "subscription_not_found_errors", + "operation", + "phase", + "operation_count", + "operation_successes", + "operation_failures", + "p50_ms", + "p95_ms", + "p99_ms", + "operation_errors", + "errors", + } + if err := w.Write(header); err != nil { + _ = f.Close() + return err + } + for _, report := range reports { + ops := make([]string, 0, len(report.ByOperation)) + for op := range report.ByOperation { + ops = append(ops, op) + } + sort.Strings(ops) + for _, op := range ops { + s := report.ByOperation[op] + if err := w.Write([]string{ + report.StartedAt.Format(time.RFC3339Nano), + report.EndedAt.Format(time.RFC3339Nano), + report.Experiment, + report.ExperimentDetail, + report.Scenario, + strconv.Itoa(report.Sessions), + strconv.Itoa(report.BackendReplicas), + report.StickySessionCheck, + strconv.Itoa(report.RateLimit), + strconv.Itoa(report.GatewayRateLimit), + report.ManifestName, + report.BackendImageID, + report.LoadImageID, + report.ExperimentConfigHash, + strconv.Itoa(report.TokenTTLSecondsAtStart), + report.Endpoint, + report.Topic, + report.RetainedTopic, + formatFloat(report.HTTPTimeoutSeconds), + formatFloat(report.StartupRampSeconds), + formatFloat(report.PollIntervalSeconds), + strconv.Itoa(report.SubscribeDurationS), + strconv.Itoa(report.MaxMessages), + strconv.Itoa(report.MaxBytes), + strconv.Itoa(report.WatchTTLS), + strconv.Itoa(report.BackendConnectS), + strconv.Itoa(report.BackendSubscribeS), + strconv.Itoa(report.BackendCollectMax), + strconv.Itoa(report.BackendWatchStartMax), + formatFloat(report.DurationSeconds), + formatFloat(report.ThroughputRPS), + formatFloat(report.SuccessRate), + strconv.FormatUint(report.TotalRequests, 10), + strconv.FormatUint(report.Successes, 10), + strconv.FormatUint(report.Failures, 10), + strconv.FormatUint(report.ExpectedToolErrors, 10), + strconv.FormatUint(report.InitializedSessions, 10), + strconv.FormatUint(report.StartedWatches, 10), + strconv.FormatUint(report.StoppedWatches, 10), + strconv.FormatUint(report.SessionNotFoundErrors, 10), + strconv.FormatUint(report.SubscriptionNotFoundErrors, 10), + op, + s.Phase, + strconv.FormatUint(s.Count, 10), + strconv.FormatUint(s.Successes, 10), + strconv.FormatUint(s.Failures, 10), + formatFloat(s.P50Milliseconds), + formatFloat(s.P95Milliseconds), + formatFloat(s.P99Milliseconds), + formatErrorSummary(s.Errors), + formatErrorSummary(report.Errors), + }); err != nil { + _ = f.Close() + return err + } + } + } + w.Flush() + if err := w.Error(); err != nil { + _ = f.Close() + return err + } + return f.Close() +} + +func formatFloat(v float64) string { + return strconv.FormatFloat(v, 'f', 3, 64) +} + +func sanitizeFilename(s string) string { + s = strings.TrimSpace(s) + if s == "" { + return "run" + } + var b strings.Builder + for _, r := range s { + switch { + case r >= 'a' && r <= 'z': + b.WriteRune(r) + case r >= 'A' && r <= 'Z': + b.WriteRune(r) + case r >= '0' && r <= '9': + b.WriteRune(r) + case r == '-' || r == '_' || r == '.': + b.WriteRune(r) + default: + b.WriteByte('-') + } + } + out := strings.Trim(b.String(), "-.") + if out == "" { + return "run" + } + return out +} + +func formatErrorSummary(errorsByCode map[string]uint64) string { + if len(errorsByCode) == 0 { + return "" + } + keys := make([]string, 0, len(errorsByCode)) + for k := range errorsByCode { + keys = append(keys, k) + } + sort.Strings(keys) + parts := make([]string, 0, len(keys)) + for _, k := range keys { + parts = append(parts, k+"="+strconv.FormatUint(errorsByCode[k], 10)) + } + return strings.Join(parts, ";") +} + +func newRateLimiter(rate int) *rateLimiter { + if rate <= 0 { + return nil + } + ch := make(chan struct{}, rate) + for i := 0; i < rate; i++ { + ch <- struct{}{} + } + interval := time.Second / time.Duration(rate) + go func() { + ticker := time.NewTicker(interval) + defer ticker.Stop() + for range ticker.C { + select { + case ch <- struct{}{}: + default: + } + } + }() + return &rateLimiter{ch: ch} +} + +func (l *rateLimiter) wait(ctx context.Context) error { + if l == nil { + return nil + } + select { + case <-ctx.Done(): + return ctx.Err() + case <-l.ch: + return nil + } +} + +func env(key, fallback string) string { + if v := os.Getenv(key); v != "" { + return v + } + return fallback +} + +func firstEnv(keys ...string) string { + for _, key := range keys { + if v := os.Getenv(key); v != "" { + return v + } + } + return "" +} + +func envInt(key string, fallback int) int { + if v := os.Getenv(key); v != "" { + var out int + if _, err := fmt.Sscanf(v, "%d", &out); err == nil { + return out + } + } + return fallback +} + +func envDuration(key string, fallback time.Duration) time.Duration { + if v := os.Getenv(key); v != "" { + if out, err := time.ParseDuration(v); err == nil { + return out + } + } + return fallback +} + +func compact(s string) string { + s = strings.TrimSpace(s) + s = strings.ReplaceAll(s, "\n", " ") + for strings.Contains(s, " ") { + s = strings.ReplaceAll(s, " ", " ") + } + return s +} diff --git a/mcp/dsx-exchange-mcp/cmd/dsx-exchange-mcp-load/main_test.go b/mcp/dsx-exchange-mcp/cmd/dsx-exchange-mcp-load/main_test.go new file mode 100644 index 0000000..6e25371 --- /dev/null +++ b/mcp/dsx-exchange-mcp/cmd/dsx-exchange-mcp-load/main_test.go @@ -0,0 +1,385 @@ +// Copyright 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +package main + +import ( + "context" + "encoding/base64" + "encoding/csv" + "encoding/json" + "errors" + "os" + "path/filepath" + "testing" + "time" +) + +func TestRecorderSnapshotKeepsStartupVisible(t *testing.T) { + start := time.Now().Add(-2 * time.Second) + rec := &recorder{ + startedAt: start, + endpoint: "http://gateway/mcp", + experiment: "baseline", + experimentDetail: "mqtt_connect_timeout_s=5;mqtt_subscribe_timeout_s=5", + scenario: "watch-hold", + sessions: 2, + backendReplicas: 1, + stickySessionCheck: "not_run", + rateLimit: 100, + gatewayRateLimit: 1000, + manifestName: "kind-gateway-load-job.yaml", + backendImageID: "sha256:backend", + loadImageID: "sha256:load", + experimentConfigHash: "sha256:abc123", + tokenTTLSecondsAtStart: 600, + httpTimeout: 30 * time.Second, + startupRamp: 30 * time.Second, + pollInterval: time.Second, + subscribeDuration: 1, + maxMessages: 10, + maxBytes: 32768, + watchTTL: 30, + backendConnectS: 5, + backendSubscribeS: 5, + backendCollectMax: 100, + backendWatchStartMax: 500, + byOperation: map[string]*operationStats{}, + errors: map[string]uint64{}, + errorSamples: map[string][]string{}, + } + rec.record("initialize", 100*time.Millisecond, true, nil) + rec.record("start_subscription", 250*time.Millisecond, true, nil) + rec.record("subscription_status", 10*time.Millisecond, true, nil) + rec.record("stop_subscription", 20*time.Millisecond, true, nil) + rec.recordInitializedSession() + rec.recordStartedWatch() + rec.recordStoppedWatch() + + report := rec.snapshot(start.Add(2 * time.Second)) + if report.TotalRequests != 4 || report.Successes != 4 || report.Failures != 0 { + t.Fatalf("request counts = total %d success %d failure %d", report.TotalRequests, report.Successes, report.Failures) + } + if report.InitializedSessions != 1 || report.StartedWatches != 1 || report.StoppedWatches != 1 { + t.Fatalf("lifecycle counts = sessions %d started %d stopped %d", report.InitializedSessions, report.StartedWatches, report.StoppedWatches) + } + if got := report.ByOperation["initialize"].Phase; got != "startup" { + t.Fatalf("initialize phase = %q, want startup", got) + } + if got := report.ByOperation["subscription_status"].Phase; got != "steady" { + t.Fatalf("subscription_status phase = %q, want steady", got) + } + if got := report.ByOperation["stop_subscription"].Phase; got != "cleanup" { + t.Fatalf("stop_subscription phase = %q, want cleanup", got) + } + if report.ThroughputRPS != 2 { + t.Fatalf("throughput = %f, want 2", report.ThroughputRPS) + } + if report.SuccessRate != 100 { + t.Fatalf("success rate = %f, want 100", report.SuccessRate) + } + if report.Experiment != "baseline" || report.BackendConnectS != 5 || report.BackendSubscribeS != 5 || report.StartupRampSeconds != 30 { + t.Fatalf("experiment metadata = %q connect=%d subscribe=%d startup_ramp=%f", report.Experiment, report.BackendConnectS, report.BackendSubscribeS, report.StartupRampSeconds) + } + if report.BackendCollectMax != 100 || report.BackendWatchStartMax != 500 { + t.Fatalf("admission metadata = collect %d watch_start %d, want 100/500", report.BackendCollectMax, report.BackendWatchStartMax) + } + if report.BackendReplicas != 1 || report.StickySessionCheck != "not_run" { + t.Fatalf("scale metadata = replicas %d sticky %q", report.BackendReplicas, report.StickySessionCheck) + } + if report.GatewayRateLimit != 1000 || report.ManifestName != "kind-gateway-load-job.yaml" || report.BackendImageID != "sha256:backend" || report.LoadImageID != "sha256:load" { + t.Fatalf("repro metadata = gateway %d manifest %q backend %q load %q", report.GatewayRateLimit, report.ManifestName, report.BackendImageID, report.LoadImageID) + } + if report.ExperimentConfigHash != "sha256:abc123" || report.TokenTTLSecondsAtStart != 600 || report.PollIntervalSeconds != 1 { + t.Fatalf("config metadata = hash %q token_ttl=%d poll=%f", report.ExperimentConfigHash, report.TokenTTLSecondsAtStart, report.PollIntervalSeconds) + } +} + +func TestRecorderCountsContextFailures(t *testing.T) { + rec := &recorder{ + startedAt: time.Now(), + byOperation: map[string]*operationStats{}, + errors: map[string]uint64{}, + errorSamples: map[string][]string{}, + } + ctx := t.Context() + _, err := measure(ctx, rec, "initialize", func(context.Context) error { + return errors.New("context deadline exceeded") + }) + if err == nil { + t.Fatal("measure returned nil error") + } + report := rec.snapshot(time.Now()) + if report.Failures != 1 { + t.Fatalf("failures = %d, want 1", report.Failures) + } + if report.Errors["context_deadline"] != 1 { + t.Fatalf("context_deadline errors = %d, want 1", report.Errors["context_deadline"]) + } + if got := report.ErrorSamples["context_deadline"]; len(got) != 1 || got[0] != "context deadline exceeded" { + t.Fatalf("context_deadline samples = %#v, want context deadline exceeded", got) + } +} + +func TestClassifyErrorExtractsStructuredToolCode(t *testing.T) { + err := errors.New(`unexpected_tool_error:{"error":{"code":"mqtt_admission_limited","message":"retry later","retry_after_seconds":1}}`) + if got := classifyError(err); got != "tool_error_mqtt_admission_limited" { + t.Fatalf("classifyError = %q, want tool_error_mqtt_admission_limited", got) + } +} + +func TestRecorderSnapshotMarksStickyCheckResult(t *testing.T) { + start := time.Now().Add(-time.Second) + rec := &recorder{ + startedAt: start, + scenario: "sticky-check", + stickySessionCheck: "running", + byOperation: map[string]*operationStats{}, + errors: map[string]uint64{}, + errorSamples: map[string][]string{}, + } + rec.record("subscription_status", time.Millisecond, true, nil) + pass := rec.snapshot(start.Add(time.Second)) + if pass.StickySessionCheck != "pass" { + t.Fatalf("sticky_session_check = %q, want pass", pass.StickySessionCheck) + } + + rec.record("read_subscription", time.Millisecond, false, errors.New("unexpected_tool_error:subscription_not_found")) + rec.record("subscription_status", time.Millisecond, false, errors.New("http_404:session not found")) + fail := rec.snapshot(start.Add(2 * time.Second)) + if fail.StickySessionCheck != "fail" { + t.Fatalf("sticky_session_check = %q, want fail", fail.StickySessionCheck) + } + if fail.SessionNotFoundErrors != 1 || fail.SubscriptionNotFoundErrors != 1 { + t.Fatalf("sticky error counters = session %d subscription %d, want 1/1", fail.SessionNotFoundErrors, fail.SubscriptionNotFoundErrors) + } +} + +func TestWaitStartupRampSpreadsSessionStarts(t *testing.T) { + start := time.Now() + if ok := waitStartupRamp(t.Context(), 60*time.Millisecond, 0, 3); !ok { + t.Fatal("session 0 unexpectedly skipped") + } + if elapsed := time.Since(start); elapsed > 20*time.Millisecond { + t.Fatalf("session 0 waited %s, want immediate", elapsed) + } + + start = time.Now() + if ok := waitStartupRamp(t.Context(), 60*time.Millisecond, 2, 3); !ok { + t.Fatal("session 2 unexpectedly skipped") + } + if elapsed := time.Since(start); elapsed < 35*time.Millisecond || elapsed > 150*time.Millisecond { + t.Fatalf("session 2 waited %s, want roughly 40ms", elapsed) + } +} + +func TestWaitStartupRampHonorsContextCancellation(t *testing.T) { + ctx, cancel := context.WithCancel(t.Context()) + cancel() + if ok := waitStartupRamp(ctx, time.Minute, 1, 2); ok { + t.Fatal("waitStartupRamp returned true after cancellation") + } +} + +func TestEffectivePollIntervalDefaults(t *testing.T) { + if got := effectivePollInterval(config{scenario: "watch-status-hold"}); got != time.Second { + t.Fatalf("watch-status poll interval = %s, want 1s", got) + } + if got := effectivePollInterval(config{scenario: "sticky-check"}); got != 250*time.Millisecond { + t.Fatalf("sticky poll interval = %s, want 250ms", got) + } + if got := effectivePollInterval(config{scenario: "sticky-check", pollInterval: time.Second}); got != time.Second { + t.Fatalf("override poll interval = %s, want 1s", got) + } +} + +func TestExperimentConfigHashExcludesBearer(t *testing.T) { + cfg := config{ + endpoint: "http://gateway/mcp", + bearer: "secret-a", + scenario: "sticky-check", + sessions: 100, + duration: time.Minute, + gatewayRateLimit: 1000, + topic: "BMS/#", + retainedTopic: "BMS/meta/#", + subscribeDuration: 1, + maxMessages: 10, + maxBytes: 1024, + watchTTL: 60, + httpTimeout: 30 * time.Second, + } + a := experimentConfigHash(cfg) + cfg.bearer = "secret-b" + b := experimentConfigHash(cfg) + if a == "" || a != b { + t.Fatalf("hash changed when only bearer changed: %q vs %q", a, b) + } + cfg.sessions = 101 + if c := experimentConfigHash(cfg); c == a { + t.Fatalf("hash did not change after config change: %q", c) + } +} + +func TestTokenTTLSeconds(t *testing.T) { + claims, err := json.Marshal(map[string]int64{"exp": time.Now().Add(time.Minute).Unix()}) + if err != nil { + t.Fatal(err) + } + token := "header." + base64.RawURLEncoding.EncodeToString(claims) + ".signature" + if got := tokenTTLSeconds(token); got < 1 || got > 60 { + t.Fatalf("token ttl = %d, want within 1..60", got) + } + if got := tokenTTLSeconds("not-a-jwt"); got != 0 { + t.Fatalf("invalid token ttl = %d, want 0", got) + } +} + +func TestWriteCSVReportIncludesOperationRows(t *testing.T) { + start := time.Date(2026, 6, 8, 12, 0, 0, 0, time.UTC) + report := runReport{ + StartedAt: start, + EndedAt: start.Add(time.Minute), + DurationSeconds: 60, + ThroughputRPS: 10, + SuccessRate: 99.5, + Endpoint: "http://gateway/mcp", + Experiment: "mixed-baseline", + ExperimentDetail: "gateway_rps=1000;mqtt_connect_timeout_s=5;mqtt_subscribe_timeout_s=5", + Scenario: "watch-hold", + Sessions: 500, + BackendReplicas: 3, + StickySessionCheck: "planned", + RateLimit: 1000, + GatewayRateLimit: 5000, + ManifestName: "kind-gateway-load-job.yaml", + BackendImageID: "sha256:backend", + LoadImageID: "sha256:load", + ExperimentConfigHash: "sha256:abc123", + TokenTTLSecondsAtStart: 600, + Topic: "BMS/v1/PUB/Value/#", + RetainedTopic: "BMS/v1/PUB/Metadata/#", + HTTPTimeoutSeconds: 30, + StartupRampSeconds: 30, + PollIntervalSeconds: 1, + SubscribeDurationS: 1, + MaxMessages: 10, + MaxBytes: 32768, + WatchTTLS: 180, + BackendConnectS: 5, + BackendSubscribeS: 5, + BackendCollectMax: 100, + BackendWatchStartMax: 500, + TotalRequests: 1000, + Successes: 995, + Failures: 5, + InitializedSessions: 500, + StartedWatches: 491, + StoppedWatches: 491, + SessionNotFoundErrors: 2, + SubscriptionNotFoundErrors: 3, + ByOperation: map[string]operationSnapshot{ + "start_subscription": { + Phase: "startup", + Count: 500, + Successes: 491, + Failures: 9, + P50Milliseconds: 5683.353, + P95Milliseconds: 7466.277, + P99Milliseconds: 8002.154, + }, + }, + Errors: map[string]uint64{"unexpected_tool_error": 9}, + } + + path := filepath.Join(t.TempDir(), "report.csv") + if err := writeCSVReport(path, []runReport{report}); err != nil { + t.Fatalf("writeCSVReport returned error: %v", err) + } + f, err := os.Open(path) + if err != nil { + t.Fatalf("open csv: %v", err) + } + defer f.Close() + rows, err := csv.NewReader(f).ReadAll() + if err != nil { + t.Fatalf("read csv: %v", err) + } + if len(rows) != 2 { + t.Fatalf("csv rows = %d, want 2", len(rows)) + } + header := rows[0] + row := rows[1] + col := func(name string) string { + for i, h := range header { + if h == name { + return row[i] + } + } + t.Fatalf("missing csv column %q", name) + return "" + } + if got := col("sessions"); got != "500" { + t.Fatalf("sessions column = %q, want 500", got) + } + if got := col("backend_replicas"); got != "3" { + t.Fatalf("backend_replicas column = %q, want 3", got) + } + if got := col("sticky_session_check"); got != "planned" { + t.Fatalf("sticky_session_check column = %q, want planned", got) + } + if got := col("gateway_rate_limit_rps"); got != "5000" { + t.Fatalf("gateway_rate_limit_rps column = %q, want 5000", got) + } + if got := col("manifest_name"); got != "kind-gateway-load-job.yaml" { + t.Fatalf("manifest_name column = %q, want kind-gateway-load-job.yaml", got) + } + if got := col("backend_image_id"); got != "sha256:backend" { + t.Fatalf("backend_image_id column = %q, want sha256:backend", got) + } + if got := col("load_image_id"); got != "sha256:load" { + t.Fatalf("load_image_id column = %q, want sha256:load", got) + } + if got := col("experiment_config_hash"); got != "sha256:abc123" { + t.Fatalf("experiment_config_hash column = %q, want sha256:abc123", got) + } + if got := col("token_ttl_seconds_at_start"); got != "600" { + t.Fatalf("token_ttl_seconds_at_start column = %q, want 600", got) + } + if got := col("experiment"); got != "mixed-baseline" { + t.Fatalf("experiment column = %q, want mixed-baseline", got) + } + if got := col("backend_mqtt_connect_timeout_seconds"); got != "5" { + t.Fatalf("backend_mqtt_connect_timeout_seconds column = %q, want 5", got) + } + if got := col("backend_mqtt_collect_max_concurrent_per_pod"); got != "100" { + t.Fatalf("backend_mqtt_collect_max_concurrent_per_pod column = %q, want 100", got) + } + if got := col("backend_mqtt_watch_start_max_concurrent_per_pod"); got != "500" { + t.Fatalf("backend_mqtt_watch_start_max_concurrent_per_pod column = %q, want 500", got) + } + if got := col("http_timeout_seconds"); got != "30.000" { + t.Fatalf("http_timeout_seconds column = %q, want 30.000", got) + } + if got := col("startup_ramp_seconds"); got != "30.000" { + t.Fatalf("startup_ramp_seconds column = %q, want 30.000", got) + } + if got := col("poll_interval_seconds"); got != "1.000" { + t.Fatalf("poll_interval_seconds column = %q, want 1.000", got) + } + if got := col("session_not_found_errors"); got != "2" { + t.Fatalf("session_not_found_errors column = %q, want 2", got) + } + if got := col("subscription_not_found_errors"); got != "3" { + t.Fatalf("subscription_not_found_errors column = %q, want 3", got) + } + if got := col("operation"); got != "start_subscription" { + t.Fatalf("operation column = %q, want start_subscription", got) + } + if got := col("p95_ms"); got != "7466.277" { + t.Fatalf("p95_ms column = %q, want 7466.277", got) + } + if got := col("errors"); got != "unexpected_tool_error=9" { + t.Fatalf("errors column = %q, want unexpected_tool_error=9", got) + } +} diff --git a/mcp/dsx-exchange-mcp/cmd/dsx-exchange-mcp/main.go b/mcp/dsx-exchange-mcp/cmd/dsx-exchange-mcp/main.go index 6c0d10c..58fdd26 100644 --- a/mcp/dsx-exchange-mcp/cmd/dsx-exchange-mcp/main.go +++ b/mcp/dsx-exchange-mcp/cmd/dsx-exchange-mcp/main.go @@ -39,21 +39,23 @@ func main() { SubscribeTimeout: time.Duration(envInt("MQTT_SUBSCRIBE_TIMEOUT_S", 5)) * time.Second, MaxResultBytes: envInt("MQTT_MAX_RESULT_BYTES", 1048576), }, - Metrics: recorder, - DefaultMaxMessages: envInt("MCP_DEFAULT_MAX_MESSAGES", 100), - MaxMessages: envInt("MCP_MAX_MESSAGES", 1000), - DefaultDurationS: envInt("MCP_DEFAULT_MAX_DURATION_S", 30), - MaxDurationS: envInt("MCP_MAX_DURATION_S", 30), - WatchDefaultTTLS: envInt("MCP_WATCH_DEFAULT_TTL_S", 300), - WatchMaxTTLS: envInt("MCP_WATCH_MAX_TTL_S", 900), - WatchDefaultBufferMessages: envInt("MCP_WATCH_DEFAULT_BUFFER_MESSAGES", 100), - WatchMaxBufferMessages: envInt("MCP_WATCH_MAX_BUFFER_MESSAGES", 1000), - WatchDefaultBufferBytes: envInt("MCP_WATCH_DEFAULT_BUFFER_BYTES", 262144), - WatchMaxBufferBytes: envInt("MCP_WATCH_MAX_BUFFER_BYTES", 1048576), - WatchMaxPerSession: envInt("MCP_WATCH_MAX_PER_SESSION", 10), - WatchMaxPerPod: envInt("MCP_WATCH_MAX_PER_POD", 1000), - FindTopicsDefaultLimit: envInt("MCP_FIND_TOPICS_DEFAULT_LIMIT", 20), - FindTopicsMaxLimit: envInt("MCP_FIND_TOPICS_MAX_LIMIT", 100), + Metrics: recorder, + DefaultMaxMessages: envInt("MCP_DEFAULT_MAX_MESSAGES", 100), + MaxMessages: envInt("MCP_MAX_MESSAGES", 1000), + DefaultDurationS: envInt("MCP_DEFAULT_MAX_DURATION_S", 30), + MaxDurationS: envInt("MCP_MAX_DURATION_S", 30), + MQTTCollectMaxConcurrent: envInt("MCP_MQTT_COLLECT_MAX_CONCURRENT_PER_POD", 100), + MQTTWatchStartMaxConcurrent: envInt("MCP_MQTT_WATCH_START_MAX_CONCURRENT_PER_POD", 500), + WatchDefaultTTLS: envInt("MCP_WATCH_DEFAULT_TTL_S", 300), + WatchMaxTTLS: envInt("MCP_WATCH_MAX_TTL_S", 900), + WatchDefaultBufferMessages: envInt("MCP_WATCH_DEFAULT_BUFFER_MESSAGES", 100), + WatchMaxBufferMessages: envInt("MCP_WATCH_MAX_BUFFER_MESSAGES", 1000), + WatchDefaultBufferBytes: envInt("MCP_WATCH_DEFAULT_BUFFER_BYTES", 262144), + WatchMaxBufferBytes: envInt("MCP_WATCH_MAX_BUFFER_BYTES", 1048576), + WatchMaxPerSession: envInt("MCP_WATCH_MAX_PER_SESSION", 10), + WatchMaxPerPod: envInt("MCP_WATCH_MAX_PER_POD", 1000), + FindTopicsDefaultLimit: envInt("MCP_FIND_TOPICS_DEFAULT_LIMIT", 20), + FindTopicsMaxLimit: envInt("MCP_FIND_TOPICS_MAX_LIMIT", 100), } srv := server.Build(cfg) @@ -77,6 +79,8 @@ func main() { "mqtt_tls_server_name", cfg.MQTT.TLS.ServerName, "max_messages", cfg.MaxMessages, "max_duration_s", cfg.MaxDurationS, + "mqtt_collect_max_concurrent_per_pod", cfg.MQTTCollectMaxConcurrent, + "mqtt_watch_start_max_concurrent_per_pod", cfg.MQTTWatchStartMaxConcurrent, "watch_max_ttl_s", cfg.WatchMaxTTLS, "watch_max_per_pod", cfg.WatchMaxPerPod, ) diff --git a/mcp/dsx-exchange-mcp/deploy/helm/dsx-exchange-mcp/templates/deployment.yaml b/mcp/dsx-exchange-mcp/deploy/helm/dsx-exchange-mcp/templates/deployment.yaml index 9b0bbac..75dc6ad 100644 --- a/mcp/dsx-exchange-mcp/deploy/helm/dsx-exchange-mcp/templates/deployment.yaml +++ b/mcp/dsx-exchange-mcp/deploy/helm/dsx-exchange-mcp/templates/deployment.yaml @@ -80,6 +80,10 @@ spec: value: {{ .Values.limits.defaultMaxDurationSeconds | int | quote }} - name: MCP_MAX_DURATION_S value: {{ .Values.limits.maxDurationSeconds | int | quote }} + - name: MCP_MQTT_COLLECT_MAX_CONCURRENT_PER_POD + value: {{ .Values.limits.mqtt.collectMaxConcurrentPerPod | int | quote }} + - name: MCP_MQTT_WATCH_START_MAX_CONCURRENT_PER_POD + value: {{ .Values.limits.mqtt.watchStartMaxConcurrentPerPod | int | quote }} - name: MCP_WATCH_DEFAULT_TTL_S value: {{ .Values.limits.watch.defaultTTLSeconds | int | quote }} - name: MCP_WATCH_MAX_TTL_S diff --git a/mcp/dsx-exchange-mcp/deploy/helm/dsx-exchange-mcp/values.yaml b/mcp/dsx-exchange-mcp/deploy/helm/dsx-exchange-mcp/values.yaml index 68cdcc3..12a3681 100644 --- a/mcp/dsx-exchange-mcp/deploy/helm/dsx-exchange-mcp/values.yaml +++ b/mcp/dsx-exchange-mcp/deploy/helm/dsx-exchange-mcp/values.yaml @@ -38,6 +38,9 @@ limits: maxMessages: 1000 defaultMaxDurationSeconds: 30 maxDurationSeconds: 30 + mqtt: + collectMaxConcurrentPerPod: 100 + watchStartMaxConcurrentPerPod: 500 watch: defaultTTLSeconds: 300 maxTTLSeconds: 900 diff --git a/mcp/dsx-exchange-mcp/deploy/loadtest/agentgatewaybackend-stateful-routing-patch.yaml b/mcp/dsx-exchange-mcp/deploy/loadtest/agentgatewaybackend-stateful-routing-patch.yaml new file mode 100644 index 0000000..cd9824d --- /dev/null +++ b/mcp/dsx-exchange-mcp/deploy/loadtest/agentgatewaybackend-stateful-routing-patch.yaml @@ -0,0 +1,9 @@ +# Copyright 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +# Apply with: +# kubectl --context kind-dsx-mcp patch agentgatewaybackend mcp-agentgw-mcp \ +# -n mcp-gateway --type=merge --patch-file agentgatewaybackend-stateful-routing-patch.yaml +spec: + mcp: + sessionRouting: Stateful diff --git a/mcp/dsx-exchange-mcp/deploy/loadtest/gateway-high-rate-values.yaml b/mcp/dsx-exchange-mcp/deploy/loadtest/gateway-high-rate-values.yaml new file mode 100644 index 0000000..ea3d74e --- /dev/null +++ b/mcp/dsx-exchange-mcp/deploy/loadtest/gateway-high-rate-values.yaml @@ -0,0 +1,10 @@ +# Copyright 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +# Test-only overlay for backend-capacity runs through Latinum MCP Gateway. +# Keep rate limiting enabled, but raise the tenant quota so the DSX Exchange +# MCP backend, not the production-like 1000 RPS gateway limiter, becomes the measured +# bottleneck. +rateLimit: + enabled: true + tenantRequestsPerSecond: 5000 diff --git a/mcp/dsx-exchange-mcp/deploy/loadtest/gateway-ratelimit-1000-configmap.yaml b/mcp/dsx-exchange-mcp/deploy/loadtest/gateway-ratelimit-1000-configmap.yaml new file mode 100644 index 0000000..8f81de4 --- /dev/null +++ b/mcp/dsx-exchange-mcp/deploy/loadtest/gateway-ratelimit-1000-configmap.yaml @@ -0,0 +1,16 @@ +# Copyright 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +apiVersion: v1 +kind: ConfigMap +metadata: + name: latinum-mcp-gateway-ratelimit-config + namespace: mcp-gateway +data: + mcp-gateway.yaml: | + domain: mcp-gateway + descriptors: + - key: tenant + rate_limit: + unit: second + requests_per_unit: 1000 diff --git a/mcp/dsx-exchange-mcp/deploy/loadtest/gateway-ratelimit-5000-configmap.yaml b/mcp/dsx-exchange-mcp/deploy/loadtest/gateway-ratelimit-5000-configmap.yaml new file mode 100644 index 0000000..cf542d1 --- /dev/null +++ b/mcp/dsx-exchange-mcp/deploy/loadtest/gateway-ratelimit-5000-configmap.yaml @@ -0,0 +1,16 @@ +# Copyright 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +apiVersion: v1 +kind: ConfigMap +metadata: + name: latinum-mcp-gateway-ratelimit-config + namespace: mcp-gateway +data: + mcp-gateway.yaml: | + domain: mcp-gateway + descriptors: + - key: tenant + rate_limit: + unit: second + requests_per_unit: 5000 diff --git a/mcp/dsx-exchange-mcp/deploy/loadtest/run-kind-load-experiment.sh b/mcp/dsx-exchange-mcp/deploy/loadtest/run-kind-load-experiment.sh new file mode 100755 index 0000000..def2568 --- /dev/null +++ b/mcp/dsx-exchange-mcp/deploy/loadtest/run-kind-load-experiment.sh @@ -0,0 +1,390 @@ +#!/usr/bin/env bash +# Copyright 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../../.." && pwd)" +LOADTEST_DIR="$ROOT_DIR/dsx-exchange-mcp/deploy/loadtest" + +KUBECTL_CONTEXT="${KUBECTL_CONTEXT:-kind-dsx-mcp}" +MCP_NAMESPACE="${MCP_NAMESPACE:-mcp-backends}" +GATEWAY_NAMESPACE="${GATEWAY_NAMESPACE:-mcp-gateway}" +LOAD_NAMESPACE="${LOAD_NAMESPACE:-mcp-loadtest}" +BACKEND_DEPLOYMENT="${BACKEND_DEPLOYMENT:-dsx-exchange-mcp}" +BACKEND_REPLICAS="${BACKEND_REPLICAS:-1}" +SCENARIO="${SCENARIO:-mixed}" +SESSIONS="${SESSIONS:-100}" +SESSION_SWEEP="${SESSION_SWEEP:-}" +STARTUP_RAMP="${STARTUP_RAMP:-30s}" +DURATION="${DURATION:-90s}" +POLL_INTERVAL="${POLL_INTERVAL:-0s}" +GATEWAY_RPS="${GATEWAY_RPS:-1000}" +CLIENT_RPS="${CLIENT_RPS:-$GATEWAY_RPS}" +HTTP_TIMEOUT="${HTTP_TIMEOUT:-60s}" +WATCH_TTL_S="${WATCH_TTL_S:-360}" +SUBSCRIBE_DURATION_S="${SUBSCRIBE_DURATION_S:-1}" +MAX_MESSAGES="${MAX_MESSAGES:-10}" +MAX_BYTES="${MAX_BYTES:-32768}" +BACKEND_CONNECT_TIMEOUT_S="${BACKEND_CONNECT_TIMEOUT_S:-10}" +BACKEND_SUBSCRIBE_TIMEOUT_S="${BACKEND_SUBSCRIBE_TIMEOUT_S:-10}" +BACKEND_COLLECT_MAX_CONCURRENT="${BACKEND_COLLECT_MAX_CONCURRENT:-100}" +BACKEND_WATCH_START_MAX_CONCURRENT="${BACKEND_WATCH_START_MAX_CONCURRENT:-500}" +ENDPOINT="${ENDPOINT:-http://mcp-agentgw.mcp-gateway.svc.cluster.local/mcp}" +TOPIC="${TOPIC:-BMS/v1/PUB/Value/Rack/RackPower/#}" +RETAINED_TOPIC="${RETAINED_TOPIC:-BMS/v1/PUB/Metadata/Rack/RackPower/#}" +STICKY_SESSION_CHECK="${STICKY_SESSION_CHECK:-not_run}" +RESET_BACKEND="${RESET_BACKEND:-1}" +APPLY_BACKEND_ENV="${APPLY_BACKEND_ENV:-1}" +ENSURE_STATEFUL_GATEWAY="${ENSURE_STATEFUL_GATEWAY:-1}" +APPLY_GATEWAY_RATELIMIT="${APPLY_GATEWAY_RATELIMIT:-1}" +STRICT_JOB_SUCCESS="${STRICT_JOB_SUCCESS:-0}" +LOAD_IMAGE="${LOAD_IMAGE:-dsx-exchange-mcp-load:dev}" +REPORT_ROOT="${REPORT_ROOT:-$ROOT_DIR/dsx-exchange-mcp/reports/loadtest/live-$(date -u +%Y%m%d)}" + +if [[ -n "$SESSION_SWEEP" ]]; then + SESSION_LABEL="${SESSION_SWEEP//,/-}" +else + SESSION_LABEL="$SESSIONS" +fi +EXPERIMENT="${EXPERIMENT:-${SCENARIO}-r${BACKEND_REPLICAS}-${SESSION_LABEL}-ramp-${STARTUP_RAMP}-gateway-${GATEWAY_RPS}}" +SAFE_EXPERIMENT="$(printf '%s' "$EXPERIMENT" | tr -c 'A-Za-z0-9_.-' '-' | sed 's/^-*//; s/-*$//')" +if [[ -z "$SAFE_EXPERIMENT" ]]; then + SAFE_EXPERIMENT="loadtest" +fi +TIMESTAMP="$(date -u +%Y%m%d-%H%M%S)" +BUNDLE_DIR="$REPORT_ROOT/$SAFE_EXPERIMENT-$TIMESTAMP" +JOB_NAME="${JOB_NAME:-$(printf 'dsx-mcp-%s' "$SAFE_EXPERIMENT" | tr 'A-Z_.' 'a-z--' | cut -c1-60)}" +MANIFEST_NAME="$JOB_NAME.yaml" +MANIFEST_PATH="$BUNDLE_DIR/manifest.yaml" + +mkdir -p "$BUNDLE_DIR" + +kubectl_cmd() { + kubectl --context "$KUBECTL_CONTEXT" "$@" +} + +capture_cluster_state() { + local path="$1" + { + echo "# captured_at=$(date -u +%FT%TZ)" + echo + echo "## dsx-exchange-mcp deployment" + kubectl_cmd get deployment "$BACKEND_DEPLOYMENT" -n "$MCP_NAMESPACE" -o wide || true + echo + kubectl_cmd get pods -n "$MCP_NAMESPACE" -o wide || true + echo + echo "## agentgateway backend" + kubectl_cmd get agentgatewaybackend mcp-agentgw-mcp -n "$GATEWAY_NAMESPACE" -o yaml || true + echo + echo "## agentgateway policy" + kubectl_cmd get agentgatewaypolicy mcp-agentgw-authz -n "$GATEWAY_NAMESPACE" -o yaml || true + echo + echo "## rate limit config" + kubectl_cmd get configmap latinum-mcp-gateway-ratelimit-config -n "$GATEWAY_NAMESPACE" -o yaml || true + echo + echo "## gateway pods" + kubectl_cmd get pods -n "$GATEWAY_NAMESPACE" -o wide || true + } > "$path" +} + +write_token_metadata() { + local path="$1" + TOKEN_B64="$(kubectl_cmd get secret dsx-exchange-mcp-load-token -n "$LOAD_NAMESPACE" -o jsonpath='{.data.bearer}' 2>/dev/null || true)" \ + python3 - "$path" <<'PY' +import base64, json, os, sys, time +out = {"present": False, "valid_jwt_shape": False, "ttl_seconds": 0} +raw_b64 = os.environ.get("TOKEN_B64", "") +try: + token = base64.b64decode(raw_b64).decode().strip() + out["present"] = bool(token) + parts = token.split(".") + if len(parts) >= 2: + payload = parts[1] + "=" * (-len(parts[1]) % 4) + claims = json.loads(base64.urlsafe_b64decode(payload)) + out.update({ + "valid_jwt_shape": True, + "scopes": claims.get("scopes"), + "ttl_seconds": max(0, int(claims.get("exp", 0) - time.time())), + }) +except Exception as exc: + out["error"] = type(exc).__name__ +open(sys.argv[1], "w").write(json.dumps(out, indent=2, sort_keys=True) + "\n") +PY +} + +backend_image_id() { + kubectl_cmd get pods -n "$MCP_NAMESPACE" -l app=dsx-exchange-mcp \ + -o jsonpath='{.items[0].status.containerStatuses[0].imageID}' 2>/dev/null || true +} + +load_image_id() { + docker image inspect "$LOAD_IMAGE" --format '{{.Id}}' 2>/dev/null || true +} + +if [[ "$ENSURE_STATEFUL_GATEWAY" == "1" ]]; then + kubectl_cmd patch agentgatewaybackend mcp-agentgw-mcp -n "$GATEWAY_NAMESPACE" \ + --type=merge --patch-file "$LOADTEST_DIR/agentgatewaybackend-stateful-routing-patch.yaml" +fi + +if [[ "$APPLY_GATEWAY_RATELIMIT" == "1" ]]; then + case "$GATEWAY_RPS" in + 1000) kubectl_cmd apply -f "$LOADTEST_DIR/gateway-ratelimit-1000-configmap.yaml" ;; + 5000) kubectl_cmd apply -f "$LOADTEST_DIR/gateway-ratelimit-5000-configmap.yaml" ;; + *) echo "unsupported GATEWAY_RPS=$GATEWAY_RPS; expected 1000 or 5000" >&2; exit 2 ;; + esac + kubectl_cmd rollout restart deployment/latinum-mcp-gateway-ratelimit -n "$GATEWAY_NAMESPACE" + kubectl_cmd rollout status deployment/latinum-mcp-gateway-ratelimit -n "$GATEWAY_NAMESPACE" --timeout=120s +fi + +if [[ "$APPLY_BACKEND_ENV" == "1" ]]; then + kubectl_cmd set env deployment "$BACKEND_DEPLOYMENT" -n "$MCP_NAMESPACE" \ + MQTT_CONNECT_TIMEOUT_S="$BACKEND_CONNECT_TIMEOUT_S" \ + MQTT_SUBSCRIBE_TIMEOUT_S="$BACKEND_SUBSCRIBE_TIMEOUT_S" \ + MCP_MQTT_COLLECT_MAX_CONCURRENT_PER_POD="$BACKEND_COLLECT_MAX_CONCURRENT" \ + MCP_MQTT_WATCH_START_MAX_CONCURRENT_PER_POD="$BACKEND_WATCH_START_MAX_CONCURRENT" +fi + +kubectl_cmd scale deployment "$BACKEND_DEPLOYMENT" -n "$MCP_NAMESPACE" --replicas="$BACKEND_REPLICAS" +kubectl_cmd rollout status deployment "$BACKEND_DEPLOYMENT" -n "$MCP_NAMESPACE" --timeout=120s +if [[ "$RESET_BACKEND" == "1" ]]; then + kubectl_cmd rollout restart deployment "$BACKEND_DEPLOYMENT" -n "$MCP_NAMESPACE" + kubectl_cmd rollout status deployment "$BACKEND_DEPLOYMENT" -n "$MCP_NAMESPACE" --timeout=120s +fi + +BACKEND_IMAGE_ID="$(backend_image_id)" +LOAD_IMAGE_ID="$(load_image_id)" + +capture_cluster_state "$BUNDLE_DIR/cluster-state-before.txt" +write_token_metadata "$BUNDLE_DIR/token-metadata.json" +cat > "$BUNDLE_DIR/images.txt" < "$MANIFEST_PATH" </dev/null || true)" + echo "poll $i state=$JOB_STATE" + case "$JOB_STATE" in + 1,*) break ;; + *,1|*,2|*,3) break ;; + esac + sleep 10 +done + +kubectl_cmd get job "$JOB_NAME" -n "$LOAD_NAMESPACE" -o wide > "$BUNDLE_DIR/job-status.txt" +kubectl_cmd logs "job/$JOB_NAME" -n "$LOAD_NAMESPACE" > "$BUNDLE_DIR/job.log" || true +capture_cluster_state "$BUNDLE_DIR/cluster-state-after.txt" + +BUNDLE_DIR="$BUNDLE_DIR" python3 <<'PY' +import csv, json, os, pathlib + +bundle = pathlib.Path(os.environ["BUNDLE_DIR"]) +text = (bundle / "job.log").read_text() +decoder = json.JSONDecoder() +reports = None +prefix = "" +for idx, ch in enumerate(text): + if ch not in "[{": + continue + try: + value, end = decoder.raw_decode(text[idx:]) + except json.JSONDecodeError: + continue + candidates = value if isinstance(value, list) else [value] + if candidates and isinstance(candidates[0], dict) and "started_at" in candidates[0]: + reports = candidates + prefix = text[:idx] + break +if reports is None: + raise SystemExit("could not find load report JSON in job.log") + +(bundle / "report.json").write_text(json.dumps(reports if len(reports) > 1 else reports[0], indent=2) + "\n") +(bundle / "report.txt").write_text(prefix.rstrip() + "\n") + +header = [ + "started_at","ended_at","experiment","experiment_detail","scenario","sessions", + "backend_replicas","sticky_session_check","rate_limit_per_second","gateway_rate_limit_rps", + "manifest_name","backend_image_id","load_image_id","experiment_config_hash", + "token_ttl_seconds_at_start","endpoint","topic","retained_topic","http_timeout_seconds", + "startup_ramp_seconds","poll_interval_seconds","subscribe_duration_seconds", + "max_messages","max_bytes","watch_ttl_seconds","backend_mqtt_connect_timeout_seconds", + "backend_mqtt_subscribe_timeout_seconds","backend_mqtt_collect_max_concurrent_per_pod", + "backend_mqtt_watch_start_max_concurrent_per_pod","duration_seconds","throughput_requests_per_second", + "success_rate_percent","total_requests","successes","failures","expected_tool_errors", + "initialized_sessions","started_watches","stopped_watches","session_not_found_errors", + "subscription_not_found_errors","operation","phase","operation_count","operation_successes", + "operation_failures","p50_ms","p95_ms","p99_ms","operation_errors","errors", +] +def fnum(value): + return f"{value:.3f}" if isinstance(value, float) else str(value) +with (bundle / "report.csv").open("w", newline="") as out: + writer = csv.writer(out) + writer.writerow(header) + for report in reports: + errors = ";".join(f"{k}={report.get('errors', {}).get(k)}" for k in sorted(report.get("errors", {}))) + for op in sorted(report["by_operation"]): + stats = report["by_operation"][op] + writer.writerow([ + report["started_at"], report["ended_at"], report.get("experiment", ""), + report.get("experiment_detail", ""), report["scenario"], report["sessions"], + report.get("backend_replicas", 0), report.get("sticky_session_check", ""), + report.get("rate_limit_per_second", 0), report.get("gateway_rate_limit_rps", 0), + report.get("manifest_name", ""), report.get("backend_image_id", ""), + report.get("load_image_id", ""), report.get("experiment_config_hash", ""), + report.get("token_ttl_seconds_at_start", 0), report["endpoint"], report["topic"], + report["retained_topic"], fnum(report["http_timeout_seconds"]), + fnum(report.get("startup_ramp_seconds", 0)), fnum(report.get("poll_interval_seconds", 0)), + report["subscribe_duration_seconds"], report["max_messages"], report["max_bytes"], + report["watch_ttl_seconds"], report.get("backend_mqtt_connect_timeout_seconds", 0), + report.get("backend_mqtt_subscribe_timeout_seconds", 0), + report.get("backend_mqtt_collect_max_concurrent_per_pod", 0), + report.get("backend_mqtt_watch_start_max_concurrent_per_pod", 0), + fnum(report["duration_seconds"]), + fnum(report["throughput_requests_per_second"]), fnum(report["success_rate_percent"]), + report["total_requests"], report["successes"], report["failures"], + report["expected_tool_errors"], report["initialized_sessions"], report["started_watches"], + report["stopped_watches"], report.get("session_not_found_errors", 0), + report.get("subscription_not_found_errors", 0), op, stats["phase"], stats["count"], + stats["successes"], stats["failures"], fnum(stats["p50_ms"]), + fnum(stats["p95_ms"]), fnum(stats["p99_ms"]), + ";".join(f"{k}={stats.get('errors', {}).get(k)}" for k in sorted(stats.get("errors", {}))), + errors, + ]) + +with (bundle / "summary.md").open("w") as out: + out.write("# MCP Load Test Summary\n\n") + out.write("| Experiment | Scenario | Sessions | Replicas | Ramp | Poll | Success | Failures | Started Watches | Stopped Watches | Session 404 | Subscription Missing |\n") + out.write("|---|---|---:|---:|---:|---:|---:|---:|---:|---:|---:|---:|\n") + for report in reports: + out.write( + f"| {report.get('experiment', '')} | {report['scenario']} | {report['sessions']} | " + f"{report.get('backend_replicas', 0)} | {report.get('startup_ramp_seconds', 0):.0f}s | " + f"{report.get('poll_interval_seconds', 0):.3f}s | {report['success_rate_percent']:.2f}% | " + f"{report['failures']} | {report['started_watches']} | {report['stopped_watches']} | " + f"{report.get('session_not_found_errors', 0)} | {report.get('subscription_not_found_errors', 0)} |\n" + ) + +with (bundle / "config.yaml").open("w") as out: + out.write("runs:\n") + for report in reports: + out.write(f" - experiment: {json.dumps(report.get('experiment', ''))}\n") + out.write(f" experiment_config_hash: {json.dumps(report.get('experiment_config_hash', ''))}\n") + out.write(f" manifest_name: {json.dumps(report.get('manifest_name', ''))}\n") + out.write(f" scenario: {json.dumps(report['scenario'])}\n") + out.write(f" sessions: {report['sessions']}\n") + out.write(f" backend_replicas: {report.get('backend_replicas', 0)}\n") + out.write(f" gateway_rate_limit_rps: {report.get('gateway_rate_limit_rps', 0)}\n") + out.write(f" client_rate_limit_per_second: {report.get('rate_limit_per_second', 0)}\n") + out.write(f" startup_ramp_seconds: {report.get('startup_ramp_seconds', 0)}\n") + out.write(f" poll_interval_seconds: {report.get('poll_interval_seconds', 0)}\n") + out.write(f" token_ttl_seconds_at_start: {report.get('token_ttl_seconds_at_start', 0)}\n") + out.write(f" backend_mqtt_connect_timeout_seconds: {report.get('backend_mqtt_connect_timeout_seconds', 0)}\n") + out.write(f" backend_mqtt_subscribe_timeout_seconds: {report.get('backend_mqtt_subscribe_timeout_seconds', 0)}\n") + out.write(f" backend_mqtt_collect_max_concurrent_per_pod: {report.get('backend_mqtt_collect_max_concurrent_per_pod', 0)}\n") + out.write(f" backend_mqtt_watch_start_max_concurrent_per_pod: {report.get('backend_mqtt_watch_start_max_concurrent_per_pod', 0)}\n") +PY + +echo "bundle written: $BUNDLE_DIR" +echo "$BUNDLE_DIR" > "$REPORT_ROOT/latest-bundle.txt" + +if [[ "$STRICT_JOB_SUCCESS" == "1" && "$JOB_STATE" != 1,* ]]; then + exit 1 +fi diff --git a/mcp/dsx-exchange-mcp/docs/current-v1-scope.md b/mcp/dsx-exchange-mcp/docs/current-v1-scope.md new file mode 100644 index 0000000..2bad47a --- /dev/null +++ b/mcp/dsx-exchange-mcp/docs/current-v1-scope.md @@ -0,0 +1,103 @@ + + +# DSX Exchange MCP Current V1 Scope + +This note records the current implementation scope for `dsx-exchange-mcp`. +When older planning docs conflict with this note, use this note as the current +source of truth. + +## Document Precedence + +Planning docs should be read newest-first. Later docs capture newer product and +engineering decisions, so they supersede older SDD language when scope or +priority differs. + +For the current branch: + +1. `current-v1-scope.md` +2. `mcp-tasks-vs-explicit-async-tools.md` +3. `long-running-subscriptions-ux.md` +4. `dsx-exchange-mcp-sdd.md` +5. Earlier tradeoff, benchmark, discussion, and eval notes + +The SDD remains useful for architecture context, but it is broader than the +current implementation target. + +## In Scope For Current V1 + +Current v1 is a focused MCP interface for schema-aware, read-only access to DSX +Exchange topics. + +In scope: + +- Expose embedded AsyncAPI specs as MCP resources. +- Provide schema/topic discovery with `dsx_exchange_describe_topic` and + `dsx_exchange_find_topics`. +- Provide bounded MQTT reads with `dsx_exchange_read_retained` and + `dsx_exchange_subscribe`. +- Pass the caller bearer through to MQTT as the broker credential. +- Let the broker and auth-callout remain authoritative for topic ACL decisions. +- Return structured tool errors for missing bearer, invalid topics, broker + unavailability, auth failure, and ACL denial. +- Provide pod-local background watch tools: + - `dsx_exchange_start_subscription` + - `dsx_exchange_read_subscription` + - `dsx_exchange_subscription_status` + - `dsx_exchange_stop_subscription` +- Keep active watch state, MQTT connections, cursors, and raw ring buffers + pod-local and session-pinned. +- Use short TTLs, bounded buffers, per-session limits, per-pod limits, metrics, + and audit logs to keep this safe. +- Document that pod restart, pod eviction, rollout interruption, or MCP session + loss can end a watch and require the client to start a new one. + +## Explicitly Out Of Scope For Current V1 + +Do not treat these as current v1 gaps: + +- Filtering MCP resource or schema-tool discovery by caller permissions. +- Hiding schema domains or schema helper tools before the caller attempts a + broker-backed MQTT read. +- Adding a separate entitlement API solely for current v1 discovery filtering. +- Implementing `dsx_exchange_bms_metadata_snapshot`. +- Implementing `dsx_exchange_build_bms_graph`. +- Implementing `dsx_exchange_summarize_subscription`. +- Implementing `dsx_exchange_aggregate_subscription`. +- Implementing `dsx_exchange_export_subscription`. +- Implementing MCP notifications for watch events. +- Making watches durable across pod restart or cross-pod failover. +- Storing raw JWTs, refreshing caller tokens, or resuming MQTT clients without a + fresh authenticated request. +- Adding Valkey, Redis, JetStream consumers, or worker pods for v1 watch state. + +These may be revisited later, but they are not required to call the current +branch useful or complete for its intended scope. + +## Possible Later Work + +Aggregation is the most plausible next feature after this scope because it can +reduce high-volume streams into smaller operator-facing results. If added, it +should be introduced as a focused extension to the existing pod-local watch +model before adding distributed watch state. + +Durable watch state, external workers, cross-pod recovery, entitlement-driven +discovery filtering, graph construction, and export sinks should wait for clear +product demand or benchmark evidence. + +## Completion Bar + +For this scope, the branch is complete enough when: + +- Default MCP unit tests pass. +- Helm rendering/linting for the MCP chart passes. +- The MCP server can be deployed behind the gateway with stateful session + routing. +- A caller can discover schema topics, read retained metadata, collect bounded + live messages, and use start/read/status/stop background watches. +- Unauthorized MQTT topics fail through broker-backed structured errors instead + of being treated as empty data. +- Docs and examples describe the smaller v1 scope instead of implying the full + SDD backlog is required now. diff --git a/mcp/dsx-exchange-mcp/docs/load-testing.md b/mcp/dsx-exchange-mcp/docs/load-testing.md new file mode 100644 index 0000000..d38d1b8 --- /dev/null +++ b/mcp/dsx-exchange-mcp/docs/load-testing.md @@ -0,0 +1,231 @@ + + +# DSX Exchange MCP Load Testing + +This note explains how to reproduce the current load-test methodology and +records the stable findings from local gateway-backed experiments. Raw report +bundles are intentionally not committed; they belong under ignored `reports/`. + +## What The Harness Tests + +`cmd/dsx-exchange-mcp-load` creates many independent MCP clients. Each client +performs its own MCP initialize flow, keeps its own `Mcp-Session-Id`, lists +tools, and then runs one workload scenario. This is not an LLM test; it is a +protocol and backend-capacity test. + +Scenarios: + +| Scenario | Purpose | +| --- | --- | +| `discovery` | Exercise schema tools only: `find_topics` and `describe_topic`. | +| `schema-resources` | Exercise MCP resources: `resources/list` and `resources/read`. | +| `bounded-read` | Exercise broker-facing bounded reads: retained reads and short live subscribes. | +| `watch` | Start, read, status-check, and stop background watches. | +| `watch-hold` | Start watches and hold them open to measure startup and active-watch pressure. | +| `watch-status-hold` | Start watches, then poll aggregated `subscription_status`. | +| `sticky-check` | Verify a subscription can be read/statused/stopped on the same MCP session. | +| `mixed` | Mix schema tools, bounded MQTT tools, and background watches. | + +`deploy/loadtest/run-kind-load-experiment.sh` wraps the load binary for local +Kind/gateway experiments. It records the manifest, image IDs, token TTL +metadata, cluster state before/after, JSON/TXT/CSV reports, per-operation +latency, and per-operation error attribution. + +## Reproduction Requirements + +The load harness depends on systems outside this MCP repo. Before running it, +the operator needs: + +- a reachable MCP gateway `/mcp` endpoint +- the `dsx-exchange-mcp` backend deployed behind that gateway +- stateful MCP session routing enabled when testing background watches +- backend configuration for the target Event Bus MQTT endpoint through Helm + `natsURL` +- broker username configured through Helm `mqtt.username` +- broker TLS trust configured through Helm `mqtt.tls.caCertSecret.name/key` +- broker TLS server name configured through Helm `mqtt.tls.serverName` when the + certificate requires it +- a fresh caller JWT from the approved secret manager or Vault flow +- the JWT available to the load job as a Kubernetes secret named + `dsx-exchange-mcp-load-token` with key `bearer` +- the MCP backend image and load-generator image available to the cluster + +Do not commit tokens, CA material, cluster snapshots, local endpoint names, or +raw generated report bundles. + +## Build And Run Path + +Start from the MCP module root: + +```sh +make sync-specs +make image +make load-image +``` + +`make image` builds the backend image as `dsx-exchange-mcp:dev`. +`make load-image` builds the load-generator image as +`dsx-exchange-mcp-load:dev`. Make those images available to the local cluster or +registry using the repo's existing deployment flow. + +After the gateway, backend, broker, CA trust, and load token secret are in +place, run the reusable wrapper: + +```sh +deploy/loadtest/run-kind-load-experiment.sh +``` + +The wrapper creates a Kubernetes Job for the load generator, applies the +selected gateway rate-limit helper when requested, records backend and load +image IDs, captures cluster state, and writes the report bundle under ignored +`reports/`. + +## Setup Checklist + +Use this checklist before treating load-test failures as MCP bugs: + +| Check | Expected state | +| --- | --- | +| Gateway endpoint | MCP clients can reach the gateway `/mcp` endpoint. | +| Gateway bearer passthrough | The caller bearer reaches `dsx-exchange-mcp` as `Authorization`. | +| Stateful routing | Calls with the same `Mcp-Session-Id` route to the same backend pod. | +| Broker endpoint | Backend `NATS_URL` points at the intended MQTT endpoint. | +| Broker username | Backend `MQTT_USERNAME` matches the broker OAuth profile. | +| Broker CA | Backend has a mounted CA file and `MQTT_TLS_CA_FILE` points to it. | +| TLS server name | Backend server-name override matches the broker certificate when required. | +| Load JWT secret | Load namespace has `dsx-exchange-mcp-load-token` with data key `bearer`. | +| JWT freshness | Token TTL is long enough for the full experiment. | +| Topic ACLs | The load topics are authorized for the caller identity. | + +If `discovery` passes but `bounded-read`, `watch`, or `mixed` fail, the next +checks are bearer freshness, broker CA/server-name settings, topic ACLs, broker +availability, and MQTT admission limits. + +## Important Knobs + +Record these for every run so the result is reproducible: + +| Knob | Meaning | +| --- | --- | +| `SCENARIO` | Workload shape, such as `discovery`, `mixed`, or `watch-status-hold`. | +| `SESSION_SWEEP` / `SESSIONS` | Concurrent MCP client/session counts. | +| `STARTUP_RAMP` | Time window used to spread client startup. `0s` means an instant startup burst. | +| `DURATION` | Total wall-clock runtime after the load job starts. | +| `GATEWAY_RPS` | Gateway tenant rate-limit setting used during the run. | +| `CLIENT_RPS` | Load-generator request rate limit. | +| `BACKEND_REPLICAS` | Number of `dsx-exchange-mcp` backend pods. | +| `BACKEND_CONNECT_TIMEOUT_S` | Backend MQTT connect timeout. | +| `BACKEND_SUBSCRIBE_TIMEOUT_S` | Backend MQTT subscribe timeout. | +| `BACKEND_COLLECT_MAX_CONCURRENT` | Per-pod admission limit for bounded MQTT collectors. | +| `BACKEND_WATCH_START_MAX_CONCURRENT` | Per-pod admission limit for watch startup. | +| `TOPIC` / `RETAINED_TOPIC` | Allowed live and retained topic filters used by broker-facing scenarios. | +| `RESET_BACKEND` | Whether the backend is restarted before the run. | + +Ramp and reset are important. A zero-ramp run measures thundering-herd startup +behavior. A ramped run is closer to organic production growth and makes it +easier to separate steady-state capacity from burst admission failures. Resetting +the backend before a run makes startup cost visible and avoids carrying state +from an earlier experiment. + +## Findings From Current Experiments + +These findings came from local Kind/gateway experiments using 100, 500, and +1000 MCP sessions, 1-3 backend replicas, 30s/60s startup ramps, raised gateway +RPS variants, and MQTT timeout/admission experiments. + +### Schema Discovery Is Mostly Healthy + +The `discovery` scenario, which only calls `find_topics` and `describe_topic`, +was healthy through the gateway: + +| Sessions | Backend replicas | Startup ramp | Success | +| ---: | ---: | ---: | ---: | +| 100 | 1 | 30s | 99.70% | +| 500 | 1 | 30s | 98.62% | +| 1000 | 1 | 30s | 97.39% | + +Failures were mostly client context deadlines near the wall-clock end of the +run, plus a small number of HTTP request failures. This indicates the schema +tools themselves are not the bottleneck. + +### Gateway Resource Proxy Needs Follow-Up + +The `schema-resources` scenario was unhealthy through the gateway: + +| Sessions | Backend replicas | Startup ramp | Success | +| ---: | ---: | ---: | ---: | +| 100 | 1 | 30s | 0.75% | +| 500 | 1 | 30s | 3.10% | +| 1000 | 1 | 30s | 6.41% | + +Direct backend validation of the same resource methods passed at 100%. That +points to gateway resource proxy/config/protocol behavior rather than backend +resource registration. + +### Mixed Load Bottlenecks On MQTT-Backed Tools + +The `mixed` scenario combines cheap schema calls with expensive broker-facing +calls. At high session counts, failures were dominated by: + +- bounded `subscribe` +- `read_retained` +- `start_subscription` +- MQTT admission limiting +- broker unavailable or MQTT subscribe/connect deadline errors + +Schema tools in the same mixed run had only small deadline/HTTP failures. The +reason is shared-path pressure: even a cheap schema call still goes through the +client, gateway, session lookup/routing, backend HTTP handler, JSON-RPC decode, +tool dispatch, JSON encode, and response path. When many MQTT calls are waiting +on connect/subscribe or admission, they consume shared gateway/backend capacity +and cheap calls can miss client deadlines. + +### Watch Status Scales Better Than Mixed Bounded MQTT + +`watch-status-hold` performed much better than mixed load once watches were +started and clients mostly polled `subscription_status`: + +| Scenario | Replicas | Sessions | Success | +| --- | ---: | ---: | ---: | +| `watch-status-hold` | 1 | 500 | 99.996% | +| `watch-status-hold` | 2 | 500 | 100.000% | +| `watch-status-hold` | 3 | 500 | 100.000% | +| `watch-status-hold` | 2 | 1000 | 98.743% | +| `watch-status-hold` | 3 | 1000 | 98.864% | + +This supports the v1 UX direction: prefer aggregated `subscription_status` for +operator-facing follow-up rather than repeatedly returning raw buffered data. + +### Replicas Help Steady State, Not Broker Startup Storms + +Adding backend replicas helped the watch-status steady state, but it did not +fix the mixed workload. The dominant mixed-load cost was concurrent MQTT +connect/subscribe against the external broker, not pure CPU work inside one MCP +pod. More pods can increase the number of simultaneous broker connection +attempts, so scaling replicas without admission control can move the bottleneck +to the broker/auth/network path faster. + +### Timeout Alone Is Not A Fix + +Raising MQTT connect/subscribe timeouts from 10s to 20s or 30s did not resolve +high-concurrency mixed failures. Longer timeouts can reduce premature failures +when the system is merely slow, but they can also hide overload by making +requests sit in limbo longer. Admission limits and startup ramping gave clearer +signals about whether a run was burst-limited or steady-state-limited. + +## Current Interpretation + +For current v1, the MCP server is useful and bounded when: + +- schema discovery is used freely +- retained/live bounded reads stay small +- background watches are pod-local and session-pinned +- `subscription_status` is the normal follow-up surface +- raw `read_subscription` is treated as detail/debug, not the primary UX + +The next scale work should focus on gateway resource handling, sticky-session +validation, MQTT startup backpressure, and pod-failure behavior before adding +durable external watch state. diff --git a/mcp/dsx-exchange-mcp/internal/auth/context.go b/mcp/dsx-exchange-mcp/internal/auth/context.go index dc5ff39..0818787 100644 --- a/mcp/dsx-exchange-mcp/internal/auth/context.go +++ b/mcp/dsx-exchange-mcp/internal/auth/context.go @@ -27,19 +27,37 @@ type Caller struct { // from the HTTP request and stores them on the request context. func Middleware(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - caller := Caller{ - Bearer: bearerFromHeader(r.Header.Get("Authorization")), - SessionID: r.Header.Get("Mcp-Session-Id"), - Tenant: r.Header.Get("x-mcp-tenant"), - Issuer: r.Header.Get("x-mcp-issuer"), - Subject: r.Header.Get("x-mcp-sub"), - SpiffeID: r.Header.Get("x-mcp-spiffe-id"), - } - r = r.WithContext(context.WithValue(r.Context(), ctxKey{}, caller)) + r = r.WithContext(WithCaller(r.Context(), CallerFromHeaders(r.Header))) next.ServeHTTP(w, r) }) } +// CallerFromHeaders extracts caller identity material from gateway-projected +// HTTP headers. +func CallerFromHeaders(h http.Header) Caller { + return Caller{ + Bearer: bearerFromHeader(h.Get("Authorization")), + SessionID: h.Get("Mcp-Session-Id"), + Tenant: h.Get("x-mcp-tenant"), + Issuer: h.Get("x-mcp-issuer"), + Subject: h.Get("x-mcp-sub"), + SpiffeID: h.Get("x-mcp-spiffe-id"), + } +} + +// WithCaller stores caller identity material on ctx. +func WithCaller(ctx context.Context, caller Caller) context.Context { + return context.WithValue(ctx, ctxKey{}, caller) +} + +// WithSessionID returns a context whose caller includes sessionID. Other caller +// fields already present on ctx are preserved. +func WithSessionID(ctx context.Context, sessionID string) context.Context { + caller := FromContext(ctx) + caller.SessionID = sessionID + return WithCaller(ctx, caller) +} + func bearerFromHeader(h string) string { const prefix = "Bearer " if !strings.HasPrefix(strings.ToLower(h), strings.ToLower(prefix)) { diff --git a/mcp/dsx-exchange-mcp/internal/auth/context_test.go b/mcp/dsx-exchange-mcp/internal/auth/context_test.go index 568f187..dd4e30c 100644 --- a/mcp/dsx-exchange-mcp/internal/auth/context_test.go +++ b/mcp/dsx-exchange-mcp/internal/auth/context_test.go @@ -17,6 +17,7 @@ func TestMiddlewareStoresCaller(t *testing.T) { req := httptest.NewRequest(http.MethodPost, "/mcp", nil) req.Header.Set("Authorization", "Bearer token-123") + req.Header.Set("Mcp-Session-Id", "session-123") req.Header.Set("x-mcp-tenant", "tenant-a") req.Header.Set("x-mcp-issuer", "https://issuer") req.Header.Set("x-mcp-sub", "tenant-a/agent") @@ -27,6 +28,9 @@ func TestMiddlewareStoresCaller(t *testing.T) { if got.Bearer != "token-123" { t.Fatalf("Bearer = %q, want token-123", got.Bearer) } + if got.SessionID != "session-123" { + t.Fatalf("SessionID = %q, want session-123", got.SessionID) + } if got.Tenant != "tenant-a" || got.Issuer != "https://issuer" || got.Subject != "tenant-a/agent" { t.Fatalf("caller identity not propagated: %+v", got) } @@ -48,3 +52,22 @@ func TestBearerSchemeIsCaseInsensitive(t *testing.T) { t.Fatalf("Bearer = %q, want token-123", got) } } + +func TestWithSessionIDPreservesCaller(t *testing.T) { + ctx := WithCaller(t.Context(), Caller{ + Bearer: "token-123", + Tenant: "tenant-a", + Issuer: "https://issuer", + Subject: "tenant-a/agent", + SpiffeID: "spiffe://tenant-a/agent/tenant-a%2Fagent", + }) + + got := FromContext(WithSessionID(ctx, "session-123")) + + if got.Bearer != "token-123" || got.Tenant != "tenant-a" || got.Subject != "tenant-a/agent" { + t.Fatalf("caller fields not preserved: %+v", got) + } + if got.SessionID != "session-123" { + t.Fatalf("SessionID = %q, want session-123", got.SessionID) + } +} diff --git a/mcp/dsx-exchange-mcp/internal/metrics/metrics.go b/mcp/dsx-exchange-mcp/internal/metrics/metrics.go index 1eb30ba..a26ed58 100644 --- a/mcp/dsx-exchange-mcp/internal/metrics/metrics.go +++ b/mcp/dsx-exchange-mcp/internal/metrics/metrics.go @@ -6,6 +6,7 @@ package metrics import ( "fmt" "net/http" + "runtime" "sort" "strings" "sync" @@ -17,15 +18,19 @@ import ( // a dependency on a metrics framework. The service can swap this for OTel or // prometheus/client_golang later without changing tool code. type Recorder struct { - activeCalls int64 - activeWatches int64 - watchMessages uint64 - watchDropped uint64 + activeCalls int64 + activeWatches int64 + activeMQTTConnections int64 + watchMessages uint64 + watchDropped uint64 mu sync.Mutex + sessionTTL time.Duration + sessions map[string]time.Time toolCalls map[string]uint64 toolErrors map[labelKey]uint64 toolDuration map[string]time.Duration + toolBuckets map[labelKey]uint64 messageCounts map[string]uint64 stoppedReasons map[labelKey]uint64 } @@ -37,9 +42,12 @@ type labelKey struct { func NewRecorder() *Recorder { return &Recorder{ + sessionTTL: 10 * time.Minute, + sessions: map[string]time.Time{}, toolCalls: map[string]uint64{}, toolErrors: map[labelKey]uint64{}, toolDuration: map[string]time.Duration{}, + toolBuckets: map[labelKey]uint64{}, messageCounts: map[string]uint64{}, stoppedReasons: map[labelKey]uint64{}, } @@ -53,6 +61,29 @@ func (r *Recorder) EndToolCall() { atomic.AddInt64(&r.activeCalls, -1) } +func (r *Recorder) ObserveSession(sessionID string) { + if r == nil || strings.TrimSpace(sessionID) == "" { + return + } + r.mu.Lock() + defer r.mu.Unlock() + r.sessions[sessionID] = time.Now() +} + +func (r *Recorder) BeginMQTTConnection() { + if r == nil { + return + } + atomic.AddInt64(&r.activeMQTTConnections, 1) +} + +func (r *Recorder) EndMQTTConnection() { + if r == nil { + return + } + atomic.AddInt64(&r.activeMQTTConnections, -1) +} + func (r *Recorder) BeginWatch() { if r == nil { return @@ -90,6 +121,11 @@ func (r *Recorder) RecordToolCall(tool, code, stoppedReason string, duration tim r.toolCalls[tool]++ r.toolDuration[tool] += duration + for _, bucket := range durationBuckets { + if duration.Seconds() <= bucket { + r.toolBuckets[labelKey{Tool: tool, Value: formatBucket(bucket)}]++ + } + } if messages > 0 { r.messageCounts[tool] += uint64(messages) } @@ -101,6 +137,8 @@ func (r *Recorder) RecordToolCall(tool, code, stoppedReason string, duration tim } } +var durationBuckets = []float64{0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1, 2.5, 5, 10, 30} + func (r *Recorder) Handler() http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { w.Header().Set("Content-Type", "text/plain; version=0.0.4; charset=utf-8") @@ -115,13 +153,22 @@ func (r *Recorder) writePrometheus(w http.ResponseWriter) { } r.mu.Lock() + activeSessions := r.activeSessionCountLocked(time.Now()) toolCalls := cloneStringMap(r.toolCalls) toolDuration := cloneDurationMap(r.toolDuration) + toolBuckets := cloneLabelMap(r.toolBuckets) messageCounts := cloneStringMap(r.messageCounts) toolErrors := cloneLabelMap(r.toolErrors) stoppedReasons := cloneLabelMap(r.stoppedReasons) r.mu.Unlock() + var mem runtime.MemStats + runtime.ReadMemStats(&mem) + + fmt.Fprintln(w, "# HELP dsx_exchange_mcp_active_sessions_recent MCP sessions observed on this pod within the recent-session TTL.") + fmt.Fprintln(w, "# TYPE dsx_exchange_mcp_active_sessions_recent gauge") + fmt.Fprintf(w, "dsx_exchange_mcp_active_sessions_recent %d\n", activeSessions) + fmt.Fprintln(w, "# HELP dsx_exchange_mcp_active_tool_calls Tool calls currently in flight.") fmt.Fprintln(w, "# TYPE dsx_exchange_mcp_active_tool_calls gauge") fmt.Fprintf(w, "dsx_exchange_mcp_active_tool_calls %d\n", atomic.LoadInt64(&r.activeCalls)) @@ -130,6 +177,22 @@ func (r *Recorder) writePrometheus(w http.ResponseWriter) { fmt.Fprintln(w, "# TYPE dsx_exchange_mcp_active_background_watches gauge") fmt.Fprintf(w, "dsx_exchange_mcp_active_background_watches %d\n", atomic.LoadInt64(&r.activeWatches)) + fmt.Fprintln(w, "# HELP dsx_exchange_mcp_active_mqtt_connections MQTT connections currently open by this pod.") + fmt.Fprintln(w, "# TYPE dsx_exchange_mcp_active_mqtt_connections gauge") + fmt.Fprintf(w, "dsx_exchange_mcp_active_mqtt_connections %d\n", atomic.LoadInt64(&r.activeMQTTConnections)) + + fmt.Fprintln(w, "# HELP dsx_exchange_mcp_runtime_goroutines Goroutines currently running in this pod.") + fmt.Fprintln(w, "# TYPE dsx_exchange_mcp_runtime_goroutines gauge") + fmt.Fprintf(w, "dsx_exchange_mcp_runtime_goroutines %d\n", runtime.NumGoroutine()) + + fmt.Fprintln(w, "# HELP dsx_exchange_mcp_runtime_heap_alloc_bytes Bytes of allocated heap objects.") + fmt.Fprintln(w, "# TYPE dsx_exchange_mcp_runtime_heap_alloc_bytes gauge") + fmt.Fprintf(w, "dsx_exchange_mcp_runtime_heap_alloc_bytes %d\n", mem.HeapAlloc) + + fmt.Fprintln(w, "# HELP dsx_exchange_mcp_runtime_sys_bytes Bytes of memory obtained from the OS.") + fmt.Fprintln(w, "# TYPE dsx_exchange_mcp_runtime_sys_bytes gauge") + fmt.Fprintf(w, "dsx_exchange_mcp_runtime_sys_bytes %d\n", mem.Sys) + fmt.Fprintln(w, "# HELP dsx_exchange_mcp_tool_calls_total Total tool calls by tool.") fmt.Fprintln(w, "# TYPE dsx_exchange_mcp_tool_calls_total counter") for _, tool := range sortedKeys(toolCalls) { @@ -142,10 +205,16 @@ func (r *Recorder) writePrometheus(w http.ResponseWriter) { fmt.Fprintf(w, "dsx_exchange_mcp_tool_errors_total{tool=\"%s\",code=\"%s\"} %d\n", promLabel(k.Tool), promLabel(k.Value), toolErrors[k]) } - fmt.Fprintln(w, "# HELP dsx_exchange_mcp_tool_duration_seconds_sum Total tool duration by tool.") - fmt.Fprintln(w, "# TYPE dsx_exchange_mcp_tool_duration_seconds_sum counter") - for _, tool := range sortedDurationKeys(toolDuration) { + fmt.Fprintln(w, "# HELP dsx_exchange_mcp_tool_duration_seconds Tool duration histogram by tool.") + fmt.Fprintln(w, "# TYPE dsx_exchange_mcp_tool_duration_seconds histogram") + for _, tool := range sortedKeys(toolCalls) { + for _, bucket := range durationBuckets { + le := formatBucket(bucket) + fmt.Fprintf(w, "dsx_exchange_mcp_tool_duration_seconds_bucket{tool=\"%s\",le=\"%s\"} %d\n", promLabel(tool), le, toolBuckets[labelKey{Tool: tool, Value: le}]) + } + fmt.Fprintf(w, "dsx_exchange_mcp_tool_duration_seconds_bucket{tool=\"%s\",le=\"+Inf\"} %d\n", promLabel(tool), toolCalls[tool]) fmt.Fprintf(w, "dsx_exchange_mcp_tool_duration_seconds_sum{tool=\"%s\"} %.6f\n", promLabel(tool), toolDuration[tool].Seconds()) + fmt.Fprintf(w, "dsx_exchange_mcp_tool_duration_seconds_count{tool=\"%s\"} %d\n", promLabel(tool), toolCalls[tool]) } fmt.Fprintln(w, "# HELP dsx_exchange_mcp_mqtt_messages_collected_total MQTT messages returned by tool.") @@ -169,6 +238,19 @@ func (r *Recorder) writePrometheus(w http.ResponseWriter) { fmt.Fprintf(w, "dsx_exchange_mcp_background_watch_dropped_messages_total %d\n", atomic.LoadUint64(&r.watchDropped)) } +func (r *Recorder) activeSessionCountLocked(now time.Time) int { + if r.sessionTTL <= 0 { + r.sessionTTL = 10 * time.Minute + } + cutoff := now.Add(-r.sessionTTL) + for sessionID, lastSeen := range r.sessions { + if lastSeen.Before(cutoff) { + delete(r.sessions, sessionID) + } + } + return len(r.sessions) +} + func cloneStringMap(in map[string]uint64) map[string]uint64 { out := make(map[string]uint64, len(in)) for k, v := range in { @@ -202,15 +284,6 @@ func sortedKeys(m map[string]uint64) []string { return out } -func sortedDurationKeys(m map[string]time.Duration) []string { - out := make([]string, 0, len(m)) - for k := range m { - out = append(out, k) - } - sort.Strings(out) - return out -} - func sortedLabelKeys(m map[labelKey]uint64) []labelKey { out := make([]labelKey, 0, len(m)) for k := range m { @@ -228,3 +301,9 @@ func sortedLabelKeys(m map[labelKey]uint64) []labelKey { func promLabel(s string) string { return strings.NewReplacer("\\", "\\\\", "\n", "\\n", "\"", "\\\"").Replace(s) } + +func formatBucket(v float64) string { + s := fmt.Sprintf("%.3f", v) + s = strings.TrimRight(s, "0") + return strings.TrimRight(s, ".") +} diff --git a/mcp/dsx-exchange-mcp/internal/metrics/metrics_test.go b/mcp/dsx-exchange-mcp/internal/metrics/metrics_test.go new file mode 100644 index 0000000..3c02e0e --- /dev/null +++ b/mcp/dsx-exchange-mcp/internal/metrics/metrics_test.go @@ -0,0 +1,60 @@ +// Copyright 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +package metrics + +import ( + "net/http/httptest" + "strings" + "testing" + "time" +) + +func TestRecorderPrometheusSurfaceIncludesLoadSignals(t *testing.T) { + rec := NewRecorder() + rec.ObserveSession("session-a") + rec.BeginToolCall() + rec.EndToolCall() + rec.BeginWatch() + rec.EndWatch() + rec.BeginMQTTConnection() + rec.EndMQTTConnection() + rec.RecordWatchMessage() + rec.RecordWatchDrop(2) + rec.RecordToolCall("dsx_exchange_subscription_status", "", "", 20*time.Millisecond, 0) + + req := httptest.NewRequest("GET", "/metrics", nil) + resp := httptest.NewRecorder() + rec.Handler().ServeHTTP(resp, req) + body := resp.Body.String() + + for _, want := range []string{ + "dsx_exchange_mcp_active_sessions_recent 1", + "dsx_exchange_mcp_active_background_watches 0", + "dsx_exchange_mcp_active_mqtt_connections 0", + "dsx_exchange_mcp_runtime_goroutines", + "dsx_exchange_mcp_runtime_heap_alloc_bytes", + "dsx_exchange_mcp_tool_calls_total{tool=\"dsx_exchange_subscription_status\"} 1", + "dsx_exchange_mcp_tool_duration_seconds_bucket{tool=\"dsx_exchange_subscription_status\",le=\"0.025\"} 1", + "dsx_exchange_mcp_background_watch_messages_total 1", + "dsx_exchange_mcp_background_watch_dropped_messages_total 2", + } { + if !strings.Contains(body, want) { + t.Fatalf("metrics body missing %q:\n%s", want, body) + } + } +} + +func TestRecorderPrunesStaleSessions(t *testing.T) { + rec := NewRecorder() + rec.sessionTTL = time.Minute + rec.sessions["old"] = time.Now().Add(-2 * time.Minute) + rec.sessions["new"] = time.Now() + + if got := rec.activeSessionCountLocked(time.Now()); got != 1 { + t.Fatalf("active sessions = %d, want 1", got) + } + if _, ok := rec.sessions["old"]; ok { + t.Fatal("stale session was not pruned") + } +} diff --git a/mcp/dsx-exchange-mcp/internal/mqttbus/client.go b/mcp/dsx-exchange-mcp/internal/mqttbus/client.go index 45a4ccc..f0d009d 100644 --- a/mcp/dsx-exchange-mcp/internal/mqttbus/client.go +++ b/mcp/dsx-exchange-mcp/internal/mqttbus/client.go @@ -32,6 +32,7 @@ const ( CodeTopicACLDenied = "topic_acl_denied" CodeMQTTAuthorizationFailed = "mqtt_authorization_failed" CodeMQTTSubscribeFailed = "mqtt_subscribe_failed" + CodeMQTTAdmissionLimited = "mqtt_admission_limited" CodeInternalError = "internal_error" ) @@ -51,6 +52,7 @@ type Config struct { ConnectTimeout time.Duration SubscribeTimeout time.Duration MaxResultBytes int + Metrics MetricsRecorder } type TLSConfig struct { @@ -59,6 +61,11 @@ type TLSConfig struct { InsecureSkipVerify bool } +type MetricsRecorder interface { + BeginMQTTConnection() + EndMQTTConnection() +} + // Message is a single MQTT message captured from the bus. type Message struct { Topic string `json:"topic"` @@ -90,9 +97,10 @@ type StreamResult struct { } type BusError struct { - Code string - Message string - Err error + Code string + Message string + Err error + RetryAfterSeconds int } func (e *BusError) Error() string { @@ -230,6 +238,10 @@ func Collect( } else if tok.Error() != nil { return out, classifyConnectError(tok.Error()) } + if cfg.Metrics != nil { + cfg.Metrics.BeginMQTTConnection() + defer cfg.Metrics.EndMQTTConnection() + } defer c.Disconnect(250) if tok := c.Subscribe(topicFilter, 0, nil); !tok.WaitTimeout(subscribeTimeout) { @@ -416,6 +428,10 @@ func Stream( } else if tok.Error() != nil { return out, classifyConnectError(tok.Error()) } + if cfg.Metrics != nil { + cfg.Metrics.BeginMQTTConnection() + defer cfg.Metrics.EndMQTTConnection() + } defer c.Disconnect(250) if tok := c.Subscribe(topicFilter, 0, nil); !tok.WaitTimeout(subscribeTimeout) { diff --git a/mcp/dsx-exchange-mcp/internal/server/admission.go b/mcp/dsx-exchange-mcp/internal/server/admission.go new file mode 100644 index 0000000..9d1e968 --- /dev/null +++ b/mcp/dsx-exchange-mcp/internal/server/admission.go @@ -0,0 +1,37 @@ +// Copyright 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +package server + +type admissionLimiter struct { + ch chan struct{} +} + +func newAdmissionLimiter(limit int) *admissionLimiter { + if limit <= 0 { + return nil + } + return &admissionLimiter{ch: make(chan struct{}, limit)} +} + +func (l *admissionLimiter) tryAcquire() bool { + if l == nil { + return true + } + select { + case l.ch <- struct{}{}: + return true + default: + return false + } +} + +func (l *admissionLimiter) release() { + if l == nil { + return + } + select { + case <-l.ch: + default: + } +} diff --git a/mcp/dsx-exchange-mcp/internal/server/e2e_test.go b/mcp/dsx-exchange-mcp/internal/server/e2e_test.go index 5376fa9..ee022d4 100644 --- a/mcp/dsx-exchange-mcp/internal/server/e2e_test.go +++ b/mcp/dsx-exchange-mcp/internal/server/e2e_test.go @@ -144,6 +144,260 @@ func TestStagedMCPSchemaDescribeThroughEndpoint(t *testing.T) { } } +func TestStagedMCPWatchThroughEndpoint(t *testing.T) { + if os.Getenv("RUN_EXCHANGE_MCP_WATCH_E2E") != "1" { + t.Skip("set RUN_EXCHANGE_MCP_WATCH_E2E=1 to run staged MCP background watch e2e") + } + + endpoint := requiredEnv(t, "DSX_EXCHANGE_MCP_URL") + bearer := requiredEnv(t, "DSX_EXCHANGE_E2E_BEARER") + allowedTopic := requiredEnv(t, "DSX_EXCHANGE_E2E_ALLOWED_TOPIC") + + ctx, cancel := context.WithTimeout(context.Background(), 90*time.Second) + defer cancel() + + client := &mcpHTTPClient{ + endpoint: endpoint, + bearer: bearer, + httpc: &http.Client{Timeout: 30 * time.Second}, + } + + sessionID, err := client.initialize(ctx) + if err != nil { + t.Fatalf("initialize through MCP endpoint failed: %v", err) + } + if sessionID == "" { + t.Fatal("initialize returned empty MCP session ID") + } + if err := client.initialized(ctx, sessionID); err != nil { + t.Fatalf("notifications/initialized failed: %v", err) + } + + tools, err := client.listTools(ctx, sessionID) + if err != nil { + t.Fatalf("tools/list failed: %v", err) + } + startTool := chooseStartSubscriptionToolName(tools, os.Getenv("DSX_EXCHANGE_E2E_START_TOOL_NAME")) + readTool := chooseReadSubscriptionToolName(tools, os.Getenv("DSX_EXCHANGE_E2E_READ_TOOL_NAME")) + statusTool := chooseStatusSubscriptionToolName(tools, os.Getenv("DSX_EXCHANGE_E2E_STATUS_TOOL_NAME")) + stopTool := chooseStopSubscriptionToolName(tools, os.Getenv("DSX_EXCHANGE_E2E_STOP_TOOL_NAME")) + if startTool == "" || readTool == "" || statusTool == "" || stopTool == "" { + t.Fatalf("tools/list missing watch tool(s): start=%q read=%q status=%q stop=%q tools=%v", startTool, readTool, statusTool, stopTool, tools) + } + + started, err := client.callTool(ctx, sessionID, startTool, map[string]any{ + "topic_filter": allowedTopic, + "ttl_seconds": 30, + "buffer_max_messages": 10, + "buffer_max_bytes": 32768, + }) + if err != nil { + t.Fatalf("tools/call(%q start watch) failed: %v", startTool, err) + } + if started.IsError { + t.Fatalf("tools/call(%q start watch) returned MCP tool error: %s", startTool, started.textSummary()) + } + var startOut watchStartOutput + if err := json.Unmarshal([]byte(started.lastText()), &startOut); err != nil { + t.Fatalf("decode watch start response: %v; content=%s", err, started.textSummary()) + } + if startOut.SubscriptionID == "" { + t.Fatalf("watch start response missing subscription_id: %#v", startOut) + } + t.Logf("started watch %s with status %s on %s", startOut.SubscriptionID, startOut.Status, startOut.TopicFilter) + + status, err := client.callTool(ctx, sessionID, statusTool, map[string]any{ + "subscription_id": startOut.SubscriptionID, + }) + if err != nil { + t.Fatalf("tools/call(%q status watch) failed: %v", statusTool, err) + } + if status.IsError { + t.Fatalf("tools/call(%q status watch) returned MCP tool error: %s", statusTool, status.textSummary()) + } + var statusOut watchStatusOutput + if err := json.Unmarshal([]byte(status.lastText()), &statusOut); err != nil { + t.Fatalf("decode watch status response: %v; content=%s", err, status.textSummary()) + } + if statusOut.SubscriptionID != startOut.SubscriptionID { + t.Fatalf("watch status subscription_id = %q, want %q", statusOut.SubscriptionID, startOut.SubscriptionID) + } + + read, err := client.callTool(ctx, sessionID, readTool, map[string]any{ + "subscription_id": startOut.SubscriptionID, + "cursor": startOut.Cursor, + "max_messages": 10, + "max_bytes": 32768, + }) + if err != nil { + t.Fatalf("tools/call(%q read watch) failed: %v", readTool, err) + } + if read.IsError { + t.Fatalf("tools/call(%q read watch) returned MCP tool error: %s", readTool, read.textSummary()) + } + var readOut watchReadOutput + if err := json.Unmarshal([]byte(read.lastText()), &readOut); err != nil { + t.Fatalf("decode watch read response: %v; content=%s", err, read.textSummary()) + } + if readOut.SubscriptionID != startOut.SubscriptionID { + t.Fatalf("watch read subscription_id = %q, want %q", readOut.SubscriptionID, startOut.SubscriptionID) + } + + stopped, err := client.callTool(ctx, sessionID, stopTool, map[string]any{ + "subscription_id": startOut.SubscriptionID, + }) + if err != nil { + t.Fatalf("tools/call(%q stop watch) failed: %v", stopTool, err) + } + if stopped.IsError { + t.Fatalf("tools/call(%q stop watch) returned MCP tool error: %s", stopTool, stopped.textSummary()) + } + var stopOut watchStopOutput + if err := json.Unmarshal([]byte(stopped.lastText()), &stopOut); err != nil { + t.Fatalf("decode watch stop response: %v; content=%s", err, stopped.textSummary()) + } + if stopOut.SubscriptionID != startOut.SubscriptionID || stopOut.Status != watchStatusStopped { + t.Fatalf("watch stop response = %#v, want stopped %q", stopOut, startOut.SubscriptionID) + } +} + +func TestStagedMCPQualityFixturesThroughEndpoint(t *testing.T) { + if os.Getenv("RUN_EXCHANGE_MCP_QUALITY_E2E") != "1" { + t.Skip("set RUN_EXCHANGE_MCP_QUALITY_E2E=1 to run staged MCP quality fixture replay") + } + + executeLiveTools := os.Getenv("DSX_EXCHANGE_MCP_QUALITY_EXECUTE_LIVE_TOOLS") == "1" + if executeLiveTools && os.Getenv("DSX_EXCHANGE_MCP_URL") == "" { + t.Fatal("DSX_EXCHANGE_MCP_URL is required when DSX_EXCHANGE_MCP_QUALITY_EXECUTE_LIVE_TOOLS=1") + } + liveMaxDurationS := envIntDefault("DSX_EXCHANGE_MCP_QUALITY_MAX_DURATION_S", 1) + + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute) + defer cancel() + + client, cleanup, endpointLabel := newLLMEvalMCPClient(t) + defer cleanup() + t.Logf("replaying quality fixtures through %s", endpointLabel) + + sessionID, err := client.initialize(ctx) + if err != nil { + t.Fatalf("initialize through MCP endpoint failed: %v", err) + } + if sessionID == "" { + t.Fatal("initialize returned empty MCP session ID") + } + if err := client.initialized(ctx, sessionID); err != nil { + t.Fatalf("notifications/initialized failed: %v", err) + } + + tools, err := client.listTools(ctx, sessionID) + if err != nil { + t.Fatalf("tools/list failed: %v", err) + } + fixtures := selectQualityFixtures(t, loadToolCallFixtures(t)) + + for _, fixture := range fixtures { + t.Run(fixture.ID, func(t *testing.T) { + seenChannels := map[string]bool{} + seenRelatedTopics := map[string]bool{} + described := false + + for i, call := range fixture.ExpectedToolCalls { + toolName := chooseToolName(tools, call.Tool, "") + if toolName == "" { + t.Fatalf("tools/list missing expected tool %q; saw %v", call.Tool, tools) + } + + switch call.Tool { + case toolDescribeTopic: + described = true + res, err := client.callTool(ctx, sessionID, toolName, call.Arguments) + if err != nil { + t.Fatalf("tools/call(%q fixture call %d) failed: %v", toolName, i, err) + } + if res.IsError { + t.Fatalf("tools/call(%q fixture call %d) returned MCP tool error: %s", toolName, i, res.textSummary()) + } + out := decodeDescribeTopicResponse(t, res, fixture.ID, i) + if out.TopicFilter != stringArg(t, fixture.ID, i, call.Arguments, "topic_filter") { + t.Fatalf("schema response topic_filter = %q, want fixture call %d topic_filter", out.TopicFilter, i) + } + if out.Count == 0 { + t.Fatalf("schema response for fixture call %d returned no matches", i) + } + for _, match := range out.Matches { + if match.Domain == fixture.ExpectedSchema.Domain { + seenChannels[match.Channel] = true + } + for _, related := range match.RelatedTopics { + seenRelatedTopics[related.TopicFilter] = true + } + } + case toolReadRetained, toolSubscribe: + if !executeLiveTools { + t.Logf("skipping live fixture call %d %s; set DSX_EXCHANGE_MCP_QUALITY_EXECUTE_LIVE_TOOLS=1 to execute", i, call.Tool) + continue + } + args := qualityLiveArguments(call, liveMaxDurationS) + res, err := client.callTool(ctx, sessionID, toolName, args) + if err != nil { + t.Fatalf("tools/call(%q fixture call %d) failed: %v", toolName, i, err) + } + if res.IsError { + t.Fatalf("tools/call(%q fixture call %d) returned MCP tool error: %s", toolName, i, res.textSummary()) + } + validateCollectResponseShape(t, res, fixture.ID, i) + case toolStartSubscription: + if !executeLiveTools { + t.Logf("skipping live fixture call %d %s; set DSX_EXCHANGE_MCP_QUALITY_EXECUTE_LIVE_TOOLS=1 to execute", i, call.Tool) + continue + } + args := qualityLiveArguments(call, liveMaxDurationS) + res, err := client.callTool(ctx, sessionID, toolName, args) + if err != nil { + t.Fatalf("tools/call(%q fixture call %d) failed: %v", toolName, i, err) + } + if res.IsError { + t.Fatalf("tools/call(%q fixture call %d) returned MCP tool error: %s", toolName, i, res.textSummary()) + } + startOut := validateWatchStartResponseShape(t, res, fixture.ID, i) + stopTool := chooseStopSubscriptionToolName(tools, "") + if stopTool == "" { + t.Fatalf("tools/list missing %s needed to clean up fixture watch", toolStopSubscription) + } + stopped, err := client.callTool(ctx, sessionID, stopTool, map[string]any{"subscription_id": startOut.SubscriptionID}) + if err != nil { + t.Fatalf("cleanup tools/call(%q) failed for fixture watch %q: %v", stopTool, startOut.SubscriptionID, err) + } + if stopped.IsError { + t.Fatalf("cleanup tools/call(%q) returned MCP tool error for fixture watch %q: %s", stopTool, startOut.SubscriptionID, stopped.textSummary()) + } + default: + t.Fatalf("fixture call %d has unsupported tool %q", i, call.Tool) + } + } + + if !described { + t.Fatal("quality fixture must include at least one schema describe call") + } + for _, channel := range fixture.ExpectedSchema.Channels { + if !seenChannels[channel] { + t.Fatalf("expected schema channel %q was not observed; saw %#v", channel, seenChannels) + } + } + for _, topic := range fixture.ExpectedSchema.RelatedTopics { + if !seenRelatedTopics[topic] { + t.Fatalf("expected related topic %q was not observed; saw %#v", topic, seenRelatedTopics) + } + } + }) + } + + if executeLiveTools { + assertOptionalDeniedSubscribe(t, ctx, client, sessionID, tools) + } +} + type mcpHTTPClient struct { endpoint string bearer string @@ -329,6 +583,22 @@ func chooseDescribeTopicToolName(names []string, explicit string) string { return chooseToolName(names, toolDescribeTopic, explicit) } +func chooseStartSubscriptionToolName(names []string, explicit string) string { + return chooseToolName(names, toolStartSubscription, explicit) +} + +func chooseReadSubscriptionToolName(names []string, explicit string) string { + return chooseToolName(names, toolReadSubscription, explicit) +} + +func chooseStatusSubscriptionToolName(names []string, explicit string) string { + return chooseToolName(names, toolStatusSubscription, explicit) +} + +func chooseStopSubscriptionToolName(names []string, explicit string) string { + return chooseToolName(names, toolStopSubscription, explicit) +} + func chooseToolName(names []string, baseName string, explicit string) string { if explicit != "" { for _, name := range names { @@ -351,6 +621,154 @@ func chooseToolName(names []string, baseName string, explicit string) string { return "" } +func selectQualityFixtures(t *testing.T, fixtures []toolCallFixture) []toolCallFixture { + t.Helper() + requested := os.Getenv("DSX_EXCHANGE_MCP_QUALITY_CASES") + if requested == "" { + return fixtures + } + wanted := map[string]bool{} + for _, id := range strings.Split(requested, ",") { + id = strings.TrimSpace(id) + if id != "" { + wanted[id] = true + } + } + var selected []toolCallFixture + for _, fixture := range fixtures { + if wanted[fixture.ID] { + selected = append(selected, fixture) + delete(wanted, fixture.ID) + } + } + if len(wanted) > 0 { + t.Fatalf("unknown DSX_EXCHANGE_MCP_QUALITY_CASES fixture id(s): %v", wanted) + } + return selected +} + +func qualityLiveArguments(call fixtureToolCall, maxDurationS int) map[string]any { + args := copyArguments(call.Arguments) + switch call.Tool { + case toolSubscribe: + args["max_duration_s"] = maxDurationS + args["max_messages"] = minPositiveIntArg(args, "max_messages", 10) + case toolReadRetained: + args["max_messages"] = minPositiveIntArg(args, "max_messages", 10) + case toolStartSubscription: + args["ttl_seconds"] = minPositiveIntArg(args, "ttl_seconds", 30) + args["buffer_max_messages"] = minPositiveIntArg(args, "buffer_max_messages", 10) + args["buffer_max_bytes"] = minPositiveIntArg(args, "buffer_max_bytes", 32768) + } + return args +} + +func copyArguments(in map[string]any) map[string]any { + out := make(map[string]any, len(in)) + for k, v := range in { + out[k] = v + } + return out +} + +func minPositiveIntArg(args map[string]any, key string, limit int) int { + got := limit + switch v := args[key].(type) { + case int: + got = v + case int64: + got = int(v) + case float64: + got = int(v) + } + if got <= 0 || got > limit { + return limit + } + return got +} + +func decodeDescribeTopicResponse(t *testing.T, res toolCallResult, fixtureID string, callIndex int) describeTopicOutput { + t.Helper() + var out describeTopicOutput + if err := json.Unmarshal([]byte(res.lastText()), &out); err != nil { + t.Fatalf("%s call %d decode schema response: %v; content=%s", fixtureID, callIndex, err, res.textSummary()) + } + return out +} + +func validateCollectResponseShape(t *testing.T, res toolCallResult, fixtureID string, callIndex int) { + t.Helper() + var out collectOutput + if err := json.Unmarshal([]byte(res.lastText()), &out); err != nil { + t.Fatalf("%s call %d decode collect response: %v; content=%s", fixtureID, callIndex, err, res.textSummary()) + } + if out.Messages == nil { + t.Fatalf("%s call %d collect response messages is nil; want JSON array", fixtureID, callIndex) + } + if out.Count != len(out.Messages) { + t.Fatalf("%s call %d collect response count = %d, want len(messages) %d", fixtureID, callIndex, out.Count, len(out.Messages)) + } + if out.DurationMS < 0 { + t.Fatalf("%s call %d collect response duration_ms = %d, want non-negative", fixtureID, callIndex, out.DurationMS) + } + if out.StoppedReason == "" { + t.Fatalf("%s call %d collect response stopped_reason is empty", fixtureID, callIndex) + } +} + +func validateWatchStartResponseShape(t *testing.T, res toolCallResult, fixtureID string, callIndex int) watchStartOutput { + t.Helper() + var out watchStartOutput + if err := json.Unmarshal([]byte(res.lastText()), &out); err != nil { + t.Fatalf("%s call %d decode watch start response: %v; content=%s", fixtureID, callIndex, err, res.textSummary()) + } + if out.SubscriptionID == "" { + t.Fatalf("%s call %d watch start response missing subscription_id: %#v", fixtureID, callIndex, out) + } + if out.TopicFilter == "" { + t.Fatalf("%s call %d watch start response missing topic_filter: %#v", fixtureID, callIndex, out) + } + if out.Status == "" { + t.Fatalf("%s call %d watch start response missing status: %#v", fixtureID, callIndex, out) + } + return out +} + +func assertOptionalDeniedSubscribe(t *testing.T, ctx context.Context, client *mcpHTTPClient, sessionID string, tools []string) { + t.Helper() + deniedTopic := os.Getenv("DSX_EXCHANGE_E2E_DENIED_TOPIC") + if deniedTopic == "" { + return + } + subscribeTool := chooseSubscribeToolName(tools, os.Getenv("DSX_EXCHANGE_E2E_TOOL_NAME")) + if subscribeTool == "" { + t.Fatalf("tools/list missing %s needed for denied-topic quality check", toolSubscribe) + } + res, err := client.callTool(ctx, sessionID, subscribeTool, map[string]any{ + "topic_filter": deniedTopic, + "max_messages": 1, + "max_duration_s": 1, + }) + if err != nil { + t.Fatalf("denied-topic tools/call(%q) failed at protocol level: %v", subscribeTool, err) + } + if !res.IsError { + t.Fatalf("denied-topic tools/call(%q, %q) unexpectedly succeeded: %s", subscribeTool, deniedTopic, res.textSummary()) + } + validateStructuredToolError(t, res, "denied-topic") +} + +func validateStructuredToolError(t *testing.T, res toolCallResult, label string) { + t.Helper() + var out structuredError + if err := json.Unmarshal([]byte(res.lastText()), &out); err != nil { + t.Fatalf("%s decode structured tool error: %v; content=%s", label, err, res.textSummary()) + } + if out.Error.Code == "" || out.Error.Message == "" { + t.Fatalf("%s structured tool error missing code/message: %#v", label, out) + } +} + func requiredEnv(t *testing.T, key string) string { t.Helper() v := os.Getenv(key) diff --git a/mcp/dsx-exchange-mcp/internal/server/llm_eval_test.go b/mcp/dsx-exchange-mcp/internal/server/llm_eval_test.go index c44f6ec..abec78a 100644 --- a/mcp/dsx-exchange-mcp/internal/server/llm_eval_test.go +++ b/mcp/dsx-exchange-mcp/internal/server/llm_eval_test.go @@ -230,11 +230,13 @@ func llmEvalSystemPrompt(allowLiveTools bool) string { Use the available MCP tools before answering. Prefer dsx_exchange_describe_topic, or the gateway-prefixed equivalent, to discover matching AsyncAPI schema channels and related metadata/value topics. For "most recent" or snapshot-style requests, plan a retained metadata read before sampling live values when the schema exposes related metadata and value topics. For live stream requests, plan dsx_exchange_subscribe with bounded max_messages and max_duration_s. +For background watch requests, plan dsx_exchange_start_subscription with a concrete topic_filter plus bounded ttl_seconds, buffer_max_messages, and buffer_max_bytes. ` + liveToolInstruction + ` Final response requirements: - Return one strict JSON object and no markdown. - JSON shape: {"answer":"brief user-facing summary","planned_tool_calls":[{"tool":"dsx_exchange_describe_topic","arguments":{"topic_filter":"..."}},{"tool":"dsx_exchange_read_retained","arguments":{"topic_filter":"...","max_messages":1000}},{"tool":"dsx_exchange_subscribe","arguments":{"topic_filter":"...","max_messages":100,"max_duration_s":30}}],"notes":["optional caveat"]} +- For background-watch requests, include {"tool":"dsx_exchange_start_subscription","arguments":{"topic_filter":"...","ttl_seconds":300,"buffer_max_messages":100,"buffer_max_bytes":262144}} in planned_tool_calls. - Use unprefixed canonical tool names in planned_tool_calls even if the MCP endpoint exposes gateway-prefixed names. - Do not invent raw data values. This eval is about choosing the right tools and topic filters.` } @@ -447,7 +449,16 @@ func traceIncludesExpectedDescribe(trace []llmToolTrace, expected []fixtureToolC } func normalizeToolName(name string) string { - for _, canonical := range []string{toolDescribeTopic, toolReadRetained, toolSubscribe} { + for _, canonical := range []string{ + toolDescribeTopic, + toolFindTopics, + toolReadRetained, + toolSubscribe, + toolStartSubscription, + toolReadSubscription, + toolStatusSubscription, + toolStopSubscription, + } { if name == canonical || strings.HasSuffix(name, "_"+canonical) { return canonical } diff --git a/mcp/dsx-exchange-mcp/internal/server/server.go b/mcp/dsx-exchange-mcp/internal/server/server.go index 6c2ca89..f3e6d89 100644 --- a/mcp/dsx-exchange-mcp/internal/server/server.go +++ b/mcp/dsx-exchange-mcp/internal/server/server.go @@ -4,29 +4,38 @@ package server import ( + "context" + "strings" + "github.com/modelcontextprotocol/go-sdk/mcp" + "github.com/NVIDIA/dsx-exchange/mcp/dsx-exchange-mcp/internal/auth" "github.com/NVIDIA/dsx-exchange/mcp/dsx-exchange-mcp/internal/metrics" "github.com/NVIDIA/dsx-exchange/mcp/dsx-exchange-mcp/internal/mqttbus" ) type Config struct { - MQTT mqttbus.Config - Metrics *metrics.Recorder - DefaultMaxMessages int - MaxMessages int - DefaultDurationS int - MaxDurationS int - WatchDefaultTTLS int - WatchMaxTTLS int - WatchDefaultBufferMessages int - WatchMaxBufferMessages int - WatchDefaultBufferBytes int - WatchMaxBufferBytes int - WatchMaxPerSession int - WatchMaxPerPod int - FindTopicsDefaultLimit int - FindTopicsMaxLimit int + MQTT mqttbus.Config + Metrics *metrics.Recorder + DefaultMaxMessages int + MaxMessages int + DefaultDurationS int + MaxDurationS int + MQTTCollectMaxConcurrent int + MQTTWatchStartMaxConcurrent int + WatchDefaultTTLS int + WatchMaxTTLS int + WatchDefaultBufferMessages int + WatchMaxBufferMessages int + WatchDefaultBufferBytes int + WatchMaxBufferBytes int + WatchMaxPerSession int + WatchMaxPerPod int + FindTopicsDefaultLimit int + FindTopicsMaxLimit int + + collectAdmission *admissionLimiter + watchStartAdmission *admissionLimiter } // Build constructs the singleton MCP server. The same *mcp.Server is returned @@ -42,8 +51,36 @@ func Build(cfg Config) *mcp.Server { ) normalizeConfig(&cfg) + cfg.MQTT.Metrics = cfg.Metrics + cfg.collectAdmission = newAdmissionLimiter(cfg.MQTTCollectMaxConcurrent) + cfg.watchStartAdmission = newAdmissionLimiter(cfg.MQTTWatchStartMaxConcurrent) + srv.AddReceivingMiddleware(callerContextMiddleware(cfg.Metrics)) watches := newWatchManager(cfg) registerTools(srv, cfg, watches) registerResources(srv) return srv } + +func callerContextMiddleware(rec *metrics.Recorder) func(mcp.MethodHandler) mcp.MethodHandler { + return func(next mcp.MethodHandler) mcp.MethodHandler { + return func(ctx context.Context, method string, req mcp.Request) (mcp.Result, error) { + sessionID := "" + if session := req.GetSession(); session != nil { + sessionID = strings.TrimSpace(session.ID()) + } + if extra := req.GetExtra(); extra != nil { + caller := auth.CallerFromHeaders(extra.Header) + if caller.SessionID == "" { + caller.SessionID = sessionID + } + ctx = auth.WithCaller(ctx, caller) + } else if sessionID != "" { + ctx = auth.WithSessionID(ctx, sessionID) + } + if rec != nil { + rec.ObserveSession(sessionID) + } + return next(ctx, method, req) + } + } +} diff --git a/mcp/dsx-exchange-mcp/internal/server/testdata/tool_call_expectations.json b/mcp/dsx-exchange-mcp/internal/server/testdata/tool_call_expectations.json index 615a375..08be6b1 100644 --- a/mcp/dsx-exchange-mcp/internal/server/testdata/tool_call_expectations.json +++ b/mcp/dsx-exchange-mcp/internal/server/testdata/tool_call_expectations.json @@ -200,6 +200,33 @@ "related_topics": [] } }, + { + "id": "bms-rack-power-background-watch", + "domain": "bms", + "question": "Start a background watch for rack power telemetry for 5 minutes. Keep up to 100 messages and 262144 bytes in the buffer.", + "expected_tool_calls": [ + { + "tool": "dsx_exchange_describe_topic", + "arguments": { + "topic_filter": "BMS/v1/PUB/Value/Rack/RackPower/#" + } + }, + { + "tool": "dsx_exchange_start_subscription", + "arguments": { + "topic_filter": "BMS/v1/PUB/Value/Rack/RackPower/#", + "ttl_seconds": 300, + "buffer_max_messages": 100, + "buffer_max_bytes": 262144 + } + } + ], + "expected_schema": { + "domain": "bms", + "channels": ["rackBmsValue"], + "related_topics": ["BMS/v1/PUB/Metadata/Rack/RackPower/#"] + } + }, { "id": "nico-machine-state", "domain": "nico", diff --git a/mcp/dsx-exchange-mcp/internal/server/tools.go b/mcp/dsx-exchange-mcp/internal/server/tools.go index e0a2dca..10eb15f 100644 --- a/mcp/dsx-exchange-mcp/internal/server/tools.go +++ b/mcp/dsx-exchange-mcp/internal/server/tools.go @@ -75,8 +75,9 @@ type structuredError struct { } type errorBody struct { - Code string `json:"code"` - Message string `json:"message"` + Code string `json:"code"` + Message string `json:"message"` + RetryAfterSeconds int `json:"retry_after_seconds,omitempty"` } func registerTools(s *mcp.Server, cfg Config, watches *watchManager) { @@ -112,7 +113,16 @@ func registerTools(s *mcp.Server, cfg Config, watches *watchManager) { "Returns the schema channel, payload shape, retained/live behavior, examples, and related metadata/value topics. " + "Use this before subscribing when the caller knows roughly which MQTT path they want but needs schema context.", }, func(ctx context.Context, _ *mcp.CallToolRequest, in describeTopicInput) (*mcp.CallToolResult, describeTopicOutput, error) { - return describeTopicTool(ctx, in) + start := time.Now() + if cfg.Metrics != nil { + cfg.Metrics.BeginToolCall() + defer cfg.Metrics.EndToolCall() + } + result, out, err := describeTopicTool(ctx, in) + if cfg.Metrics != nil { + cfg.Metrics.RecordToolCall(toolDescribeTopic, toolResultErrorCode(result, err), "", time.Since(start), out.Count) + } + return result, out, err }) mcp.AddTool(s, &mcp.Tool{ @@ -121,7 +131,16 @@ func registerTools(s *mcp.Server, cfg Config, watches *watchManager) { "Use this before starting a long-running subscription when the caller describes a domain or signal but does not know the raw MQTT topic path. " + "Returned topic filters still require broker ACL approval when used by MQTT tools.", }, func(ctx context.Context, _ *mcp.CallToolRequest, in findTopicsInput) (*mcp.CallToolResult, findTopicsOutput, error) { - return findTopicsTool(ctx, cfg, in) + start := time.Now() + if cfg.Metrics != nil { + cfg.Metrics.BeginToolCall() + defer cfg.Metrics.EndToolCall() + } + result, out, err := findTopicsTool(ctx, cfg, in) + if cfg.Metrics != nil { + cfg.Metrics.RecordToolCall(toolFindTopics, toolResultErrorCode(result, err), "", time.Since(start), out.Count) + } + return result, out, err }) registerWatchTools(s, cfg, watches) @@ -251,6 +270,11 @@ func collectTool( return finishTool(tool, caller, topicFilter, maxMessages, durationS, start, collectOutput{}, err, cfg) } + if !cfg.collectAdmission.tryAcquire() { + return finishTool(tool, caller, topicFilter, maxMessages, durationS, start, collectOutput{}, admissionLimitedError(), cfg) + } + defer cfg.collectAdmission.release() + result, err := mqttbus.Collect(ctx, cfg.MQTT, caller.Bearer, topicFilter, maxMessages, time.Duration(durationS)*time.Second, retainedOnly) out := collectOutput{ Messages: append([]mqttbus.Message{}, result.Messages...), @@ -285,7 +309,7 @@ func finishTool( auditToolCall(tool, caller, topicFilter, maxMessages, durationS, out, duration, code) if err != nil { - body := structuredError{Error: errorBody{Code: code, Message: publicMessage(err)}} + body := structuredError{Error: errorBody{Code: code, Message: publicMessage(err), RetryAfterSeconds: retryAfterSeconds(err)}} raw, _ := json.Marshal(body) return &mcp.CallToolResult{ IsError: true, @@ -315,6 +339,12 @@ func normalizeConfig(cfg *Config) { if cfg.MaxDurationS <= 0 { cfg.MaxDurationS = 30 } + if cfg.MQTTCollectMaxConcurrent <= 0 { + cfg.MQTTCollectMaxConcurrent = 100 + } + if cfg.MQTTWatchStartMaxConcurrent <= 0 { + cfg.MQTTWatchStartMaxConcurrent = 500 + } if cfg.WatchDefaultTTLS <= 0 { cfg.WatchDefaultTTLS = 300 } @@ -407,6 +437,26 @@ func errorCode(err error) string { return mqttbus.ErrorCode(err) } +func toolResultErrorCode(result *mcp.CallToolResult, err error) string { + if err != nil { + return errorCode(err) + } + if result == nil || !result.IsError { + return "" + } + for _, item := range result.Content { + text, ok := item.(*mcp.TextContent) + if !ok || text.Text == "" { + continue + } + var body structuredError + if json.Unmarshal([]byte(text.Text), &body) == nil && body.Error.Code != "" { + return body.Error.Code + } + } + return mqttbus.CodeInternalError +} + func publicMessage(err error) string { var busErr *mqttbus.BusError if errors.As(err, &busErr) { @@ -421,6 +471,22 @@ func publicMessage(err error) string { return "tool call failed" } +func admissionLimitedError() error { + return &mqttbus.BusError{ + Code: mqttbus.CodeMQTTAdmissionLimited, + Message: "too many MQTT-backed tool calls are starting; retry later", + RetryAfterSeconds: 1, + } +} + +func retryAfterSeconds(err error) int { + var busErr *mqttbus.BusError + if errors.As(err, &busErr) && busErr.RetryAfterSeconds > 0 { + return busErr.RetryAfterSeconds + } + return 0 +} + func auditToolCall( tool string, caller auth.Caller, diff --git a/mcp/dsx-exchange-mcp/internal/server/tools_test.go b/mcp/dsx-exchange-mcp/internal/server/tools_test.go index 2ff448d..d7732fb 100644 --- a/mcp/dsx-exchange-mcp/internal/server/tools_test.go +++ b/mcp/dsx-exchange-mcp/internal/server/tools_test.go @@ -45,6 +45,43 @@ func TestApplyLimitsDefaultsAndCaps(t *testing.T) { } } +func TestCollectToolAdmissionLimitFailsFast(t *testing.T) { + cfg := Config{ + DefaultMaxMessages: 10, + MaxMessages: 20, + DefaultDurationS: 5, + MaxDurationS: 30, + } + normalizeConfig(&cfg) + cfg.collectAdmission = newAdmissionLimiter(1) + if !cfg.collectAdmission.tryAcquire() { + t.Fatal("pre-acquire collect admission failed") + } + ctx := auth.WithCaller(context.Background(), auth.Caller{ + Bearer: "token", + SessionID: "session-1", + }) + + result, _, err := collectTool(ctx, cfg, toolSubscribe, "BMS/v1/PUB/Value/Rack/RackPower/#", 1, 1, false) + if err != nil { + t.Fatalf("collectTool returned transport error: %v", err) + } + if result == nil || !result.IsError { + t.Fatalf("collectTool IsError = %v, want true", result != nil && result.IsError) + } + text, ok := result.Content[0].(*mcp.TextContent) + if !ok { + t.Fatalf("error content type = %T, want *mcp.TextContent", result.Content[0]) + } + var body structuredError + if err := json.Unmarshal([]byte(text.Text), &body); err != nil { + t.Fatalf("decode error body: %v", err) + } + if body.Error.Code != mqttbus.CodeMQTTAdmissionLimited || body.Error.RetryAfterSeconds != 1 { + t.Fatalf("error body = %#v, want mqtt_admission_limited with retry_after_seconds=1", body.Error) + } +} + func TestDescribeTopicToolMatchesSchema(t *testing.T) { _, out, err := describeTopicTool(context.Background(), describeTopicInput{ TopicFilter: "BMS/v1/PUB/Value/Rack/RackLiquidIsolationStatus/#", @@ -142,11 +179,21 @@ func TestToolCallExpectationFixtures(t *testing.T) { } cfg := Config{ - DefaultMaxMessages: 100, - MaxMessages: 1000, - DefaultDurationS: 30, - MaxDurationS: 30, + DefaultMaxMessages: 100, + MaxMessages: 1000, + DefaultDurationS: 30, + MaxDurationS: 30, + WatchDefaultTTLS: 300, + WatchMaxTTLS: 900, + WatchDefaultBufferMessages: 100, + WatchMaxBufferMessages: 1000, + WatchDefaultBufferBytes: 262144, + WatchMaxBufferBytes: 1048576, + WatchMaxPerSession: 10, + WatchMaxPerPod: 1000, } + normalizeConfig(&cfg) + watches := newWatchManager(cfg) for _, fixture := range fixtures { t.Run(fixture.ID, func(t *testing.T) { @@ -202,6 +249,17 @@ func TestToolCallExpectationFixtures(t *testing.T) { if _, _, err := applyLimits(cfg, maxMessages, maxDurationS); err != nil { t.Fatalf("subscribe limits invalid for %q: %v", topicFilter, err) } + case toolStartSubscription: + ttlS := intArg(t, fixture.ID, i, call.Arguments, "ttl_seconds") + bufferMaxMessages := intArg(t, fixture.ID, i, call.Arguments, "buffer_max_messages") + bufferMaxBytes := intArg(t, fixture.ID, i, call.Arguments, "buffer_max_bytes") + if _, _, _, err := watches.applyStartLimits(watchStartRequest{ + TTLS: ttlS, + BufferMaxMessages: bufferMaxMessages, + BufferMaxBytes: bufferMaxBytes, + }); err != nil { + t.Fatalf("start_subscription limits invalid for %q: %v", topicFilter, err) + } default: t.Fatalf("call %d has unknown tool %q", i, call.Tool) } diff --git a/mcp/dsx-exchange-mcp/internal/server/watch.go b/mcp/dsx-exchange-mcp/internal/server/watch.go index b7271c7..abb2227 100644 --- a/mcp/dsx-exchange-mcp/internal/server/watch.go +++ b/mcp/dsx-exchange-mcp/internal/server/watch.go @@ -7,12 +7,15 @@ import ( "context" "crypto/rand" "encoding/hex" + "encoding/json" "fmt" "math" + "sort" "strconv" "strings" "sync" "time" + "unicode/utf8" "github.com/NVIDIA/dsx-exchange/mcp/dsx-exchange-mcp/internal/auth" "github.com/NVIDIA/dsx-exchange/mcp/dsx-exchange-mcp/internal/mqttbus" @@ -33,7 +36,11 @@ const ( codeBufferOverflow = "buffer_overflow" ) -const finishedWatchRetention = 5 * time.Minute +const ( + finishedWatchRetention = 5 * time.Minute + maxWatchStatusUpdates = 50 + maxWatchStatusPayloadBytes = 4096 +) type streamRunner func(context.Context, mqttbus.Config, string, string, mqttbus.StreamOptions, func(mqttbus.Message) error) (mqttbus.StreamResult, error) @@ -62,11 +69,13 @@ type watch struct { lastMessage time.Time lastError *errorBody - cursor int64 - droppedCount int64 - messageCount int64 - bufferBytes int - buffer []bufferedWatchMessage + cursor int64 + droppedCount int64 + messageCount int64 + bufferBytes int + buffer []bufferedWatchMessage + updates map[string]*watchTopicUpdate + updatesDropped int64 maxMessages int maxBytes int @@ -129,6 +138,50 @@ type watchMessageOutput struct { ReceivedAt time.Time `json:"received_at"` } +type watchTopicUpdate struct { + topic string + count int64 + latestCursor string + latestPayload string + latestPayloadEncoding string + latestPayloadTruncated bool + retained bool + qos byte + latestReceivedAt time.Time + numeric *watchNumericAggregate +} + +type watchTopicUpdateOutput struct { + Topic string `json:"topic"` + Count int64 `json:"count"` + LatestCursor string `json:"latest_cursor"` + LatestPayload string `json:"latest_payload,omitempty"` + LatestPayloadEncoding string `json:"latest_payload_encoding,omitempty"` + LatestPayloadTruncated bool `json:"latest_payload_truncated,omitempty"` + Retained bool `json:"retained"` + QoS byte `json:"qos"` + LatestReceivedAt time.Time `json:"latest_received_at"` + Numeric *watchNumericOutput `json:"numeric,omitempty"` +} + +type watchNumericAggregate struct { + field string + count int64 + min float64 + max float64 + sum float64 + latest float64 +} + +type watchNumericOutput struct { + Field string `json:"field"` + Count int64 `json:"count"` + Min float64 `json:"min"` + Max float64 `json:"max"` + Mean float64 `json:"mean"` + Latest float64 `json:"latest"` +} + type watchStartOutput struct { SubscriptionID string `json:"subscription_id"` Status string `json:"status"` @@ -152,17 +205,21 @@ type watchReadOutput struct { } type watchStatusOutput struct { - SubscriptionID string `json:"subscription_id"` - Status string `json:"status"` - TopicFilter string `json:"topic_filter"` - MessageCount int64 `json:"message_count"` - DroppedCount int64 `json:"dropped_count"` - OldestCursor string `json:"oldest_cursor"` - NewestCursor string `json:"newest_cursor"` - ExpiresAt time.Time `json:"expires_at"` - LastMessageAt *time.Time `json:"last_message_at,omitempty"` - LastError *errorBody `json:"last_error,omitempty"` - BufferWatermark watchWatermark `json:"buffer_watermark"` + SubscriptionID string `json:"subscription_id"` + Status string `json:"status"` + TopicFilter string `json:"topic_filter"` + MessageCount int64 `json:"message_count"` + DroppedCount int64 `json:"dropped_count"` + UpdateCount int `json:"update_count"` + UpdatesDropped int64 `json:"updates_dropped"` + UpdatesTruncated bool `json:"updates_truncated"` + Updates []watchTopicUpdateOutput `json:"updates,omitempty"` + OldestCursor string `json:"oldest_cursor"` + NewestCursor string `json:"newest_cursor"` + ExpiresAt time.Time `json:"expires_at"` + LastMessageAt *time.Time `json:"last_message_at,omitempty"` + LastError *errorBody `json:"last_error,omitempty"` + BufferWatermark watchWatermark `json:"buffer_watermark"` } type watchStopOutput struct { @@ -211,6 +268,7 @@ func (m *watchManager) start(req watchStartRequest) (watchStartOutput, error) { status: watchStatusStarting, createdAt: started, expiresAt: started.Add(time.Duration(ttlS) * time.Second), + updates: map[string]*watchTopicUpdate{}, maxMessages: bufferMessages, maxBytes: bufferBytes, cancel: cancel, @@ -220,15 +278,23 @@ func (m *watchManager) start(req watchStartRequest) (watchStartOutput, error) { ready := make(chan struct{}, 1) finished := make(chan streamFinished, 1) + if !m.cfg.watchStartAdmission.tryAcquire() { + cancel() + return watchStartOutput{}, admissionLimitedError() + } + releaseStartup := sync.OnceFunc(m.cfg.watchStartAdmission.release) + m.mu.Lock() if m.activeTotal >= m.cfg.WatchMaxPerPod { m.mu.Unlock() + releaseStartup() cancel() return watchStartOutput{}, &mqttbus.BusError{Code: mqttbus.CodeInvalidArgument, Message: fmt.Sprintf("active watch count exceeds per-pod cap %d", m.cfg.WatchMaxPerPod)} } sessionWatches := m.watches[w.sessionID] if activeSessionCount(sessionWatches) >= m.cfg.WatchMaxPerSession { m.mu.Unlock() + releaseStartup() cancel() return watchStartOutput{}, &mqttbus.BusError{Code: mqttbus.CodeInvalidArgument, Message: fmt.Sprintf("active watch count exceeds per-session cap %d", m.cfg.WatchMaxPerSession)} } @@ -250,6 +316,7 @@ func (m *watchManager) start(req watchStartRequest) (watchStartOutput, error) { MaxMessages: math.MaxInt32, MaxDuration: time.Duration(ttlS) * time.Second, OnSubscribed: func() { + releaseStartup() m.markRunning(w.sessionID, w.id) select { case ready <- struct{}{}: @@ -260,6 +327,7 @@ func (m *watchManager) start(req watchStartRequest) (watchStartOutput, error) { m.recordMessage(w.sessionID, w.id, msg) return nil }) + releaseStartup() m.finish(w.sessionID, w.id, result, err) select { case finished <- streamFinished{result: result, err: err}: @@ -352,17 +420,22 @@ func (m *watchManager) status(req watchStatusRequest) (watchStatusOutput, error) if err != nil { return watchStatusOutput{}, err } + updates, updatesTruncated := w.statusUpdates() out := watchStatusOutput{ - SubscriptionID: w.id, - Status: w.status, - TopicFilter: w.topicFilter, - MessageCount: w.messageCount, - DroppedCount: w.droppedCount, - OldestCursor: w.oldestCursor(), - NewestCursor: strconv.FormatInt(w.cursor, 10), - ExpiresAt: w.expiresAt, - LastError: w.lastError, - BufferWatermark: w.watermark(), + SubscriptionID: w.id, + Status: w.status, + TopicFilter: w.topicFilter, + MessageCount: w.messageCount, + DroppedCount: w.droppedCount, + UpdateCount: len(w.updates), + UpdatesDropped: w.updatesDropped, + UpdatesTruncated: updatesTruncated || w.updatesDropped > 0, + Updates: updates, + OldestCursor: w.oldestCursor(), + NewestCursor: strconv.FormatInt(w.cursor, 10), + ExpiresAt: w.expiresAt, + LastError: w.lastError, + BufferWatermark: w.watermark(), } if !w.lastMessage.IsZero() { last := w.lastMessage @@ -508,9 +581,11 @@ func (m *watchManager) recordMessage(sessionID, subscriptionID string, msg mqttb w.cursor++ w.messageCount++ w.lastMessage = msg.ReceivedAt + cursor := strconv.FormatInt(w.cursor, 10) + w.recordTopicUpdate(cursor, msg) size := len(msg.Topic) + len(msg.Payload) w.buffer = append(w.buffer, bufferedWatchMessage{ - cursor: strconv.FormatInt(w.cursor, 10), + cursor: cursor, size: size, msg: msg, }) @@ -529,6 +604,162 @@ func (m *watchManager) recordMessage(sessionID, subscriptionID string, msg mqttb } } +func (w *watch) recordTopicUpdate(cursor string, msg mqttbus.Message) { + if w.updates == nil { + w.updates = map[string]*watchTopicUpdate{} + } + update := w.updates[msg.Topic] + if update == nil { + if len(w.updates) >= maxWatchStatusUpdates { + w.evictOldestTopicUpdate() + } + update = &watchTopicUpdate{topic: msg.Topic} + w.updates[msg.Topic] = update + } + payload, truncated := truncatePayloadSample(msg.Payload, maxWatchStatusPayloadBytes) + update.count++ + update.latestCursor = cursor + update.latestPayload = payload + update.latestPayloadEncoding = msg.PayloadEncoding + update.latestPayloadTruncated = truncated + update.retained = msg.Retained + update.qos = msg.QoS + update.latestReceivedAt = msg.ReceivedAt + if field, value, ok := extractNumericPayloadValue(msg.Payload); ok { + update.recordNumeric(field, value) + } +} + +func (w *watch) evictOldestTopicUpdate() { + var oldest *watchTopicUpdate + oldestTopic := "" + for topic, update := range w.updates { + if oldest == nil || + update.latestReceivedAt.Before(oldest.latestReceivedAt) || + (update.latestReceivedAt.Equal(oldest.latestReceivedAt) && topic < oldestTopic) { + oldest = update + oldestTopic = topic + } + } + if oldestTopic == "" { + return + } + delete(w.updates, oldestTopic) + w.updatesDropped++ +} + +func (w *watch) statusUpdates() ([]watchTopicUpdateOutput, bool) { + updates := make([]watchTopicUpdateOutput, 0, len(w.updates)) + for _, update := range w.updates { + var numeric *watchNumericOutput + if update.numeric != nil { + numeric = update.numeric.output() + } + updates = append(updates, watchTopicUpdateOutput{ + Topic: update.topic, + Count: update.count, + LatestCursor: update.latestCursor, + LatestPayload: update.latestPayload, + LatestPayloadEncoding: update.latestPayloadEncoding, + LatestPayloadTruncated: update.latestPayloadTruncated, + Retained: update.retained, + QoS: update.qos, + LatestReceivedAt: update.latestReceivedAt, + Numeric: numeric, + }) + } + sort.Slice(updates, func(i, j int) bool { + left := updates[i] + right := updates[j] + if !left.LatestReceivedAt.Equal(right.LatestReceivedAt) { + return left.LatestReceivedAt.After(right.LatestReceivedAt) + } + return left.Topic < right.Topic + }) + if len(updates) <= maxWatchStatusUpdates { + return updates, false + } + return updates[:maxWatchStatusUpdates], true +} + +func (u *watchTopicUpdate) recordNumeric(field string, value float64) { + if u.numeric == nil || u.numeric.field != field { + u.numeric = &watchNumericAggregate{ + field: field, + count: 1, + min: value, + max: value, + sum: value, + latest: value, + } + return + } + u.numeric.count++ + u.numeric.sum += value + u.numeric.latest = value + if value < u.numeric.min { + u.numeric.min = value + } + if value > u.numeric.max { + u.numeric.max = value + } +} + +func (a *watchNumericAggregate) output() *watchNumericOutput { + if a == nil || a.count == 0 { + return nil + } + return &watchNumericOutput{ + Field: a.field, + Count: a.count, + Min: a.min, + Max: a.max, + Mean: a.sum / float64(a.count), + Latest: a.latest, + } +} + +func extractNumericPayloadValue(payload string) (string, float64, bool) { + var body map[string]any + if err := json.Unmarshal([]byte(payload), &body); err != nil { + return "", 0, false + } + if value, ok := numericJSONValue(body["value"]); ok { + return "value", value, true + } + data, ok := body["data"].(map[string]any) + if !ok { + return "", 0, false + } + if value, ok := numericJSONValue(data["value"]); ok { + return "data.value", value, true + } + return "", 0, false +} + +func numericJSONValue(v any) (float64, bool) { + switch n := v.(type) { + case float64: + if math.IsNaN(n) || math.IsInf(n, 0) { + return 0, false + } + return n, true + default: + return 0, false + } +} + +func truncatePayloadSample(payload string, maxBytes int) (string, bool) { + if maxBytes <= 0 || len(payload) <= maxBytes { + return payload, false + } + sample := payload[:maxBytes] + for len(sample) > 0 && !utf8.ValidString(sample) { + sample = sample[:len(sample)-1] + } + return sample, true +} + func (m *watchManager) finish(sessionID, subscriptionID string, result mqttbus.StreamResult, err error) { m.mu.Lock() w := m.watches[sessionID][subscriptionID] diff --git a/mcp/dsx-exchange-mcp/internal/server/watch_test.go b/mcp/dsx-exchange-mcp/internal/server/watch_test.go index 1628d9e..24de1b8 100644 --- a/mcp/dsx-exchange-mcp/internal/server/watch_test.go +++ b/mcp/dsx-exchange-mcp/internal/server/watch_test.go @@ -5,6 +5,8 @@ package server import ( "context" + "fmt" + "strings" "testing" "time" @@ -75,6 +77,28 @@ func TestWatchManagerLifecycleReadOverflowAndStop(t *testing.T) { if status.MessageCount != 3 || status.DroppedCount != 1 { t.Fatalf("status = %#v, want 3 messages and 1 drop", status) } + if status.UpdateCount != 1 || status.UpdatesTruncated { + t.Fatalf("status updates = count %d truncated %v, want one untruncated update", status.UpdateCount, status.UpdatesTruncated) + } + if status.UpdatesDropped != 0 { + t.Fatalf("updates_dropped = %d, want 0", status.UpdatesDropped) + } + if len(status.Updates) != 1 { + t.Fatalf("status updates len = %d, want 1", len(status.Updates)) + } + update := status.Updates[0] + if update.Topic != "BMS/v1/PUB/Value/Rack/RackPower/a" || update.Count != 3 || update.LatestCursor != "3" { + t.Fatalf("status update = %#v, want latest cursor 3 with count 3", update) + } + if update.LatestPayload != `{"value":3}` || update.LatestReceivedAt != time.Unix(3, 0) { + t.Fatalf("status update latest = payload %q time %s, want value 3 at unix 3", update.LatestPayload, update.LatestReceivedAt) + } + if update.Numeric == nil { + t.Fatal("status update numeric aggregate missing") + } + if update.Numeric.Field != "value" || update.Numeric.Count != 3 || update.Numeric.Min != 1 || update.Numeric.Max != 3 || update.Numeric.Mean != 2 || update.Numeric.Latest != 3 { + t.Fatalf("numeric aggregate = %#v, want field=value count=3 min=1 max=3 mean=2 latest=3", update.Numeric) + } stop, err := m.stop(watchStopRequest{ Caller: caller, @@ -96,6 +120,158 @@ func TestWatchManagerLifecycleReadOverflowAndStop(t *testing.T) { } } +func TestWatchManagerStatusAggregatesBoundedTopicUpdates(t *testing.T) { + cfg := Config{ + WatchDefaultTTLS: 30, + WatchMaxTTLS: 60, + WatchDefaultBufferMessages: 2, + WatchMaxBufferMessages: 100, + WatchDefaultBufferBytes: 8192, + WatchMaxBufferBytes: 8192, + WatchMaxPerSession: 2, + WatchMaxPerPod: 10, + } + normalizeConfig(&cfg) + m := newWatchManager(cfg) + m.newID = func() string { return "sub_aggregate" } + + messages := make([]mqttbus.Message, 0, maxWatchStatusUpdates+1) + for i := 0; i < maxWatchStatusUpdates+1; i++ { + payload := fmt.Sprintf(`{"value":%d}`, i) + if i == maxWatchStatusUpdates { + payload = strings.Repeat("x", maxWatchStatusPayloadBytes+10) + } + messages = append(messages, mqttbus.Message{ + Topic: fmt.Sprintf("BMS/v1/PUB/Value/Rack/RackPower/%03d", i), + Payload: payload, + PayloadEncoding: "utf8", + ReceivedAt: time.Unix(int64(i), 0), + }) + } + m.runner = fakeStreamRunner(messages) + caller := testCaller() + + start, err := m.start(watchStartRequest{ + Caller: caller, + TopicFilter: "BMS/v1/PUB/Value/Rack/RackPower/#", + }) + if err != nil { + t.Fatalf("start returned error: %v", err) + } + + status, err := m.status(watchStatusRequest{ + Caller: caller, + SubscriptionID: start.SubscriptionID, + }) + if err != nil { + t.Fatalf("status returned error: %v", err) + } + if status.UpdateCount != maxWatchStatusUpdates { + t.Fatalf("update_count = %d, want %d", status.UpdateCount, maxWatchStatusUpdates) + } + if len(status.Updates) != maxWatchStatusUpdates || status.UpdatesDropped != 1 || !status.UpdatesTruncated { + t.Fatalf("updates len/dropped/truncated = %d/%d/%v, want %d/1/true", len(status.Updates), status.UpdatesDropped, status.UpdatesTruncated, maxWatchStatusUpdates) + } + latest := status.Updates[0] + if latest.Topic != "BMS/v1/PUB/Value/Rack/RackPower/050" || latest.LatestCursor != "51" { + t.Fatalf("latest update = %#v, want newest topic/cursor", latest) + } + if len(latest.LatestPayload) != maxWatchStatusPayloadBytes || !latest.LatestPayloadTruncated { + t.Fatalf("latest payload len/truncated = %d/%v, want %d/true", len(latest.LatestPayload), latest.LatestPayloadTruncated, maxWatchStatusPayloadBytes) + } + + _, err = m.stop(watchStopRequest{ + Caller: caller, + SubscriptionID: start.SubscriptionID, + }) + if err != nil { + t.Fatalf("stop returned error: %v", err) + } +} + +func TestWatchManagerStatusOmitsNumericForMetadataAndTracksCloudEventValue(t *testing.T) { + cfg := Config{ + WatchDefaultTTLS: 30, + WatchMaxTTLS: 60, + WatchDefaultBufferMessages: 10, + WatchMaxBufferMessages: 100, + WatchDefaultBufferBytes: 8192, + WatchMaxBufferBytes: 8192, + WatchMaxPerSession: 2, + WatchMaxPerPod: 10, + } + normalizeConfig(&cfg) + m := newWatchManager(cfg) + m.newID = func() string { return "sub_mixed_aggregates" } + m.runner = fakeStreamRunner([]mqttbus.Message{ + {Topic: "BMS/v1/PUB/Metadata/Rack/RackPower/a", Payload: `{"unit":"kW","displayName":"Rack A"}`, PayloadEncoding: "utf8", ReceivedAt: time.Unix(1, 0)}, + {Topic: "grid/v1/poweragent/a/powerstate/status", Payload: `{"specversion":"1.0","data":{"value":10}}`, PayloadEncoding: "utf8", ReceivedAt: time.Unix(2, 0)}, + {Topic: "grid/v1/poweragent/a/powerstate/status", Payload: `{"specversion":"1.0","data":{"value":20}}`, PayloadEncoding: "utf8", ReceivedAt: time.Unix(3, 0)}, + }) + caller := testCaller() + + start, err := m.start(watchStartRequest{ + Caller: caller, + TopicFilter: "BMS/v1/PUB/#", + }) + if err != nil { + t.Fatalf("start returned error: %v", err) + } + status, err := m.status(watchStatusRequest{ + Caller: caller, + SubscriptionID: start.SubscriptionID, + }) + if err != nil { + t.Fatalf("status returned error: %v", err) + } + + updates := map[string]watchTopicUpdateOutput{} + for _, update := range status.Updates { + updates[update.Topic] = update + } + metadata := updates["BMS/v1/PUB/Metadata/Rack/RackPower/a"] + if metadata.Count != 1 || metadata.Numeric != nil { + t.Fatalf("metadata update = %#v, want count-only without numeric aggregate", metadata) + } + event := updates["grid/v1/poweragent/a/powerstate/status"] + if event.Numeric == nil { + t.Fatal("CloudEvent-style numeric aggregate missing") + } + if event.Numeric.Field != "data.value" || event.Numeric.Count != 2 || event.Numeric.Min != 10 || event.Numeric.Max != 20 || event.Numeric.Mean != 15 || event.Numeric.Latest != 20 { + t.Fatalf("CloudEvent numeric aggregate = %#v, want field=data.value count=2 min=10 max=20 mean=15 latest=20", event.Numeric) + } +} + +func TestWatchManagerStartAdmissionLimitFailsFast(t *testing.T) { + cfg := Config{ + WatchDefaultTTLS: 30, + WatchMaxTTLS: 60, + WatchDefaultBufferMessages: 10, + WatchMaxBufferMessages: 100, + WatchDefaultBufferBytes: 8192, + WatchMaxBufferBytes: 8192, + WatchMaxPerSession: 2, + WatchMaxPerPod: 10, + } + normalizeConfig(&cfg) + cfg.watchStartAdmission = newAdmissionLimiter(1) + if !cfg.watchStartAdmission.tryAcquire() { + t.Fatal("pre-acquire watch-start admission failed") + } + m := newWatchManager(cfg) + + _, err := m.start(watchStartRequest{ + Caller: testCaller(), + TopicFilter: "BMS/v1/PUB/Value/Rack/RackPower/#", + }) + if got := mqttbus.ErrorCode(err); got != mqttbus.CodeMQTTAdmissionLimited { + t.Fatalf("start admission error code = %q, want %q", got, mqttbus.CodeMQTTAdmissionLimited) + } + if retry := retryAfterSeconds(err); retry != 1 { + t.Fatalf("retry_after_seconds = %d, want 1", retry) + } +} + func TestWatchManagerRequiresStatefulSession(t *testing.T) { cfg := Config{} normalizeConfig(&cfg) diff --git a/mcp/dsx-exchange-mcp/internal/server/watch_tools.go b/mcp/dsx-exchange-mcp/internal/server/watch_tools.go index a7de081..d968a33 100644 --- a/mcp/dsx-exchange-mcp/internal/server/watch_tools.go +++ b/mcp/dsx-exchange-mcp/internal/server/watch_tools.go @@ -56,7 +56,8 @@ func registerWatchTools(s *mcp.Server, cfg Config, watches *watchManager) { mcp.AddTool(s, &mcp.Tool{ Name: toolReadSubscription, - Description: "Read a bounded batch of messages from a pod-local background watch by cursor. " + + Description: "Read a bounded raw batch of messages from a pod-local background watch by cursor. " + + "Use this as a debug or detail path when raw payloads are needed; prefer dsx_exchange_subscription_status for scalable update summaries. " + "This reads only the owning pod's in-memory ring buffer; if the session or pod-local state was lost, the tool returns subscription_not_found or session_lost.", }, func(ctx context.Context, _ *mcp.CallToolRequest, in readSubscriptionInput) (*mcp.CallToolResult, watchReadOutput, error) { return readSubscriptionTool(ctx, cfg, watches, in) @@ -64,7 +65,7 @@ func registerWatchTools(s *mcp.Server, cfg Config, watches *watchManager) { mcp.AddTool(s, &mcp.Tool{ Name: toolStatusSubscription, - Description: "Return pod-local status, counters, watermarks, expiry, and last error for a background watch owned by the current Mcp-Session-Id.", + Description: "Return pod-local status, counters, watermarks, expiry, last error, and bounded per-topic update summaries for a background watch owned by the current Mcp-Session-Id.", }, func(ctx context.Context, _ *mcp.CallToolRequest, in subscriptionIDInput) (*mcp.CallToolResult, watchStatusOutput, error) { return statusSubscriptionTool(ctx, cfg, watches, in) }) @@ -88,7 +89,7 @@ func startSubscriptionTool(ctx context.Context, cfg Config, watches *watchManage topicFilter, err := resolveSubscriptionTopic(cfg, in) if err != nil { recordWatchAudit(toolStartSubscription, caller, "", "", 0, start, err, cfg) - return toolError[watchStartOutput](mqttbus.ErrorCode(err), publicMessage(err)) + return toolErrorFromErr[watchStartOutput](err) } out, err := watches.start(watchStartRequest{ @@ -100,7 +101,7 @@ func startSubscriptionTool(ctx context.Context, cfg Config, watches *watchManage }) recordWatchAudit(toolStartSubscription, caller, out.SubscriptionID, topicFilter, 0, start, err, cfg) if err != nil { - return toolError[watchStartOutput](mqttbus.ErrorCode(err), publicMessage(err)) + return toolErrorFromErr[watchStartOutput](err) } return toolOK("started subscription "+out.SubscriptionID, out) } @@ -121,7 +122,7 @@ func readSubscriptionTool(ctx context.Context, cfg Config, watches *watchManager }) recordWatchAudit(toolReadSubscription, caller, in.SubscriptionID, "", out.Count, start, err, cfg) if err != nil { - return toolError[watchReadOutput](mqttbus.ErrorCode(err), publicMessage(err)) + return toolErrorFromErr[watchReadOutput](err) } return toolOK(fmt.Sprintf("read %d subscription messages", out.Count), out) } @@ -139,7 +140,7 @@ func statusSubscriptionTool(ctx context.Context, cfg Config, watches *watchManag }) recordWatchAudit(toolStatusSubscription, caller, in.SubscriptionID, out.TopicFilter, 0, start, err, cfg) if err != nil { - return toolError[watchStatusOutput](mqttbus.ErrorCode(err), publicMessage(err)) + return toolErrorFromErr[watchStatusOutput](err) } return toolOK("subscription status "+out.Status, out) } @@ -157,7 +158,7 @@ func stopSubscriptionTool(ctx context.Context, cfg Config, watches *watchManager }) recordWatchAudit(toolStopSubscription, caller, in.SubscriptionID, "", 0, start, err, cfg) if err != nil { - return toolError[watchStopOutput](mqttbus.ErrorCode(err), publicMessage(err)) + return toolErrorFromErr[watchStopOutput](err) } return toolOK("stopped subscription "+out.SubscriptionID, out) } @@ -222,11 +223,19 @@ func toolOK[T any](summary string, out T) (*mcp.CallToolResult, T, error) { } func toolError[T any](code, message string) (*mcp.CallToolResult, T, error) { + return toolErrorWithRetry[T](code, message, 0) +} + +func toolErrorFromErr[T any](err error) (*mcp.CallToolResult, T, error) { + return toolErrorWithRetry[T](mqttbus.ErrorCode(err), publicMessage(err), retryAfterSeconds(err)) +} + +func toolErrorWithRetry[T any](code, message string, retryAfter int) (*mcp.CallToolResult, T, error) { var zero T if code == "" { code = mqttbus.CodeInternalError } - body := structuredError{Error: errorBody{Code: code, Message: message}} + body := structuredError{Error: errorBody{Code: code, Message: message, RetryAfterSeconds: retryAfter}} raw, _ := json.Marshal(body) return &mcp.CallToolResult{ IsError: true, From 9f0c9df542b6b5599c90578fddc9603f138d503a Mon Sep 17 00:00:00 2001 From: Daniyal Rana Date: Fri, 12 Jun 2026 10:03:15 -0700 Subject: [PATCH 03/27] feat(mcp): support standalone bounded event bus tools Signed-off-by: Daniyal Rana --- local/Makefile | 1 + local/README.md | 35 +- mcp/dsx-exchange-mcp/Architecture.md | 41 +-- mcp/dsx-exchange-mcp/Makefile | 16 +- mcp/dsx-exchange-mcp/README.md | 208 ++++++------ .../cmd/dsx-exchange-mcp-load/main.go | 33 +- .../cmd/dsx-exchange-mcp-load/main_test.go | 23 ++ .../cmd/dsx-exchange-mcp/main.go | 48 +-- .../templates/deployment.yaml | 32 +- .../values.deployed-bus.example.yaml | 3 +- .../helm/dsx-exchange-mcp/values.kind.yaml | 49 +++ .../deploy/helm/dsx-exchange-mcp/values.yaml | 20 +- mcp/dsx-exchange-mcp/docs/load-testing.md | 105 +++--- mcp/dsx-exchange-mcp/internal/auth/context.go | 2 +- .../internal/metrics/metrics.go | 309 ------------------ .../internal/metrics/metrics_test.go | 60 ---- .../internal/mqttbus/client.go | 94 ++++-- .../internal/mqttbus/client_test.go | 60 ++++ .../internal/mqttbus/e2e_test.go | 20 +- .../internal/server/e2e_test.go | 4 +- .../internal/server/llm_eval_test.go | 9 +- .../internal/server/server.go | 49 ++- .../testdata/tool_call_expectations.json | 9 +- mcp/dsx-exchange-mcp/internal/server/tools.go | 109 +++--- .../internal/server/tools_test.go | 44 ++- mcp/dsx-exchange-mcp/internal/server/watch.go | 19 -- .../internal/server/watch_tools.go | 31 +- mcp/dsx-exchange-mcp/skaffold.yaml | 39 +++ skaffold.yaml | 3 + 29 files changed, 624 insertions(+), 851 deletions(-) create mode 100644 mcp/dsx-exchange-mcp/deploy/helm/dsx-exchange-mcp/values.kind.yaml delete mode 100644 mcp/dsx-exchange-mcp/internal/metrics/metrics.go delete mode 100644 mcp/dsx-exchange-mcp/internal/metrics/metrics_test.go create mode 100644 mcp/dsx-exchange-mcp/skaffold.yaml diff --git a/local/Makefile b/local/Makefile index cc3e926..c8584f5 100644 --- a/local/Makefile +++ b/local/Makefile @@ -68,6 +68,7 @@ dummy-bms: ## Publish looping dummy BMS data to the CSC MQTT broker status: ## Check deployment status @echo "CSC Cluster:" @kubectl get pods -n event-bus --context kind-csc 2>/dev/null || echo " NATS: Not deployed" + @kubectl get pods -n mcp-backends --context kind-csc 2>/dev/null || echo " MCP: Not deployed" @echo "" @echo "CPC-1 Cluster:" @kubectl get pods -n event-bus --context kind-cpc-1 2>/dev/null || echo " NATS: Not deployed" diff --git a/local/README.md b/local/README.md index b2a9802..d8118b7 100644 --- a/local/README.md +++ b/local/README.md @@ -59,13 +59,14 @@ Use `make skaffold-run` for deploy-only local setup. ### Skaffold -The root `skaffold.yaml` imports `local/infra/skaffold.yaml` and -`local/nats/skaffold.yaml`. Skaffold deploys the cluster infrastructure, builds -the auth-callout image, and installs the event-bus chart. Host scripts still -handle prerequisites, Kind cluster creation, the local registry, and generated -NATS secret material. The local Skaffold entrypoints import smaller domain files -for MetalLB, Envoy Gateway, cert-manager, metrics-server, Prometheus, Keycloak, -auth-callout image build, secret manifests, and NATS releases. +The root `skaffold.yaml` imports `local/infra/skaffold.yaml`, +`local/nats/skaffold.yaml`, and `mcp/dsx-exchange-mcp/skaffold.yaml`. Skaffold +deploys the cluster infrastructure, builds the auth-callout and MCP images, and +installs the event-bus and MCP charts. Host scripts still handle prerequisites, +Kind cluster creation, the local registry, and generated NATS secret material. +The local Skaffold entrypoints import smaller domain files for MetalLB, Envoy +Gateway, cert-manager, metrics-server, Prometheus, Keycloak, auth-callout image +build, secret manifests, NATS releases, and the MCP backend. For iterative development, keep Skaffold running in one terminal: @@ -164,3 +165,23 @@ make dummy-bms The dummy BMS target uses the same local e2e environment and Envoy Gateway LoadBalancer path as the functional and performance tests. It publishes to the CSC broker at `tcp://172.18.200.1:1883` unless `CSC_BROKER_URL` is overridden. + +### DSX Exchange MCP + +The local stack also deploys `dsx-exchange-mcp` into the CSC Kind cluster. This +is a direct backend deployment, not an MCP gateway deployment. It is intended +for manual MCP client checks against the same local Event Bus services used by +the e2e tests. + +After `make skaffold-run`, expose the MCP backend locally: + +```bash +cd ../mcp/dsx-exchange-mcp +make port-forward-kind +``` + +Configure the MCP client with `http://127.0.0.1:18080/mcp`. The local MCP Kind +deployment uses the Event Bus noauth path by default, so do not configure an +MCP bearer token and do not send a dummy token. Schema discovery tools do not +connect to MQTT. Broker-backed tools connect to the local Event Bus without +MQTT username/password, matching the local evaluation noauth setup. diff --git a/mcp/dsx-exchange-mcp/Architecture.md b/mcp/dsx-exchange-mcp/Architecture.md index c5ef96c..6e73ccb 100644 --- a/mcp/dsx-exchange-mcp/Architecture.md +++ b/mcp/dsx-exchange-mcp/Architecture.md @@ -62,7 +62,6 @@ internal/mqttbus/client.go | v internal/server/tools.go - records metrics writes audit log returns MCP result ``` @@ -75,14 +74,13 @@ For an MCP resource read, the flow stops inside `internal/specs`; no MQTT connec | --- | --- | | `cmd/dsx-exchange-mcp/main.go` | Process entrypoint. Reads env config, builds the MCP server, registers HTTP routes, starts `ListenAndServe`. | | `internal/server/server.go` | Creates the MCP server instance and registers tools/resources. | -| `internal/server/tools.go` | Defines MCP tools, parses tool inputs, describes schema topics, enforces bounds, calls MQTT collection, emits audit logs and metrics. | +| `internal/server/tools.go` | Defines MCP tools, parses tool inputs, describes schema topics, enforces bounds, calls MQTT collection, and emits audit logs. | | `internal/server/resources.go` | Defines MCP resources backed by embedded DSX specs. | | `internal/specs/specs.go` | Exposes raw spec resources from the embedded `schemas/` tree. | | `internal/schemaindex/index.go` | Parses AsyncAPI channel/message/operation primitives into a topic catalogue for schema exploration tools. | | `schemas/` | Generated copy of the monorepo root `schemas/`, embedded into the binary by `schemas/embed.go`. | | `internal/mqttbus/client.go` | MQTT/NATS client logic: connect, subscribe, collect messages, classify broker errors. | | `internal/auth/context.go` | Pulls Gateway-provided bearer and identity headers into Go context. | -| `internal/metrics/metrics.go` | In-process Prometheus text metrics endpoint. | | `deploy/helm/dsx-exchange-mcp/templates/deployment.yaml` | Kubernetes Deployment: env vars, probes, security context, runtime class. | | `deploy/helm/dsx-exchange-mcp/templates/service.yaml` | Kubernetes Service that Gateway discovers/routes to. | | `deploy/helm/dsx-exchange-mcp/values.yaml` | Default deploy-time configuration. | @@ -94,7 +92,6 @@ The binary starts in `cmd/dsx-exchange-mcp/main.go`. ```go addr := envOr("MCP_ADDR", ":8080") natsURL := envOr("NATS_URL", "tcp://nats:1883") -recorder := metrics.NewRecorder() ``` The entrypoint builds one `server.Config` from environment variables: @@ -105,7 +102,6 @@ cfg := server.Config{ BrokerURL: natsURL, Username: envOr("MQTT_USERNAME", mqttbus.DefaultUsername), }, - Metrics: recorder, DefaultMaxMessages: intEnvOr("MCP_DEFAULT_MAX_MESSAGES", 100), MaxMessages: intEnvOr("MCP_MAX_MESSAGES", 1000), DefaultDurationS: intEnvOr("MCP_DEFAULT_MAX_DURATION_S", 30), @@ -124,7 +120,6 @@ handler := mcp.NewStreamableHTTPHandler(func(*http.Request) *mcp.Server { mux.Handle("/mcp", auth.Middleware(handler)) mux.HandleFunc("/healthz/live", healthOK) mux.HandleFunc("/healthz/ready", healthOK) -mux.Handle("/metrics", recorder.Handler()) ``` Important detail: this service uses MCP Streamable HTTP, but the current tools are bounded request/response calls. It does not currently maintain long-lived background subscriptions for clients. @@ -218,7 +213,7 @@ The two MQTT data tools eventually call `collectTool`, which: 2. Applies max message and max duration defaults. 3. Calls MQTT collection. 4. Converts the result into MCP content. -5. Emits metrics and audit logs. +5. Emits an audit log. The MQTT call is direct: @@ -507,7 +502,7 @@ That means pods are intended to run with the configured Kata runtime class in th ## Observability -There are three observability paths in the current code. +There are two observability paths in the current code. ### Health @@ -518,35 +513,7 @@ There are three observability paths in the current code. /healthz/ready ``` -Both currently return HTTP 200 with body `ok`. - -### Metrics - -`internal/metrics/metrics.go` implements a lightweight Prometheus text endpoint mounted at: - -```text -/metrics -``` - -Current metrics include: - -```text -dsx_exchange_mcp_active_tool_calls -dsx_exchange_mcp_tool_calls_total{tool} -dsx_exchange_mcp_tool_errors_total{tool,code} -dsx_exchange_mcp_tool_duration_seconds_sum{tool} -dsx_exchange_mcp_mqtt_messages_collected_total{tool} -dsx_exchange_mcp_stopped_reasons_total{tool,reason} -``` - -The Helm chart enables scrape annotations by default: - -```yaml -metrics: - scrape: true - path: /metrics - port: http -``` +Both currently return HTTP 204 with no response body. ### Audit Logs diff --git a/mcp/dsx-exchange-mcp/Makefile b/mcp/dsx-exchange-mcp/Makefile index f5bc8a0..694b50d 100644 --- a/mcp/dsx-exchange-mcp/Makefile +++ b/mcp/dsx-exchange-mcp/Makefile @@ -6,18 +6,32 @@ LOAD_BINARY := dsx-exchange-mcp-load PKG := github.com/NVIDIA/dsx-exchange/mcp/dsx-exchange-mcp SCHEMA_SRC ?= ../../schemas GOFLAGS ?= -mod=vendor +SKAFFOLD ?= skaffold +KUBECTL ?= kubectl +KIND_CONTEXT ?= kind-csc +MCP_NAMESPACE ?= mcp-backends +MCP_RELEASE ?= dsx-exchange-mcp +MCP_LOCAL_PORT ?= 18080 -.PHONY: build build-load run test tidy vendor lint sync-specs verify-specs image load-image clean +.PHONY: build build-load run skaffold-run-kind port-forward-kind test tidy vendor lint sync-specs verify-specs image load-image clean build: + mkdir -p bin go build $(GOFLAGS) -o bin/$(BINARY) ./cmd/$(BINARY) build-load: + mkdir -p bin go build $(GOFLAGS) -o bin/$(LOAD_BINARY) ./cmd/$(LOAD_BINARY) run: sync-specs build ./bin/$(BINARY) +skaffold-run-kind: + "$(SKAFFOLD)" run -f skaffold.yaml --cleanup=false + +port-forward-kind: + "$(KUBECTL)" --context "$(KIND_CONTEXT)" -n "$(MCP_NAMESPACE)" port-forward "svc/$(MCP_RELEASE)" "$(MCP_LOCAL_PORT):8080" + test: go test $(GOFLAGS) ./... diff --git a/mcp/dsx-exchange-mcp/README.md b/mcp/dsx-exchange-mcp/README.md index 0347e13..8d2e1b2 100644 --- a/mcp/dsx-exchange-mcp/README.md +++ b/mcp/dsx-exchange-mcp/README.md @@ -3,8 +3,7 @@ MCP server that exposes the DSX Exchange AsyncAPI specs as Resources and a read-only NATS-MQTT bridge as Tools. One server for all DSX Exchange domains. -Runs as one of the upstream MCP servers behind the Latinum MCP Gateway -(agentgateway). +Runs standalone over Streamable HTTP. ## What it exposes @@ -30,76 +29,77 @@ Bounded MQTT tools create a short-lived broker connection for one request and return within configured message, duration, and byte limits: - `dsx_exchange_subscribe(topic_filter, max_messages, max_duration_s)` — - subscribe and collect messages over a window. Use this for live values. + subscribe and collect messages over a bounded window. Use this for live + values. For watch/listen/monitor requests, MCP clients that support + background tool calls should run this tool in the background so the main + agent can keep working. If background execution is unavailable, use short + sampling windows and repeat the call. - `dsx_exchange_read_retained(topic_filter, max_messages)` — drain retained messages currently held by the broker. Use this for metadata; BMS values are not retained (republished on change every ~100 s). -Background watch tools are the v1 stand-in for long MQTT subscriptions. They -start a pod-local MQTT watch, then let the client poll status or bounded raw -buffer reads instead of blocking one MCP request for a long time: - -- `dsx_exchange_start_subscription(topic_filter|selector, ttl_seconds, ...)` — - start a pod-local background MQTT watch and return a `subscription_id`. -- `dsx_exchange_read_subscription(subscription_id, cursor, max_messages, max_bytes)` — - read a bounded raw batch from the watch buffer for detail/debug use. -- `dsx_exchange_subscription_status(subscription_id)` — inspect watch status, - counters, bounded per-topic update summaries, watermarks, expiry, and last - error. -- `dsx_exchange_stop_subscription(subscription_id)` — stop a watch and release - its local buffer. - Topic filters use standard MQTT wildcards: `+` (single level), `#` (multi-level, end of filter only). -Why background watches exist: MCP tool calls are fundamentally request/response. -A long MQTT subscription inside one tool call can tie up the MCP client while it -waits for stream data, which is a poor fit for sparse or ongoing telemetry. -MCP Tasks may eventually be a cleaner protocol-level answer, but that feature is -still experimental. The current watch tools provide a bounded, explicit v1 -pattern: start the MQTT work, return quickly, poll `subscription_status` for -aggregated updates, and stop the watch when done. Watches remain pod-local, -TTL-limited, buffer-limited, and session-pinned. +Why this split exists: MCP tool calls are fundamentally request/response. A +long MQTT subscription inside one foreground tool call can tie up the MCP client +while it waits for stream data, which is a poor fit for sparse or ongoing +telemetry. The preferred stateless pattern is to use `dsx_exchange_subscribe` +with bounded limits and have agent runtimes run long sampling calls in the +background when they support that primitive. MCP Tasks or response streaming may +eventually provide a cleaner protocol-level answer, but those paths are still +experimental for this use case. The public v1 surface intentionally avoids +server-side watch/listen/monitor state: one MQTT tool call creates a temporary +client, subscribes for a finite window, returns bounded results, and disconnects. ## Auth -The server holds **no credentials of its own**. The caller's JWT flows through -end-to-end: +The server supports two MQTT auth modes. It does not accept JWTs as tool +arguments. -1. The Latinum MCP Gateway validates the JWT via `ext_authz` and forwards - `Authorization: Bearer ` unchanged (SDD §2.4). -2. This server validates request shape and safety limits, but does not - duplicate DSX Exchange broker authZ policy in v1. -3. For tool calls, the same bearer is presented to NATS as the MQTT CONNECT - password (`username=oauthtoken`, `password=`). The NATS auth-callout - service validates it and enforces topic ACLs keyed on the OAuth2 identity. +- `jwt_passthrough` (default): each MCP request may include + `Authorization: Bearer `. Broker-backed tools present that bearer to + MQTT as `username=`, `password=`. The DSX Exchange + auth-callout validates the JWT and enforces topic ACLs. +- `noauth`: broker-backed tools send no MQTT username or password. Use this + only with local/dev Event Bus deployments configured with the noauth + anonymous fallback. -The DSX Exchange broker/auth-callout is the source of truth for token validity -and topic ACLs; this server maps broker failures into structured MCP errors. +Schema discovery tools do not connect to MQTT and therefore do not require a +bearer. Broker-backed tools in `jwt_passthrough` mode return a structured +`missing_bearer` tool error when the MCP request has no bearer. ## Layout ``` cmd/dsx-exchange-mcp main, env wiring, HTTP listener -internal/auth bearer extraction + gateway identity context +internal/auth bearer extraction + request identity context internal/server MCP server, resource & tool registration internal/specs raw AsyncAPI resources from embedded schemas internal/schemaindex parsed AsyncAPI topic catalogue for schema tools -internal/mqttbus paho v3 client wrapper (OAuth2 password + TLS) +internal/mqttbus paho v3 client wrapper (jwt_passthrough/noauth + TLS) deploy/helm chart (kata runtime, readonly rootfs, drop ALL caps) schemas/ generated embedded copy of monorepo root schemas/ ``` ## Build & run +Fast local process path: + ```sh cd mcp/dsx-exchange-mcp make sync-specs # copies ../../schemas/ into ./schemas make test make build -make run # listens on :8080, expects NATS at tcp://nats:1883 +make build-load +make run # listens on :8080 ``` +Configure an MCP client with `http://127.0.0.1:8080/mcp`. Schema resources and +schema discovery tools work without a broker connection. MQTT-backed tools need +`NATS_URL` to point at a reachable broker and need the MCP client to provide +`Authorization: Bearer `. + Images: ```sh @@ -117,7 +117,8 @@ Environment: | --- | --- | --- | | `MCP_ADDR` | `:8080` | listener for `/mcp` (Streamable HTTP) | | `NATS_URL` | `tcp://nats:1883` | MQTT 3.1.1 facade on the NATS broker | -| `MQTT_USERNAME` | `oauthtoken` | MQTT username for OAuth2 bearer auth | +| `MCP_MQTT_AUTH_MODE` | `jwt_passthrough` | `jwt_passthrough` or `noauth` | +| `MQTT_USERNAME` | `oauthtoken` | MQTT username used only in `jwt_passthrough` mode | | `MQTT_CONNECT_TIMEOUT_S` | `5` | timeout for MQTT CONNECT | | `MQTT_SUBSCRIBE_TIMEOUT_S` | `5` | timeout for MQTT SUBSCRIBE | | `MQTT_TLS_CA_FILE` | (unset) | optional root CA bundle for private broker CA | @@ -129,31 +130,21 @@ Environment: | `MCP_MAX_DURATION_S` | `30` | hard subscribe window cap | | `MQTT_MAX_RESULT_BYTES` | `1048576` | max returned topic+payload bytes | | `MCP_MQTT_COLLECT_MAX_CONCURRENT_PER_POD` | `100` | per-pod admission limit for bounded MQTT collectors | -| `MCP_MQTT_WATCH_START_MAX_CONCURRENT_PER_POD` | `500` | per-pod admission limit for watch start MQTT setup | -| `MCP_WATCH_DEFAULT_TTL_S` | `300` | default background watch TTL | -| `MCP_WATCH_MAX_TTL_S` | `900` | hard background watch TTL cap | -| `MCP_WATCH_DEFAULT_BUFFER_MESSAGES` | `100` | default watch ring-buffer message cap | -| `MCP_WATCH_MAX_BUFFER_MESSAGES` | `1000` | hard watch ring-buffer message cap | -| `MCP_WATCH_DEFAULT_BUFFER_BYTES` | `262144` | default watch ring-buffer byte cap | -| `MCP_WATCH_MAX_BUFFER_BYTES` | `1048576` | hard watch ring-buffer byte cap | -| `MCP_WATCH_MAX_PER_SESSION` | `10` | active background watch cap per MCP session | -| `MCP_WATCH_MAX_PER_POD` | `1000` | active background watch cap per pod | | `MCP_FIND_TOPICS_DEFAULT_LIMIT` | `20` | default schema search result cap | | `MCP_FIND_TOPICS_MAX_LIMIT` | `100` | hard schema search result cap | | `LOG_FORMAT` | `json` | structured logs | -| `OTEL_EXPORTER_OTLP_ENDPOINT` | (unset) | reserved for future OTLP push export; scrape `/metrics` today | -Health and metrics endpoints are served on the same listener: +Health endpoints are served on the same listener: - `/healthz/live` - `/healthz/ready` -- `/metrics` — Prometheus-compatible process/tool metrics TLS trust is deployment configuration, not MCP tool input. For deployed-bus -tests or production, mount the broker root CA and set `MQTT_TLS_CA_FILE`; agents -only provide bearer credentials and tool arguments. The caller bearer is passed -to MQTT as `password=` with the configured `MQTT_USERNAME`; broker -OAuth and topic ACL enforcement remain broker-side. +tests or production, mount the broker root CA and set `MQTT_TLS_CA_FILE`. +Agents provide bearer credentials through MCP request headers and tool +arguments only. In `noauth` local mode, do not provide a dummy token; the MQTT +client intentionally sends no username/password so the Event Bus noauth +fallback can match. The public schema tree is copied from the monorepo root `schemas/` directory. Override the location with `SCHEMA_SRC=/path/to/schemas make sync-specs`. @@ -167,42 +158,37 @@ resources or schema tool matches. To update specs, re-run `sync-specs` against a refreshed schema checkout and cut a new image. -## Deploy - -Helm chart at `deploy/helm/dsx-exchange-mcp/`. Defaults match the DSX security -posture from the gateway SDD: `runtimeClassName: kata`, non-root, read-only -root filesystem, `drop: ["ALL"]`, two replicas, preferred pod anti-affinity, -and a PodDisruptionBudget. The Service exposes a single ClusterIP on the MCP -port with `appProtocol: agentgateway.dev/mcp`; the gateway's -`AgentgatewayBackend` points at it. Local kind deployments should override -`runtimeClassName: ""`. - -Example gateway upstream entry: - -```yaml -upstreams: - - serviceName: dsx-exchange-mcp - portName: mcp - namespace: mcp-backends - serviceLabels: - app: dsx-exchange-mcp - port: 8080 - podSelector: - app: dsx-exchange-mcp +## Deploy to local Kind + +The Helm chart lives at `deploy/helm/dsx-exchange-mcp/`. Production-oriented +defaults keep the container non-root with a read-only root filesystem and +`drop: ["ALL"]`; local Kind overrides live in +`deploy/helm/dsx-exchange-mcp/values.kind.yaml`. + +The repo root Skaffold flow deploys the local Event Bus stack and this MCP +backend: + +```sh +make -C local skaffold-run ``` -The derived gateway target name is `dsx-exchange-mcp-mcp`, so tools are -prefixed as `dsx-exchange-mcp-mcp_dsx_exchange_subscribe` in multi-upstream -gateway deployments. +To deploy or redeploy only the MCP backend after the local stack already exists: -## Using it locally or behind the gateway +```sh +cd mcp/dsx-exchange-mcp +make skaffold-run-kind +``` -For a local backend-only loop, run the MCP server with a broker URL, MQTT -username, and any required broker CA trust. The MCP client must provide a bearer -token in the `Authorization` header for broker-backed tools. +Expose the backend for a desktop MCP client: -For the production-style path, put this server behind the Latinum MCP Gateway -and verify the setup checklist below. +```sh +make port-forward-kind +``` + +Configure the MCP client with `http://127.0.0.1:18080/mcp`. In Kind, the MCP pod +uses `tcp://nats.event-bus.svc.cluster.local:1883` from `values.kind.yaml`. +This path intentionally does not require an MCP gateway. The Kind values use +`MCP_MQTT_AUTH_MODE=noauth`, matching the local Event Bus noauth setup. ## Setup checklist @@ -210,30 +196,28 @@ Before an MCP client or load test can call broker-backed tools, verify: | Item | What the operator provides | Where this MCP expects it | | --- | --- | --- | -| Gateway route | A reachable Latinum MCP Gateway `/mcp` endpoint | `DSX_EXCHANGE_MCP_URL` for tests/tools | -| Stateful routing | Gateway routes the same `Mcp-Session-Id` to the same backend pod | Required for `start/read/status/stop_subscription` | +| MCP endpoint | A reachable direct server `/mcp` endpoint | `DSX_EXCHANGE_MCP_URL` for tests/tools | +| MQTT auth mode | `jwt_passthrough` for deployed broker auth, `noauth` for local anonymous fallback | Helm `mqtt.authMode`, runtime `MCP_MQTT_AUTH_MODE` | | Broker endpoint | MQTT endpoint for the DSX Event Bus | Helm `natsURL`, runtime `NATS_URL` | -| Broker username | OAuth profile username for MQTT CONNECT | Helm `mqtt.username`, runtime `MQTT_USERNAME` | +| Broker username | OAuth profile username for MQTT CONNECT in `jwt_passthrough` mode | Helm `mqtt.username`, runtime `MQTT_USERNAME` | | Broker CA | Root/intermediate CA bundle for broker TLS | Secret referenced by `mqtt.tls.caCertSecret.name/key` | | TLS server name | Broker certificate server name, if needed | Helm `mqtt.tls.serverName`, runtime `MQTT_TLS_SERVER_NAME` | -| Caller JWT | Fresh user/service bearer from approved secret manager flow | MCP `Authorization: Bearer ...`; load secret key `bearer` | +| Caller JWT | Fresh user/service bearer from approved secret manager flow when using `jwt_passthrough` | MCP `Authorization: Bearer ...`; load secret key `bearer` | | Allowed topics | Topics the caller JWT is authorized to read | E2E/load env topic inputs | If schema tools work but broker-backed tools return auth or subscribe errors, debug in this order: bearer freshness, broker CA trust, broker URL/server name, -topic ACLs, then gateway bearer passthrough. +and topic ACLs. Do not commit bearer tokens, CA files, cluster snapshots, or environment-specific -broker/gateway endpoints. +broker endpoints. ## E2E against deployed bus -Deployed-bus tests are opt-in because they require external broker, gateway, -JWT, topic, and CA setup. Stage 1 tests the MQTT bridge directly. Stage 2 tests -the MCP protocol path through either this server directly or the gateway. When -running through the gateway, point `DSX_EXCHANGE_MCP_URL` at the gateway `/mcp` -endpoint. If the gateway prefixes tools, either let the test discover the -`*_dsx_exchange_subscribe` tool or set `DSX_EXCHANGE_E2E_TOOL_NAME`. +Deployed-bus tests are opt-in because they require external broker, JWT, topic, +and CA setup. Stage 1 tests the MQTT bridge directly. Stage 2 tests the MCP +protocol path through this server's direct `/mcp` endpoint. Set +`DSX_EXCHANGE_MCP_URL` to the local process or port-forwarded Kind endpoint. Never commit bearer tokens, CA material, or topic names that are environment specific or sensitive. @@ -244,15 +228,12 @@ Validation ladder: # Direct MQTT bridge to the deployed broker. RUN_EXCHANGE_E2E_DEPLOYED_BUS=1 go test -mod=vendor ./internal/mqttbus -run TestDeployedBusE2E -# MCP schema/tool path through a direct backend or gateway /mcp endpoint. +# MCP schema/tool path through a direct backend /mcp endpoint. RUN_EXCHANGE_MCP_SCHEMA_E2E=1 go test -mod=vendor ./internal/server -run TestStagedMCPSchemaDescribeThroughEndpoint # MCP bounded broker-backed tool path. RUN_EXCHANGE_MCP_E2E=1 go test -mod=vendor ./internal/server -run TestStagedMCPE2EDeployedBus -# MCP async watch start/read/status/stop path. -RUN_EXCHANGE_MCP_WATCH_E2E=1 go test -mod=vendor ./internal/server -run TestStagedMCPWatchThroughEndpoint - # Curated prompt-to-tool fixture replay through the endpoint. RUN_EXCHANGE_MCP_QUALITY_E2E=1 go test -mod=vendor ./internal/server -run TestStagedMCPQualityFixturesThroughEndpoint ``` @@ -260,9 +241,10 @@ RUN_EXCHANGE_MCP_QUALITY_E2E=1 go test -mod=vendor ./internal/server -run TestSt Required environment for the staged MCP tests is the setup checklist above plus `DSX_EXCHANGE_MCP_URL`, `DSX_EXCHANGE_E2E_BEARER`, and the allowed topic inputs used by the selected test. For direct MQTT tests, provide the broker URL, -username if non-default, CA/server-name settings, bearer, and allowed/denied -topic inputs through the `DSX_EXCHANGE_MQTT_*` and `DSX_EXCHANGE_E2E_*` -environment variables. +`DSX_EXCHANGE_MQTT_AUTH_MODE`, username if non-default, CA/server-name +settings, bearer when using `jwt_passthrough`, and allowed/denied topic inputs +through the `DSX_EXCHANGE_MQTT_*` and `DSX_EXCHANGE_E2E_*` environment +variables. ## Local LLM prompt eval @@ -271,8 +253,8 @@ through an OpenAI-compatible local LLM endpoint, executes emitted MCP tool calls logs the tool trace, and compares the model's final tool plan with `internal/server/testdata/tool_call_expectations.json`. -For the gateway path, set `DSX_EXCHANGE_MCP_URL` to the Latinum MCP Gateway -`/mcp` endpoint. If it is unset, the test starts an in-process MCP server. +Set `DSX_EXCHANGE_MCP_URL` to a local process or port-forwarded Kind `/mcp` +endpoint. If it is unset, the test starts an in-process MCP server. See `docs/local-llm-mcp-eval.md`. @@ -289,15 +271,13 @@ evidence and should stay under ignored `reports/`. ## Status Alpha. Populated specs load and surface as resources when synced into the -embedded bundle. The MQTT tools use paho v3 and pass OAuth2 bearer credentials -to the broker as `username=`, `password=`. Broker-side -auth-callout remains the source of truth for topic ACLs. Background watches are -pod-local, session-pinned, and intentionally limited to start/read/status/stop -for v1. +embedded bundle. The MQTT tools use paho v3 and support `jwt_passthrough` and +`noauth` broker modes. Broker-side auth-callout remains the source of truth for +JWT validation, anonymous fallback, and topic ACLs. Public v1 is stateless: +schema discovery plus finite bounded MQTT reads. ## References -- Latinum MCP Gateway SDD — `context/Latinum MCP Gateway - SDD (1).pdf` - Schema repo — `gitlab-master.nvidia.com/ncp/dsx/event-bus/schema` - Current v1 scope — `docs/current-v1-scope.md` - Load validation findings — `docs/load-testing.md` diff --git a/mcp/dsx-exchange-mcp/cmd/dsx-exchange-mcp-load/main.go b/mcp/dsx-exchange-mcp/cmd/dsx-exchange-mcp-load/main.go index 5535607..42d31cb 100644 --- a/mcp/dsx-exchange-mcp/cmd/dsx-exchange-mcp-load/main.go +++ b/mcp/dsx-exchange-mcp/cmd/dsx-exchange-mcp-load/main.go @@ -334,7 +334,7 @@ func parseConfig() config { flag.StringVar(&cfg.bearer, "bearer", firstEnv("DSX_EXCHANGE_E2E_BEARER", "DSX_EXCHANGE_BEARER"), "Bearer token for MCP Authorization") flag.StringVar(&cfg.experiment, "experiment", env("DSX_EXCHANGE_MCP_LOAD_EXPERIMENT", ""), "experiment label recorded in JSON/CSV reports") flag.StringVar(&cfg.experimentDetail, "experiment-detail", env("DSX_EXCHANGE_MCP_LOAD_EXPERIMENT_DETAIL", ""), "free-form experiment detail recorded in JSON/CSV reports") - flag.StringVar(&cfg.scenario, "scenario", env("DSX_EXCHANGE_MCP_LOAD_SCENARIO", "discovery"), "scenario: discovery, discovery-hold, schema-resources, bounded-read, watch, watch-hold, watch-status-hold, sticky-check, mixed") + flag.StringVar(&cfg.scenario, "scenario", env("DSX_EXCHANGE_MCP_LOAD_SCENARIO", "discovery"), "scenario: discovery, discovery-hold, schema-resources, bounded-read, mixed, mixed-stateless, or legacy watch/watch-hold/watch-status-hold/sticky-check") flag.IntVar(&cfg.sessions, "sessions", envInt("DSX_EXCHANGE_MCP_LOAD_SESSIONS", 50), "concurrent MCP sessions") flag.StringVar(&cfg.sessionSweep, "session-sweep", env("DSX_EXCHANGE_MCP_LOAD_SESSION_SWEEP", ""), "comma-separated concurrent MCP session counts; overrides -sessions when set") flag.IntVar(&cfg.backendReplicas, "backend-replicas", envInt("DSX_EXCHANGE_MCP_LOAD_BACKEND_REPLICAS", 0), "metadata: MCP backend replica count for this experiment") @@ -402,7 +402,7 @@ func validateConfig(cfg config) error { return errors.New("-gateway-rate-limit-rps must be zero or greater") } switch cfg.scenario { - case "discovery", "discovery-hold", "schema-resources", "bounded-read", "watch", "watch-hold", "watch-status-hold", "sticky-check", "mixed": + case "discovery", "discovery-hold", "schema-resources", "bounded-read", "mixed-stateless", "watch", "watch-hold", "watch-status-hold", "sticky-check", "mixed": default: return fmt.Errorf("unknown scenario %q", cfg.scenario) } @@ -564,11 +564,9 @@ func runSession(ctx context.Context, cfg config, rec *recorder, client *mcpClien }); err != nil { return } - if sessionID == "" { - rec.recordError("initialize_empty_session_id") - return + if sessionID != "" { + rec.recordInitializedSession() } - rec.recordInitializedSession() if _, err := measure(ctx, rec, "notifications_initialized", func(ctx context.Context) error { return client.initialized(ctx, sessionID) }); err != nil { @@ -609,17 +607,19 @@ func runSession(ctx context.Context, cfg config, rec *recorder, client *mcpClien runSchemaResources(ctx, rec, client, sessionID) case "bounded-read": runBoundedRead(ctx, cfg, rec, client, sessionID, names) + case "mixed-stateless": + if rng.Intn(100) < 60 { + runDiscovery(ctx, cfg, rec, client, sessionID, names) + } else { + runBoundedRead(ctx, cfg, rec, client, sessionID, names) + } case "watch": runWatch(ctx, cfg, rec, client, sessionID, names) case "mixed": - n := rng.Intn(100) - switch { - case n < 60: + if rng.Intn(100) < 60 { runDiscovery(ctx, cfg, rec, client, sessionID, names) - case n < 85: + } else { runBoundedRead(ctx, cfg, rec, client, sessionID, names) - default: - runWatch(ctx, cfg, rec, client, sessionID, names) } } } @@ -916,6 +916,9 @@ func measure(ctx context.Context, rec *recorder, operation string, fn func(conte err := fn(ctx) duration := time.Since(start) if err != nil { + if ctx.Err() != nil && errors.Is(err, context.DeadlineExceeded) { + return "", err + } rec.record(operation, duration, false, err) return "", err } @@ -1159,7 +1162,7 @@ func (n toolNames) require(scenario string) error { if n.find == "" { return errors.New("missing_tool_find_topics") } - if scenario == "bounded-read" || scenario == "mixed" { + if scenario == "bounded-read" || scenario == "mixed-stateless" || scenario == "mixed" { if n.readRetained == "" { return errors.New("missing_tool_read_retained") } @@ -1167,12 +1170,12 @@ func (n toolNames) require(scenario string) error { return errors.New("missing_tool_subscribe") } } - if scenario == "watch" || scenario == "watch-hold" || scenario == "watch-status-hold" || scenario == "sticky-check" || scenario == "mixed" { + if scenario == "watch" || scenario == "watch-hold" || scenario == "watch-status-hold" || scenario == "sticky-check" { if n.start == "" || n.status == "" || n.stop == "" { return errors.New("missing_watch_tool") } } - if scenario == "watch" || scenario == "watch-hold" || scenario == "sticky-check" || scenario == "mixed" { + if scenario == "watch" || scenario == "watch-hold" || scenario == "sticky-check" { if n.read == "" { return errors.New("missing_watch_read_tool") } diff --git a/mcp/dsx-exchange-mcp/cmd/dsx-exchange-mcp-load/main_test.go b/mcp/dsx-exchange-mcp/cmd/dsx-exchange-mcp-load/main_test.go index 6e25371..ac1aed5 100644 --- a/mcp/dsx-exchange-mcp/cmd/dsx-exchange-mcp-load/main_test.go +++ b/mcp/dsx-exchange-mcp/cmd/dsx-exchange-mcp-load/main_test.go @@ -121,6 +121,29 @@ func TestRecorderCountsContextFailures(t *testing.T) { } } +func TestMeasureSkipsParentDeadlineCancellation(t *testing.T) { + rec := &recorder{ + startedAt: time.Now(), + byOperation: map[string]*operationStats{}, + errors: map[string]uint64{}, + errorSamples: map[string][]string{}, + } + ctx, cancel := context.WithTimeout(context.Background(), time.Nanosecond) + defer cancel() + <-ctx.Done() + + if _, err := measure(ctx, rec, "subscribe", func(context.Context) error { + return context.DeadlineExceeded + }); err == nil { + t.Fatal("measure returned nil error, want deadline") + } + + report := rec.snapshot(time.Now()) + if report.TotalRequests != 0 || report.Failures != 0 { + t.Fatalf("parent deadline cancellation was recorded: total=%d failures=%d", report.TotalRequests, report.Failures) + } +} + func TestClassifyErrorExtractsStructuredToolCode(t *testing.T) { err := errors.New(`unexpected_tool_error:{"error":{"code":"mqtt_admission_limited","message":"retry later","retry_after_seconds":1}}`) if got := classifyError(err); got != "tool_error_mqtt_admission_limited" { diff --git a/mcp/dsx-exchange-mcp/cmd/dsx-exchange-mcp/main.go b/mcp/dsx-exchange-mcp/cmd/dsx-exchange-mcp/main.go index 58fdd26..488dca0 100644 --- a/mcp/dsx-exchange-mcp/cmd/dsx-exchange-mcp/main.go +++ b/mcp/dsx-exchange-mcp/cmd/dsx-exchange-mcp/main.go @@ -13,7 +13,6 @@ import ( "github.com/modelcontextprotocol/go-sdk/mcp" "github.com/NVIDIA/dsx-exchange/mcp/dsx-exchange-mcp/internal/auth" - "github.com/NVIDIA/dsx-exchange/mcp/dsx-exchange-mcp/internal/metrics" "github.com/NVIDIA/dsx-exchange/mcp/dsx-exchange-mcp/internal/mqttbus" "github.com/NVIDIA/dsx-exchange/mcp/dsx-exchange-mcp/internal/server" ) @@ -24,12 +23,12 @@ func main() { addr := envOr("MCP_ADDR", ":8080") natsURL := envOr("NATS_URL", "tcp://nats:1883") - recorder := metrics.NewRecorder() cfg := server.Config{ MQTT: mqttbus.Config{ BrokerURL: natsURL, Username: envOr("MQTT_USERNAME", mqttbus.DefaultUsername), + AuthMode: mqttbus.AuthMode(envOr("MCP_MQTT_AUTH_MODE", string(mqttbus.DefaultAuthMode))), TLS: mqttbus.TLSConfig{ CAFile: os.Getenv("MQTT_TLS_CA_FILE"), ServerName: os.Getenv("MQTT_TLS_SERVER_NAME"), @@ -39,50 +38,51 @@ func main() { SubscribeTimeout: time.Duration(envInt("MQTT_SUBSCRIBE_TIMEOUT_S", 5)) * time.Second, MaxResultBytes: envInt("MQTT_MAX_RESULT_BYTES", 1048576), }, - Metrics: recorder, - DefaultMaxMessages: envInt("MCP_DEFAULT_MAX_MESSAGES", 100), - MaxMessages: envInt("MCP_MAX_MESSAGES", 1000), - DefaultDurationS: envInt("MCP_DEFAULT_MAX_DURATION_S", 30), - MaxDurationS: envInt("MCP_MAX_DURATION_S", 30), - MQTTCollectMaxConcurrent: envInt("MCP_MQTT_COLLECT_MAX_CONCURRENT_PER_POD", 100), - MQTTWatchStartMaxConcurrent: envInt("MCP_MQTT_WATCH_START_MAX_CONCURRENT_PER_POD", 500), - WatchDefaultTTLS: envInt("MCP_WATCH_DEFAULT_TTL_S", 300), - WatchMaxTTLS: envInt("MCP_WATCH_MAX_TTL_S", 900), - WatchDefaultBufferMessages: envInt("MCP_WATCH_DEFAULT_BUFFER_MESSAGES", 100), - WatchMaxBufferMessages: envInt("MCP_WATCH_MAX_BUFFER_MESSAGES", 1000), - WatchDefaultBufferBytes: envInt("MCP_WATCH_DEFAULT_BUFFER_BYTES", 262144), - WatchMaxBufferBytes: envInt("MCP_WATCH_MAX_BUFFER_BYTES", 1048576), - WatchMaxPerSession: envInt("MCP_WATCH_MAX_PER_SESSION", 10), - WatchMaxPerPod: envInt("MCP_WATCH_MAX_PER_POD", 1000), - FindTopicsDefaultLimit: envInt("MCP_FIND_TOPICS_DEFAULT_LIMIT", 20), - FindTopicsMaxLimit: envInt("MCP_FIND_TOPICS_MAX_LIMIT", 100), + DefaultMaxMessages: envInt("MCP_DEFAULT_MAX_MESSAGES", 100), + MaxMessages: envInt("MCP_MAX_MESSAGES", 1000), + DefaultDurationS: envInt("MCP_DEFAULT_MAX_DURATION_S", 30), + MaxDurationS: envInt("MCP_MAX_DURATION_S", 30), + MQTTCollectMaxConcurrent: envInt("MCP_MQTT_COLLECT_MAX_CONCURRENT_PER_POD", 100), + FindTopicsDefaultLimit: envInt("MCP_FIND_TOPICS_DEFAULT_LIMIT", 20), + FindTopicsMaxLimit: envInt("MCP_FIND_TOPICS_MAX_LIMIT", 100), + EnableExperimentalWatchTools: envBool("MCP_ENABLE_EXPERIMENTAL_WATCH_TOOLS", false), + } + + if cfg.EnableExperimentalWatchTools { + logger.Error("experimental watch tools are no longer exposed; use bounded dsx_exchange_subscribe calls") + os.Exit(2) + } + if err := cfg.MQTT.Validate(); err != nil { + logger.Error("invalid MQTT configuration", "err", err) + os.Exit(2) } srv := server.Build(cfg) handler := mcp.NewStreamableHTTPHandler( func(*http.Request) *mcp.Server { return srv }, - nil, + &mcp.StreamableHTTPOptions{ + Stateless: true, + JSONResponse: true, + }, ) mux := http.NewServeMux() mux.Handle("/mcp", auth.Middleware(handler)) mux.HandleFunc("/healthz/live", healthOK) mux.HandleFunc("/healthz/ready", healthOK) - mux.Handle("/metrics", recorder.Handler()) logger.Info("dsx-exchange-mcp listening", "addr", addr, "nats", natsURL, + "mqtt_auth_mode", cfg.MQTT.AuthMode, "mqtt_username", cfg.MQTT.Username, "mqtt_tls_ca_configured", cfg.MQTT.TLS.CAFile != "", "mqtt_tls_server_name", cfg.MQTT.TLS.ServerName, "max_messages", cfg.MaxMessages, "max_duration_s", cfg.MaxDurationS, "mqtt_collect_max_concurrent_per_pod", cfg.MQTTCollectMaxConcurrent, - "mqtt_watch_start_max_concurrent_per_pod", cfg.MQTTWatchStartMaxConcurrent, - "watch_max_ttl_s", cfg.WatchMaxTTLS, - "watch_max_per_pod", cfg.WatchMaxPerPod, + "experimental_watch_tools_enabled", cfg.EnableExperimentalWatchTools, ) if err := http.ListenAndServe(addr, mux); err != nil { logger.Error("server exited", "err", err) diff --git a/mcp/dsx-exchange-mcp/deploy/helm/dsx-exchange-mcp/templates/deployment.yaml b/mcp/dsx-exchange-mcp/deploy/helm/dsx-exchange-mcp/templates/deployment.yaml index 75dc6ad..63cc9e9 100644 --- a/mcp/dsx-exchange-mcp/deploy/helm/dsx-exchange-mcp/templates/deployment.yaml +++ b/mcp/dsx-exchange-mcp/deploy/helm/dsx-exchange-mcp/templates/deployment.yaml @@ -19,12 +19,6 @@ spec: metadata: labels: app: dsx-exchange-mcp - {{- if .Values.metrics.scrape }} - annotations: - prometheus.io/scrape: "true" - prometheus.io/port: "8080" - prometheus.io/path: "/metrics" - {{- end }} spec: {{- with .Values.runtimeClassName }} runtimeClassName: {{ . | quote }} @@ -56,6 +50,8 @@ spec: value: {{ .Values.natsURL | quote }} - name: MQTT_USERNAME value: {{ .Values.mqtt.username | quote }} + - name: MCP_MQTT_AUTH_MODE + value: {{ .Values.mqtt.authMode | quote }} - name: MQTT_CONNECT_TIMEOUT_S value: {{ .Values.mqtt.connectTimeoutSeconds | int | quote }} - name: MQTT_SUBSCRIBE_TIMEOUT_S @@ -82,36 +78,12 @@ spec: value: {{ .Values.limits.maxDurationSeconds | int | quote }} - name: MCP_MQTT_COLLECT_MAX_CONCURRENT_PER_POD value: {{ .Values.limits.mqtt.collectMaxConcurrentPerPod | int | quote }} - - name: MCP_MQTT_WATCH_START_MAX_CONCURRENT_PER_POD - value: {{ .Values.limits.mqtt.watchStartMaxConcurrentPerPod | int | quote }} - - name: MCP_WATCH_DEFAULT_TTL_S - value: {{ .Values.limits.watch.defaultTTLSeconds | int | quote }} - - name: MCP_WATCH_MAX_TTL_S - value: {{ .Values.limits.watch.maxTTLSeconds | int | quote }} - - name: MCP_WATCH_DEFAULT_BUFFER_MESSAGES - value: {{ .Values.limits.watch.defaultBufferMessages | int | quote }} - - name: MCP_WATCH_MAX_BUFFER_MESSAGES - value: {{ .Values.limits.watch.maxBufferMessages | int | quote }} - - name: MCP_WATCH_DEFAULT_BUFFER_BYTES - value: {{ .Values.limits.watch.defaultBufferBytes | int | quote }} - - name: MCP_WATCH_MAX_BUFFER_BYTES - value: {{ .Values.limits.watch.maxBufferBytes | int | quote }} - - name: MCP_WATCH_MAX_PER_SESSION - value: {{ .Values.limits.watch.maxPerSession | int | quote }} - - name: MCP_WATCH_MAX_PER_POD - value: {{ .Values.limits.watch.maxPerPod | int | quote }} - name: MCP_FIND_TOPICS_DEFAULT_LIMIT value: {{ .Values.limits.findTopics.defaultLimit | int | quote }} - name: MCP_FIND_TOPICS_MAX_LIMIT value: {{ .Values.limits.findTopics.maxLimit | int | quote }} - name: LOG_FORMAT value: json - {{- if .Values.otel.exporterOtlpEndpoint }} - - name: OTEL_EXPORTER_OTLP_ENDPOINT - value: {{ .Values.otel.exporterOtlpEndpoint | quote }} - - name: OTEL_SERVICE_NAME - value: {{ .Values.otel.serviceName | quote }} - {{- end }} startupProbe: httpGet: path: /healthz/live diff --git a/mcp/dsx-exchange-mcp/deploy/helm/dsx-exchange-mcp/values.deployed-bus.example.yaml b/mcp/dsx-exchange-mcp/deploy/helm/dsx-exchange-mcp/values.deployed-bus.example.yaml index 47bbf0c..a1b415f 100644 --- a/mcp/dsx-exchange-mcp/deploy/helm/dsx-exchange-mcp/values.deployed-bus.example.yaml +++ b/mcp/dsx-exchange-mcp/deploy/helm/dsx-exchange-mcp/values.deployed-bus.example.yaml @@ -10,7 +10,8 @@ natsURL: tls://event-bus-ytl-dev2.dev.dsx.nvidia.com:1883 runtimeClassName: "" mqtt: - username: oauth + authMode: jwt_passthrough + username: oauthtoken tls: caCertSecret: name: dsx-exchange-broker-ca diff --git a/mcp/dsx-exchange-mcp/deploy/helm/dsx-exchange-mcp/values.kind.yaml b/mcp/dsx-exchange-mcp/deploy/helm/dsx-exchange-mcp/values.kind.yaml new file mode 100644 index 0000000..849089c --- /dev/null +++ b/mcp/dsx-exchange-mcp/deploy/helm/dsx-exchange-mcp/values.kind.yaml @@ -0,0 +1,49 @@ +# Copyright 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +# Local Kind values for manual MCP client validation. +# Deploys the backend directly; no MCP gateway is required. + +image: + repository: localhost:5001/dsx-exchange-mcp + tag: local + pullPolicy: Always + +replicaCount: 1 +runtimeClassName: "" + +natsURL: tcp://nats.event-bus.svc.cluster.local:1883 + +mqtt: + authMode: noauth + username: "" + connectTimeoutSeconds: 5 + subscribeTimeoutSeconds: 5 + maxResultBytes: 1048576 + tls: + caCertSecret: + name: "" + key: ca.crt + serverName: "" + insecureSkipVerify: false + +limits: + defaultMaxMessages: 100 + maxMessages: 1000 + defaultMaxDurationSeconds: 30 + maxDurationSeconds: 30 + mqtt: + collectMaxConcurrentPerPod: 100 + +podDisruptionBudget: + enabled: false + +affinity: null + +resources: + requests: + cpu: 50m + memory: 64Mi + limits: + cpu: 500m + memory: 256Mi diff --git a/mcp/dsx-exchange-mcp/deploy/helm/dsx-exchange-mcp/values.yaml b/mcp/dsx-exchange-mcp/deploy/helm/dsx-exchange-mcp/values.yaml index 12a3681..712cdf1 100644 --- a/mcp/dsx-exchange-mcp/deploy/helm/dsx-exchange-mcp/values.yaml +++ b/mcp/dsx-exchange-mcp/deploy/helm/dsx-exchange-mcp/values.yaml @@ -16,12 +16,13 @@ podDisruptionBudget: minAvailable: 1 # Per Latinum MCP Gateway SDD: upstream MCP servers run with kata, nonroot, -# readonly rootfs, and emit JSON logs. OTel Collector can scrape /metrics. +# readonly rootfs, and emit JSON logs. runtimeClassName: kata natsURL: tcp://nats.nats.svc:1883 mqtt: + authMode: jwt_passthrough username: oauthtoken connectTimeoutSeconds: 5 subscribeTimeoutSeconds: 5 @@ -40,23 +41,10 @@ limits: maxDurationSeconds: 30 mqtt: collectMaxConcurrentPerPod: 100 - watchStartMaxConcurrentPerPod: 500 - watch: - defaultTTLSeconds: 300 - maxTTLSeconds: 900 - defaultBufferMessages: 100 - maxBufferMessages: 1000 - defaultBufferBytes: 262144 - maxBufferBytes: 1048576 - maxPerSession: 10 - maxPerPod: 1000 findTopics: defaultLimit: 20 maxLimit: 100 -metrics: - scrape: true - acceptInsecureMQTTTLS: false affinity: @@ -76,7 +64,3 @@ resources: limits: cpu: 500m memory: 256Mi - -otel: - exporterOtlpEndpoint: "" - serviceName: dsx-exchange-mcp diff --git a/mcp/dsx-exchange-mcp/docs/load-testing.md b/mcp/dsx-exchange-mcp/docs/load-testing.md index d38d1b8..23eaeff 100644 --- a/mcp/dsx-exchange-mcp/docs/load-testing.md +++ b/mcp/dsx-exchange-mcp/docs/load-testing.md @@ -5,16 +5,18 @@ SPDX-License-Identifier: Apache-2.0 # DSX Exchange MCP Load Testing -This note explains how to reproduce the current load-test methodology and -records the stable findings from local gateway-backed experiments. Raw report -bundles are intentionally not committed; they belong under ignored `reports/`. +This note explains how to reproduce the current load-test methodology and keeps +the stable findings from local experiments. Raw report bundles are intentionally +not committed; they belong under ignored `reports/`. ## What The Harness Tests `cmd/dsx-exchange-mcp-load` creates many independent MCP clients. Each client -performs its own MCP initialize flow, keeps its own `Mcp-Session-Id`, lists -tools, and then runs one workload scenario. This is not an LLM test; it is a -protocol and backend-capacity test. +performs its own MCP initialize flow, lists tools, and then runs one workload +scenario. Current `dsx-exchange-mcp` uses stateless JSON Streamable HTTP, so a +server may not return `Mcp-Session-Id`; the harness handles both stateless and +stateful endpoints. This is not an LLM test; it is a protocol and +backend-capacity test. Scenarios: @@ -23,11 +25,12 @@ Scenarios: | `discovery` | Exercise schema tools only: `find_topics` and `describe_topic`. | | `schema-resources` | Exercise MCP resources: `resources/list` and `resources/read`. | | `bounded-read` | Exercise broker-facing bounded reads: retained reads and short live subscribes. | -| `watch` | Start, read, status-check, and stop background watches. | -| `watch-hold` | Start watches and hold them open to measure startup and active-watch pressure. | -| `watch-status-hold` | Start watches, then poll aggregated `subscription_status`. | -| `sticky-check` | Verify a subscription can be read/statused/stopped on the same MCP session. | -| `mixed` | Mix schema tools, bounded MQTT tools, and background watches. | +| `mixed` | Mix schema tools and bounded MQTT tools. | +| `mixed-stateless` | Alias-style scenario with the same public-surface intent as `mixed`. | + +Legacy watch scenarios (`watch`, `watch-hold`, `watch-status-hold`, and +`sticky-check`) remain in the harness for historical report comparison, but +current public v1 does not expose watch/listen/monitor lifecycle tools. `deploy/loadtest/run-kind-load-experiment.sh` wraps the load binary for local Kind/gateway experiments. It records the manifest, image IDs, token TTL @@ -36,21 +39,23 @@ latency, and per-operation error attribution. ## Reproduction Requirements -The load harness depends on systems outside this MCP repo. Before running it, -the operator needs: +The load harness depends on systems outside this MCP repo. Before running +gateway-backed deployed-bus tests, the operator needs: -- a reachable MCP gateway `/mcp` endpoint -- the `dsx-exchange-mcp` backend deployed behind that gateway -- stateful MCP session routing enabled when testing background watches +- a reachable MCP `/mcp` endpoint, either direct backend or gateway - backend configuration for the target Event Bus MQTT endpoint through Helm `natsURL` -- broker username configured through Helm `mqtt.username` +- MQTT auth mode configured through Helm `mqtt.authMode` +- broker username configured through Helm `mqtt.username` when using + `jwt_passthrough` - broker TLS trust configured through Helm `mqtt.tls.caCertSecret.name/key` - broker TLS server name configured through Helm `mqtt.tls.serverName` when the certificate requires it -- a fresh caller JWT from the approved secret manager or Vault flow +- a fresh caller JWT from the approved secret manager or Vault flow when using + `jwt_passthrough` - the JWT available to the load job as a Kubernetes secret named - `dsx-exchange-mcp-load-token` with key `bearer` + `dsx-exchange-mcp-load-token` with key `bearer` when using + `jwt_passthrough` - the MCP backend image and load-generator image available to the cluster Do not commit tokens, CA material, cluster snapshots, local endpoint names, or @@ -89,19 +94,19 @@ Use this checklist before treating load-test failures as MCP bugs: | Check | Expected state | | --- | --- | -| Gateway endpoint | MCP clients can reach the gateway `/mcp` endpoint. | -| Gateway bearer passthrough | The caller bearer reaches `dsx-exchange-mcp` as `Authorization`. | -| Stateful routing | Calls with the same `Mcp-Session-Id` route to the same backend pod. | +| MCP endpoint | MCP clients can reach the direct backend or gateway `/mcp` endpoint. | +| MQTT auth mode | Backend uses `jwt_passthrough` for deployed auth or `noauth` for local anonymous fallback. | +| Bearer passthrough | In `jwt_passthrough`, the caller bearer reaches `dsx-exchange-mcp` as `Authorization`. | | Broker endpoint | Backend `NATS_URL` points at the intended MQTT endpoint. | -| Broker username | Backend `MQTT_USERNAME` matches the broker OAuth profile. | +| Broker username | In `jwt_passthrough`, backend `MQTT_USERNAME` matches the broker OAuth profile. | | Broker CA | Backend has a mounted CA file and `MQTT_TLS_CA_FILE` points to it. | | TLS server name | Backend server-name override matches the broker certificate when required. | -| Load JWT secret | Load namespace has `dsx-exchange-mcp-load-token` with data key `bearer`. | -| JWT freshness | Token TTL is long enough for the full experiment. | +| Load JWT secret | In `jwt_passthrough`, load namespace has `dsx-exchange-mcp-load-token` with data key `bearer`. | +| JWT freshness | In `jwt_passthrough`, token TTL is long enough for the full experiment. | | Topic ACLs | The load topics are authorized for the caller identity. | -If `discovery` passes but `bounded-read`, `watch`, or `mixed` fail, the next -checks are bearer freshness, broker CA/server-name settings, topic ACLs, broker +If `discovery` passes but `bounded-read` or `mixed` fails, the next checks are +auth mode, bearer freshness, broker CA/server-name settings, topic ACLs, broker availability, and MQTT admission limits. ## Important Knobs @@ -110,7 +115,7 @@ Record these for every run so the result is reproducible: | Knob | Meaning | | --- | --- | -| `SCENARIO` | Workload shape, such as `discovery`, `mixed`, or `watch-status-hold`. | +| `SCENARIO` | Workload shape, such as `discovery`, `schema-resources`, `bounded-read`, or `mixed`. | | `SESSION_SWEEP` / `SESSIONS` | Concurrent MCP client/session counts. | | `STARTUP_RAMP` | Time window used to spread client startup. `0s` means an instant startup burst. | | `DURATION` | Total wall-clock runtime after the load job starts. | @@ -120,7 +125,6 @@ Record these for every run so the result is reproducible: | `BACKEND_CONNECT_TIMEOUT_S` | Backend MQTT connect timeout. | | `BACKEND_SUBSCRIBE_TIMEOUT_S` | Backend MQTT subscribe timeout. | | `BACKEND_COLLECT_MAX_CONCURRENT` | Per-pod admission limit for bounded MQTT collectors. | -| `BACKEND_WATCH_START_MAX_CONCURRENT` | Per-pod admission limit for watch startup. | | `TOPIC` / `RETAINED_TOPIC` | Allowed live and retained topic filters used by broker-facing scenarios. | | `RESET_BACKEND` | Whether the backend is restarted before the run. | @@ -133,8 +137,10 @@ from an earlier experiment. ## Findings From Current Experiments These findings came from local Kind/gateway experiments using 100, 500, and -1000 MCP sessions, 1-3 backend replicas, 30s/60s startup ramps, raised gateway -RPS variants, and MQTT timeout/admission experiments. +1000 MCP clients, 1-3 backend replicas, 30s/60s startup ramps, raised gateway +RPS variants, and MQTT timeout/admission experiments. Some older experiments +included watch lifecycle tools; those results are kept only as historical +capacity evidence. ### Schema Discovery Is Mostly Healthy @@ -172,7 +178,6 @@ calls. At high session counts, failures were dominated by: - bounded `subscribe` - `read_retained` -- `start_subscription` - MQTT admission limiting - broker unavailable or MQTT subscribe/connect deadline errors @@ -183,7 +188,7 @@ tool dispatch, JSON encode, and response path. When many MQTT calls are waiting on connect/subscribe or admission, they consume shared gateway/backend capacity and cheap calls can miss client deadlines. -### Watch Status Scales Better Than Mixed Bounded MQTT +### Historical Watch Status Result `watch-status-hold` performed much better than mixed load once watches were started and clients mostly polled `subscription_status`: @@ -196,17 +201,19 @@ started and clients mostly polled `subscription_status`: | `watch-status-hold` | 2 | 1000 | 98.743% | | `watch-status-hold` | 3 | 1000 | 98.864% | -This supports the v1 UX direction: prefer aggregated `subscription_status` for -operator-facing follow-up rather than repeatedly returning raw buffered data. +This was useful evidence that lightweight status polling is cheaper than +repeated broker startup. The public v1 direction has since moved away from +server-side watch state, so new UX validation should focus on finite +`dsx_exchange_subscribe` calls that clients can run in the background when they +support that primitive. ### Replicas Help Steady State, Not Broker Startup Storms -Adding backend replicas helped the watch-status steady state, but it did not -fix the mixed workload. The dominant mixed-load cost was concurrent MQTT -connect/subscribe against the external broker, not pure CPU work inside one MCP -pod. More pods can increase the number of simultaneous broker connection -attempts, so scaling replicas without admission control can move the bottleneck -to the broker/auth/network path faster. +Adding backend replicas did not automatically fix mixed bounded MQTT load. The +dominant cost was concurrent MQTT connect/subscribe against the external broker, +not pure CPU work inside one MCP pod. More pods can increase the number of +simultaneous broker connection attempts, so scaling replicas without admission +control can move the bottleneck to the broker/auth/network path faster. ### Timeout Alone Is Not A Fix @@ -222,10 +229,12 @@ For current v1, the MCP server is useful and bounded when: - schema discovery is used freely - retained/live bounded reads stay small -- background watches are pod-local and session-pinned -- `subscription_status` is the normal follow-up surface -- raw `read_subscription` is treated as detail/debug, not the primary UX - -The next scale work should focus on gateway resource handling, sticky-session -validation, MQTT startup backpressure, and pod-failure behavior before adding -durable external watch state. +- long listen/watch/monitor prompts are implemented as finite + `dsx_exchange_subscribe` calls +- MCP clients run long bounded calls in the background when they support that + UX primitive + +The next scale work should focus on gateway resource handling, MQTT startup +backpressure, and pod-failure behavior. Durable external watch state should stay +out of scope unless product/load evidence shows bounded background tool calls +are not enough. diff --git a/mcp/dsx-exchange-mcp/internal/auth/context.go b/mcp/dsx-exchange-mcp/internal/auth/context.go index 0818787..1e062d6 100644 --- a/mcp/dsx-exchange-mcp/internal/auth/context.go +++ b/mcp/dsx-exchange-mcp/internal/auth/context.go @@ -23,7 +23,7 @@ type Caller struct { SpiffeID string } -// Middleware extracts the caller bearer and gateway-projected identity headers +// Middleware extracts the caller bearer and identity headers // from the HTTP request and stores them on the request context. func Middleware(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { diff --git a/mcp/dsx-exchange-mcp/internal/metrics/metrics.go b/mcp/dsx-exchange-mcp/internal/metrics/metrics.go deleted file mode 100644 index a26ed58..0000000 --- a/mcp/dsx-exchange-mcp/internal/metrics/metrics.go +++ /dev/null @@ -1,309 +0,0 @@ -// Copyright 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. -// SPDX-License-Identifier: Apache-2.0 - -package metrics - -import ( - "fmt" - "net/http" - "runtime" - "sort" - "strings" - "sync" - "sync/atomic" - "time" -) - -// Recorder keeps a small Prometheus-compatible metrics surface without adding -// a dependency on a metrics framework. The service can swap this for OTel or -// prometheus/client_golang later without changing tool code. -type Recorder struct { - activeCalls int64 - activeWatches int64 - activeMQTTConnections int64 - watchMessages uint64 - watchDropped uint64 - - mu sync.Mutex - sessionTTL time.Duration - sessions map[string]time.Time - toolCalls map[string]uint64 - toolErrors map[labelKey]uint64 - toolDuration map[string]time.Duration - toolBuckets map[labelKey]uint64 - messageCounts map[string]uint64 - stoppedReasons map[labelKey]uint64 -} - -type labelKey struct { - Tool string - Value string -} - -func NewRecorder() *Recorder { - return &Recorder{ - sessionTTL: 10 * time.Minute, - sessions: map[string]time.Time{}, - toolCalls: map[string]uint64{}, - toolErrors: map[labelKey]uint64{}, - toolDuration: map[string]time.Duration{}, - toolBuckets: map[labelKey]uint64{}, - messageCounts: map[string]uint64{}, - stoppedReasons: map[labelKey]uint64{}, - } -} - -func (r *Recorder) BeginToolCall() { - atomic.AddInt64(&r.activeCalls, 1) -} - -func (r *Recorder) EndToolCall() { - atomic.AddInt64(&r.activeCalls, -1) -} - -func (r *Recorder) ObserveSession(sessionID string) { - if r == nil || strings.TrimSpace(sessionID) == "" { - return - } - r.mu.Lock() - defer r.mu.Unlock() - r.sessions[sessionID] = time.Now() -} - -func (r *Recorder) BeginMQTTConnection() { - if r == nil { - return - } - atomic.AddInt64(&r.activeMQTTConnections, 1) -} - -func (r *Recorder) EndMQTTConnection() { - if r == nil { - return - } - atomic.AddInt64(&r.activeMQTTConnections, -1) -} - -func (r *Recorder) BeginWatch() { - if r == nil { - return - } - atomic.AddInt64(&r.activeWatches, 1) -} - -func (r *Recorder) EndWatch() { - if r == nil { - return - } - atomic.AddInt64(&r.activeWatches, -1) -} - -func (r *Recorder) RecordWatchMessage() { - if r == nil { - return - } - atomic.AddUint64(&r.watchMessages, 1) -} - -func (r *Recorder) RecordWatchDrop(n int64) { - if r == nil || n <= 0 { - return - } - atomic.AddUint64(&r.watchDropped, uint64(n)) -} - -func (r *Recorder) RecordToolCall(tool, code, stoppedReason string, duration time.Duration, messages int) { - if r == nil { - return - } - r.mu.Lock() - defer r.mu.Unlock() - - r.toolCalls[tool]++ - r.toolDuration[tool] += duration - for _, bucket := range durationBuckets { - if duration.Seconds() <= bucket { - r.toolBuckets[labelKey{Tool: tool, Value: formatBucket(bucket)}]++ - } - } - if messages > 0 { - r.messageCounts[tool] += uint64(messages) - } - if code != "" { - r.toolErrors[labelKey{Tool: tool, Value: code}]++ - } - if stoppedReason != "" { - r.stoppedReasons[labelKey{Tool: tool, Value: stoppedReason}]++ - } -} - -var durationBuckets = []float64{0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1, 2.5, 5, 10, 30} - -func (r *Recorder) Handler() http.Handler { - return http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { - w.Header().Set("Content-Type", "text/plain; version=0.0.4; charset=utf-8") - r.writePrometheus(w) - }) -} - -func (r *Recorder) writePrometheus(w http.ResponseWriter) { - if r == nil { - fmt.Fprintln(w, "# no metrics recorder configured") - return - } - - r.mu.Lock() - activeSessions := r.activeSessionCountLocked(time.Now()) - toolCalls := cloneStringMap(r.toolCalls) - toolDuration := cloneDurationMap(r.toolDuration) - toolBuckets := cloneLabelMap(r.toolBuckets) - messageCounts := cloneStringMap(r.messageCounts) - toolErrors := cloneLabelMap(r.toolErrors) - stoppedReasons := cloneLabelMap(r.stoppedReasons) - r.mu.Unlock() - - var mem runtime.MemStats - runtime.ReadMemStats(&mem) - - fmt.Fprintln(w, "# HELP dsx_exchange_mcp_active_sessions_recent MCP sessions observed on this pod within the recent-session TTL.") - fmt.Fprintln(w, "# TYPE dsx_exchange_mcp_active_sessions_recent gauge") - fmt.Fprintf(w, "dsx_exchange_mcp_active_sessions_recent %d\n", activeSessions) - - fmt.Fprintln(w, "# HELP dsx_exchange_mcp_active_tool_calls Tool calls currently in flight.") - fmt.Fprintln(w, "# TYPE dsx_exchange_mcp_active_tool_calls gauge") - fmt.Fprintf(w, "dsx_exchange_mcp_active_tool_calls %d\n", atomic.LoadInt64(&r.activeCalls)) - - fmt.Fprintln(w, "# HELP dsx_exchange_mcp_active_background_watches Background watches currently active in this pod.") - fmt.Fprintln(w, "# TYPE dsx_exchange_mcp_active_background_watches gauge") - fmt.Fprintf(w, "dsx_exchange_mcp_active_background_watches %d\n", atomic.LoadInt64(&r.activeWatches)) - - fmt.Fprintln(w, "# HELP dsx_exchange_mcp_active_mqtt_connections MQTT connections currently open by this pod.") - fmt.Fprintln(w, "# TYPE dsx_exchange_mcp_active_mqtt_connections gauge") - fmt.Fprintf(w, "dsx_exchange_mcp_active_mqtt_connections %d\n", atomic.LoadInt64(&r.activeMQTTConnections)) - - fmt.Fprintln(w, "# HELP dsx_exchange_mcp_runtime_goroutines Goroutines currently running in this pod.") - fmt.Fprintln(w, "# TYPE dsx_exchange_mcp_runtime_goroutines gauge") - fmt.Fprintf(w, "dsx_exchange_mcp_runtime_goroutines %d\n", runtime.NumGoroutine()) - - fmt.Fprintln(w, "# HELP dsx_exchange_mcp_runtime_heap_alloc_bytes Bytes of allocated heap objects.") - fmt.Fprintln(w, "# TYPE dsx_exchange_mcp_runtime_heap_alloc_bytes gauge") - fmt.Fprintf(w, "dsx_exchange_mcp_runtime_heap_alloc_bytes %d\n", mem.HeapAlloc) - - fmt.Fprintln(w, "# HELP dsx_exchange_mcp_runtime_sys_bytes Bytes of memory obtained from the OS.") - fmt.Fprintln(w, "# TYPE dsx_exchange_mcp_runtime_sys_bytes gauge") - fmt.Fprintf(w, "dsx_exchange_mcp_runtime_sys_bytes %d\n", mem.Sys) - - fmt.Fprintln(w, "# HELP dsx_exchange_mcp_tool_calls_total Total tool calls by tool.") - fmt.Fprintln(w, "# TYPE dsx_exchange_mcp_tool_calls_total counter") - for _, tool := range sortedKeys(toolCalls) { - fmt.Fprintf(w, "dsx_exchange_mcp_tool_calls_total{tool=\"%s\"} %d\n", promLabel(tool), toolCalls[tool]) - } - - fmt.Fprintln(w, "# HELP dsx_exchange_mcp_tool_errors_total Tool errors by tool and code.") - fmt.Fprintln(w, "# TYPE dsx_exchange_mcp_tool_errors_total counter") - for _, k := range sortedLabelKeys(toolErrors) { - fmt.Fprintf(w, "dsx_exchange_mcp_tool_errors_total{tool=\"%s\",code=\"%s\"} %d\n", promLabel(k.Tool), promLabel(k.Value), toolErrors[k]) - } - - fmt.Fprintln(w, "# HELP dsx_exchange_mcp_tool_duration_seconds Tool duration histogram by tool.") - fmt.Fprintln(w, "# TYPE dsx_exchange_mcp_tool_duration_seconds histogram") - for _, tool := range sortedKeys(toolCalls) { - for _, bucket := range durationBuckets { - le := formatBucket(bucket) - fmt.Fprintf(w, "dsx_exchange_mcp_tool_duration_seconds_bucket{tool=\"%s\",le=\"%s\"} %d\n", promLabel(tool), le, toolBuckets[labelKey{Tool: tool, Value: le}]) - } - fmt.Fprintf(w, "dsx_exchange_mcp_tool_duration_seconds_bucket{tool=\"%s\",le=\"+Inf\"} %d\n", promLabel(tool), toolCalls[tool]) - fmt.Fprintf(w, "dsx_exchange_mcp_tool_duration_seconds_sum{tool=\"%s\"} %.6f\n", promLabel(tool), toolDuration[tool].Seconds()) - fmt.Fprintf(w, "dsx_exchange_mcp_tool_duration_seconds_count{tool=\"%s\"} %d\n", promLabel(tool), toolCalls[tool]) - } - - fmt.Fprintln(w, "# HELP dsx_exchange_mcp_mqtt_messages_collected_total MQTT messages returned by tool.") - fmt.Fprintln(w, "# TYPE dsx_exchange_mcp_mqtt_messages_collected_total counter") - for _, tool := range sortedKeys(messageCounts) { - fmt.Fprintf(w, "dsx_exchange_mcp_mqtt_messages_collected_total{tool=\"%s\"} %d\n", promLabel(tool), messageCounts[tool]) - } - - fmt.Fprintln(w, "# HELP dsx_exchange_mcp_stopped_reasons_total Tool stop reasons by tool.") - fmt.Fprintln(w, "# TYPE dsx_exchange_mcp_stopped_reasons_total counter") - for _, k := range sortedLabelKeys(stoppedReasons) { - fmt.Fprintf(w, "dsx_exchange_mcp_stopped_reasons_total{tool=\"%s\",reason=\"%s\"} %d\n", promLabel(k.Tool), promLabel(k.Value), stoppedReasons[k]) - } - - fmt.Fprintln(w, "# HELP dsx_exchange_mcp_background_watch_messages_total MQTT messages received by background watches.") - fmt.Fprintln(w, "# TYPE dsx_exchange_mcp_background_watch_messages_total counter") - fmt.Fprintf(w, "dsx_exchange_mcp_background_watch_messages_total %d\n", atomic.LoadUint64(&r.watchMessages)) - - fmt.Fprintln(w, "# HELP dsx_exchange_mcp_background_watch_dropped_messages_total MQTT messages dropped from background watch buffers.") - fmt.Fprintln(w, "# TYPE dsx_exchange_mcp_background_watch_dropped_messages_total counter") - fmt.Fprintf(w, "dsx_exchange_mcp_background_watch_dropped_messages_total %d\n", atomic.LoadUint64(&r.watchDropped)) -} - -func (r *Recorder) activeSessionCountLocked(now time.Time) int { - if r.sessionTTL <= 0 { - r.sessionTTL = 10 * time.Minute - } - cutoff := now.Add(-r.sessionTTL) - for sessionID, lastSeen := range r.sessions { - if lastSeen.Before(cutoff) { - delete(r.sessions, sessionID) - } - } - return len(r.sessions) -} - -func cloneStringMap(in map[string]uint64) map[string]uint64 { - out := make(map[string]uint64, len(in)) - for k, v := range in { - out[k] = v - } - return out -} - -func cloneDurationMap(in map[string]time.Duration) map[string]time.Duration { - out := make(map[string]time.Duration, len(in)) - for k, v := range in { - out[k] = v - } - return out -} - -func cloneLabelMap(in map[labelKey]uint64) map[labelKey]uint64 { - out := make(map[labelKey]uint64, len(in)) - for k, v := range in { - out[k] = v - } - return out -} - -func sortedKeys(m map[string]uint64) []string { - out := make([]string, 0, len(m)) - for k := range m { - out = append(out, k) - } - sort.Strings(out) - return out -} - -func sortedLabelKeys(m map[labelKey]uint64) []labelKey { - out := make([]labelKey, 0, len(m)) - for k := range m { - out = append(out, k) - } - sort.Slice(out, func(i, j int) bool { - if out[i].Tool == out[j].Tool { - return out[i].Value < out[j].Value - } - return out[i].Tool < out[j].Tool - }) - return out -} - -func promLabel(s string) string { - return strings.NewReplacer("\\", "\\\\", "\n", "\\n", "\"", "\\\"").Replace(s) -} - -func formatBucket(v float64) string { - s := fmt.Sprintf("%.3f", v) - s = strings.TrimRight(s, "0") - return strings.TrimRight(s, ".") -} diff --git a/mcp/dsx-exchange-mcp/internal/metrics/metrics_test.go b/mcp/dsx-exchange-mcp/internal/metrics/metrics_test.go deleted file mode 100644 index 3c02e0e..0000000 --- a/mcp/dsx-exchange-mcp/internal/metrics/metrics_test.go +++ /dev/null @@ -1,60 +0,0 @@ -// Copyright 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. -// SPDX-License-Identifier: Apache-2.0 - -package metrics - -import ( - "net/http/httptest" - "strings" - "testing" - "time" -) - -func TestRecorderPrometheusSurfaceIncludesLoadSignals(t *testing.T) { - rec := NewRecorder() - rec.ObserveSession("session-a") - rec.BeginToolCall() - rec.EndToolCall() - rec.BeginWatch() - rec.EndWatch() - rec.BeginMQTTConnection() - rec.EndMQTTConnection() - rec.RecordWatchMessage() - rec.RecordWatchDrop(2) - rec.RecordToolCall("dsx_exchange_subscription_status", "", "", 20*time.Millisecond, 0) - - req := httptest.NewRequest("GET", "/metrics", nil) - resp := httptest.NewRecorder() - rec.Handler().ServeHTTP(resp, req) - body := resp.Body.String() - - for _, want := range []string{ - "dsx_exchange_mcp_active_sessions_recent 1", - "dsx_exchange_mcp_active_background_watches 0", - "dsx_exchange_mcp_active_mqtt_connections 0", - "dsx_exchange_mcp_runtime_goroutines", - "dsx_exchange_mcp_runtime_heap_alloc_bytes", - "dsx_exchange_mcp_tool_calls_total{tool=\"dsx_exchange_subscription_status\"} 1", - "dsx_exchange_mcp_tool_duration_seconds_bucket{tool=\"dsx_exchange_subscription_status\",le=\"0.025\"} 1", - "dsx_exchange_mcp_background_watch_messages_total 1", - "dsx_exchange_mcp_background_watch_dropped_messages_total 2", - } { - if !strings.Contains(body, want) { - t.Fatalf("metrics body missing %q:\n%s", want, body) - } - } -} - -func TestRecorderPrunesStaleSessions(t *testing.T) { - rec := NewRecorder() - rec.sessionTTL = time.Minute - rec.sessions["old"] = time.Now().Add(-2 * time.Minute) - rec.sessions["new"] = time.Now() - - if got := rec.activeSessionCountLocked(time.Now()); got != 1 { - t.Fatalf("active sessions = %d, want 1", got) - } - if _, ok := rec.sessions["old"]; ok { - t.Fatal("stale session was not pruned") - } -} diff --git a/mcp/dsx-exchange-mcp/internal/mqttbus/client.go b/mcp/dsx-exchange-mcp/internal/mqttbus/client.go index f0d009d..4eb48b4 100644 --- a/mcp/dsx-exchange-mcp/internal/mqttbus/client.go +++ b/mcp/dsx-exchange-mcp/internal/mqttbus/client.go @@ -21,6 +21,7 @@ import ( const ( DefaultUsername = "oauthtoken" + DefaultAuthMode = AuthModeJWTPassthrough CodeMissingBearer = "missing_bearer" CodeInvalidTopicFilter = "invalid_topic_filter" @@ -45,14 +46,21 @@ const ( StoppedResultTooLarge = "result_too_large" ) +type AuthMode string + +const ( + AuthModeJWTPassthrough AuthMode = "jwt_passthrough" + AuthModeNoAuth AuthMode = "noauth" +) + type Config struct { BrokerURL string Username string + AuthMode AuthMode TLS TLSConfig ConnectTimeout time.Duration SubscribeTimeout time.Duration MaxResultBytes int - Metrics MetricsRecorder } type TLSConfig struct { @@ -61,11 +69,6 @@ type TLSConfig struct { InsecureSkipVerify bool } -type MetricsRecorder interface { - BeginMQTTConnection() - EndMQTTConnection() -} - // Message is a single MQTT message captured from the bus. type Message struct { Topic string `json:"topic"` @@ -131,10 +134,53 @@ func ErrorCode(err error) string { return CodeInternalError } +func NormalizeAuthMode(mode AuthMode) (AuthMode, error) { + switch mode { + case "": + return DefaultAuthMode, nil + case AuthModeJWTPassthrough, AuthModeNoAuth: + return mode, nil + default: + return "", &BusError{ + Code: CodeInvalidArgument, + Message: fmt.Sprintf("unsupported MQTT auth mode %q; use %q or %q", mode, AuthModeJWTPassthrough, AuthModeNoAuth), + } + } +} + +func (cfg Config) Validate() error { + _, err := NormalizeAuthMode(cfg.AuthMode) + return err +} + +func configureClientAuth(opts *mqtt.ClientOptions, cfg Config, bearer string) error { + mode, err := NormalizeAuthMode(cfg.AuthMode) + if err != nil { + return err + } + switch mode { + case AuthModeJWTPassthrough: + if bearer == "" { + return &BusError{Code: CodeMissingBearer, Message: "missing Authorization bearer for jwt_passthrough MQTT auth mode"} + } + username := cfg.Username + if username == "" { + username = DefaultUsername + } + opts.SetUsername(username) + opts.SetPassword(bearer) + case AuthModeNoAuth: + // Intentionally omit username/password. Event Bus noauth matches only + // when no OAuth2, mTLS, or NKey credentials are present. + } + return nil +} + // Collect opens a one-shot MQTT connection, subscribes to topicFilter, and // returns up to maxMessages messages or until maxDuration elapses. The caller's -// bearer is passed as the MQTT password; DSX Exchange auth-callout owns token -// validation and topic ACL enforcement. +// bearer is passed as the MQTT password in jwt_passthrough mode; noauth mode +// sends no MQTT username/password. DSX Exchange auth-callout owns token +// validation, anonymous profile matching, and topic ACL enforcement. func Collect( ctx context.Context, cfg Config, @@ -146,9 +192,6 @@ func Collect( start := time.Now() out := CollectResult{} - if bearer == "" { - return out, &BusError{Code: CodeMissingBearer, Message: "missing caller bearer; gateway should pass Authorization through"} - } if err := ValidateTopicFilter(topicFilter); err != nil { return out, err } @@ -170,19 +213,16 @@ func Collect( if subscribeTimeout <= 0 { subscribeTimeout = 5 * time.Second } - username := cfg.Username - if username == "" { - username = DefaultUsername - } opts := mqtt.NewClientOptions(). AddBroker(cfg.BrokerURL). SetClientID(fmt.Sprintf("dsx-exchange-mcp-%d", time.Now().UnixNano())). - SetUsername(username). - SetPassword(bearer). SetCleanSession(true). SetAutoReconnect(false). SetConnectTimeout(connectTimeout) + if err := configureClientAuth(opts, cfg, bearer); err != nil { + return out, err + } if usesTLS(cfg.BrokerURL) || cfg.TLS.CAFile != "" || cfg.TLS.ServerName != "" || cfg.TLS.InsecureSkipVerify { tlsCfg, err := buildTLSConfig(cfg.TLS) @@ -238,10 +278,6 @@ func Collect( } else if tok.Error() != nil { return out, classifyConnectError(tok.Error()) } - if cfg.Metrics != nil { - cfg.Metrics.BeginMQTTConnection() - defer cfg.Metrics.EndMQTTConnection() - } defer c.Disconnect(250) if tok := c.Subscribe(topicFilter, 0, nil); !tok.WaitTimeout(subscribeTimeout) { @@ -330,9 +366,6 @@ func Stream( start := time.Now() out := StreamResult{} - if bearer == "" { - return out, &BusError{Code: CodeMissingBearer, Message: "missing caller bearer; gateway should pass Authorization through"} - } if err := ValidateTopicFilter(topicFilter); err != nil { return out, err } @@ -357,10 +390,6 @@ func Stream( if subscribeTimeout <= 0 { subscribeTimeout = 5 * time.Second } - username := cfg.Username - if username == "" { - username = DefaultUsername - } clientID := opts.ClientID if clientID == "" { clientID = fmt.Sprintf("dsx-exchange-mcp-task-%d", time.Now().UnixNano()) @@ -384,8 +413,6 @@ func Stream( optsMQTT := mqtt.NewClientOptions(). AddBroker(cfg.BrokerURL). SetClientID(clientID). - SetUsername(username). - SetPassword(bearer). SetCleanSession(true). SetAutoReconnect(false). SetConnectTimeout(connectTimeout). @@ -396,6 +423,9 @@ func Stream( } finish(StoppedBrokerError) }) + if err := configureClientAuth(optsMQTT, cfg, bearer); err != nil { + return out, err + } if usesTLS(cfg.BrokerURL) || cfg.TLS.CAFile != "" || cfg.TLS.ServerName != "" || cfg.TLS.InsecureSkipVerify { tlsCfg, err := buildTLSConfig(cfg.TLS) @@ -428,10 +458,6 @@ func Stream( } else if tok.Error() != nil { return out, classifyConnectError(tok.Error()) } - if cfg.Metrics != nil { - cfg.Metrics.BeginMQTTConnection() - defer cfg.Metrics.EndMQTTConnection() - } defer c.Disconnect(250) if tok := c.Subscribe(topicFilter, 0, nil); !tok.WaitTimeout(subscribeTimeout) { diff --git a/mcp/dsx-exchange-mcp/internal/mqttbus/client_test.go b/mcp/dsx-exchange-mcp/internal/mqttbus/client_test.go index ceaa39d..bff866f 100644 --- a/mcp/dsx-exchange-mcp/internal/mqttbus/client_test.go +++ b/mcp/dsx-exchange-mcp/internal/mqttbus/client_test.go @@ -7,6 +7,8 @@ import ( "errors" "strings" "testing" + + mqtt "github.com/eclipse/paho.mqtt.golang" ) func TestValidateTopicFilter(t *testing.T) { @@ -47,6 +49,64 @@ func TestErrorCode(t *testing.T) { } } +func TestNormalizeAuthMode(t *testing.T) { + mode, err := NormalizeAuthMode("") + if err != nil { + t.Fatalf("NormalizeAuthMode empty returned error: %v", err) + } + if mode != AuthModeJWTPassthrough { + t.Fatalf("NormalizeAuthMode empty = %q, want %q", mode, AuthModeJWTPassthrough) + } + + mode, err = NormalizeAuthMode(AuthModeNoAuth) + if err != nil { + t.Fatalf("NormalizeAuthMode noauth returned error: %v", err) + } + if mode != AuthModeNoAuth { + t.Fatalf("NormalizeAuthMode noauth = %q, want %q", mode, AuthModeNoAuth) + } + + if _, err = NormalizeAuthMode("dummy"); ErrorCode(err) != CodeInvalidArgument { + t.Fatalf("NormalizeAuthMode invalid code = %q, want %q", ErrorCode(err), CodeInvalidArgument) + } +} + +func TestConfigureClientAuthJWTPassthrough(t *testing.T) { + opts := mqtt.NewClientOptions() + err := configureClientAuth(opts, Config{AuthMode: AuthModeJWTPassthrough}, "token") + if err != nil { + t.Fatalf("configureClientAuth jwt returned error: %v", err) + } + if opts.Username != DefaultUsername { + t.Fatalf("username = %q, want %q", opts.Username, DefaultUsername) + } + if opts.Password != "token" { + t.Fatalf("password = %q, want token", opts.Password) + } +} + +func TestConfigureClientAuthJWTPassthroughRequiresBearer(t *testing.T) { + opts := mqtt.NewClientOptions() + err := configureClientAuth(opts, Config{AuthMode: AuthModeJWTPassthrough}, "") + if ErrorCode(err) != CodeMissingBearer { + t.Fatalf("configureClientAuth missing bearer code = %q, want %q", ErrorCode(err), CodeMissingBearer) + } +} + +func TestConfigureClientAuthNoAuthSendsNoCredentials(t *testing.T) { + opts := mqtt.NewClientOptions() + err := configureClientAuth(opts, Config{AuthMode: AuthModeNoAuth, Username: DefaultUsername}, "dummy") + if err != nil { + t.Fatalf("configureClientAuth noauth returned error: %v", err) + } + if opts.Username != "" { + t.Fatalf("noauth username = %q, want empty", opts.Username) + } + if opts.Password != "" { + t.Fatalf("noauth password = %q, want empty", opts.Password) + } +} + func TestClassifyConnectError(t *testing.T) { tests := []struct { name string diff --git a/mcp/dsx-exchange-mcp/internal/mqttbus/e2e_test.go b/mcp/dsx-exchange-mcp/internal/mqttbus/e2e_test.go index 8e67e5c..497a5b6 100644 --- a/mcp/dsx-exchange-mcp/internal/mqttbus/e2e_test.go +++ b/mcp/dsx-exchange-mcp/internal/mqttbus/e2e_test.go @@ -15,7 +15,8 @@ func TestDeployedBusE2EAllowedTopic(t *testing.T) { t.Skip("set RUN_EXCHANGE_E2E_DEPLOYED_BUS=1 to run deployed-bus e2e") } brokerURL := requiredEnv(t, "DSX_EXCHANGE_MQTT_URL") - bearer := requiredEnv(t, "DSX_EXCHANGE_E2E_BEARER") + authMode := AuthMode(envOrDefault("DSX_EXCHANGE_MQTT_AUTH_MODE", string(DefaultAuthMode))) + bearer := e2eBearer(t, authMode) topic := requiredEnv(t, "DSX_EXCHANGE_E2E_ALLOWED_TOPIC") ctx, cancel := context.WithTimeout(context.Background(), 45*time.Second) @@ -24,6 +25,7 @@ func TestDeployedBusE2EAllowedTopic(t *testing.T) { res, err := Collect(ctx, Config{ BrokerURL: brokerURL, Username: envOrDefault("DSX_EXCHANGE_MQTT_USERNAME", DefaultUsername), + AuthMode: authMode, TLS: TLSConfig{ CAFile: os.Getenv("DSX_EXCHANGE_MQTT_CA_FILE"), ServerName: os.Getenv("DSX_EXCHANGE_MQTT_SERVER_NAME"), @@ -43,7 +45,8 @@ func TestDeployedBusE2EDeniedTopic(t *testing.T) { t.Skip("set RUN_EXCHANGE_E2E_DEPLOYED_BUS=1 to run deployed-bus e2e") } brokerURL := requiredEnv(t, "DSX_EXCHANGE_MQTT_URL") - bearer := requiredEnv(t, "DSX_EXCHANGE_E2E_BEARER") + authMode := AuthMode(envOrDefault("DSX_EXCHANGE_MQTT_AUTH_MODE", string(DefaultAuthMode))) + bearer := e2eBearer(t, authMode) topic := requiredEnv(t, "DSX_EXCHANGE_E2E_DENIED_TOPIC") ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second) @@ -52,6 +55,7 @@ func TestDeployedBusE2EDeniedTopic(t *testing.T) { _, err := Collect(ctx, Config{ BrokerURL: brokerURL, Username: envOrDefault("DSX_EXCHANGE_MQTT_USERNAME", DefaultUsername), + AuthMode: authMode, TLS: TLSConfig{ CAFile: os.Getenv("DSX_EXCHANGE_MQTT_CA_FILE"), ServerName: os.Getenv("DSX_EXCHANGE_MQTT_SERVER_NAME"), @@ -77,6 +81,18 @@ func requiredEnv(t *testing.T, key string) string { return v } +func e2eBearer(t *testing.T, mode AuthMode) string { + t.Helper() + normalized, err := NormalizeAuthMode(mode) + if err != nil { + t.Fatalf("invalid DSX_EXCHANGE_MQTT_AUTH_MODE: %v", err) + } + if normalized == AuthModeNoAuth { + return "" + } + return requiredEnv(t, "DSX_EXCHANGE_E2E_BEARER") +} + func envOrDefault(key, fallback string) string { if v := os.Getenv(key); v != "" { return v diff --git a/mcp/dsx-exchange-mcp/internal/server/e2e_test.go b/mcp/dsx-exchange-mcp/internal/server/e2e_test.go index ee022d4..8a420c9 100644 --- a/mcp/dsx-exchange-mcp/internal/server/e2e_test.go +++ b/mcp/dsx-exchange-mcp/internal/server/e2e_test.go @@ -148,6 +148,7 @@ func TestStagedMCPWatchThroughEndpoint(t *testing.T) { if os.Getenv("RUN_EXCHANGE_MCP_WATCH_E2E") != "1" { t.Skip("set RUN_EXCHANGE_MCP_WATCH_E2E=1 to run staged MCP background watch e2e") } + t.Skip("background watch tools are retired from the public MCP surface; validate bounded dsx_exchange_subscribe instead") endpoint := requiredEnv(t, "DSX_EXCHANGE_MCP_URL") bearer := requiredEnv(t, "DSX_EXCHANGE_E2E_BEARER") @@ -166,9 +167,6 @@ func TestStagedMCPWatchThroughEndpoint(t *testing.T) { if err != nil { t.Fatalf("initialize through MCP endpoint failed: %v", err) } - if sessionID == "" { - t.Fatal("initialize returned empty MCP session ID") - } if err := client.initialized(ctx, sessionID); err != nil { t.Fatalf("notifications/initialized failed: %v", err) } diff --git a/mcp/dsx-exchange-mcp/internal/server/llm_eval_test.go b/mcp/dsx-exchange-mcp/internal/server/llm_eval_test.go index abec78a..0a0e9ab 100644 --- a/mcp/dsx-exchange-mcp/internal/server/llm_eval_test.go +++ b/mcp/dsx-exchange-mcp/internal/server/llm_eval_test.go @@ -125,7 +125,10 @@ func newLLMEvalMCPClient(t *testing.T) (*mcpHTTPClient, func(), string) { }) handler := mcp.NewStreamableHTTPHandler(func(*http.Request) *mcp.Server { return srv - }, nil) + }, &mcp.StreamableHTTPOptions{ + Stateless: true, + JSONResponse: true, + }) mux := http.NewServeMux() mux.Handle("/mcp", auth.Middleware(handler)) @@ -230,13 +233,13 @@ func llmEvalSystemPrompt(allowLiveTools bool) string { Use the available MCP tools before answering. Prefer dsx_exchange_describe_topic, or the gateway-prefixed equivalent, to discover matching AsyncAPI schema channels and related metadata/value topics. For "most recent" or snapshot-style requests, plan a retained metadata read before sampling live values when the schema exposes related metadata and value topics. For live stream requests, plan dsx_exchange_subscribe with bounded max_messages and max_duration_s. -For background watch requests, plan dsx_exchange_start_subscription with a concrete topic_filter plus bounded ttl_seconds, buffer_max_messages, and buffer_max_bytes. +For background watch/listen/monitor requests, plan dsx_exchange_subscribe with a concrete topic_filter plus bounded max_messages and max_duration_s; use max_duration_s=30 unless the deployment documents a higher cap, and invoke it as a native background MCP tool call in Cursor (not a subagent); the host should run the tool while the chat stays usable, like long make targets. ` + liveToolInstruction + ` Final response requirements: - Return one strict JSON object and no markdown. - JSON shape: {"answer":"brief user-facing summary","planned_tool_calls":[{"tool":"dsx_exchange_describe_topic","arguments":{"topic_filter":"..."}},{"tool":"dsx_exchange_read_retained","arguments":{"topic_filter":"...","max_messages":1000}},{"tool":"dsx_exchange_subscribe","arguments":{"topic_filter":"...","max_messages":100,"max_duration_s":30}}],"notes":["optional caveat"]} -- For background-watch requests, include {"tool":"dsx_exchange_start_subscription","arguments":{"topic_filter":"...","ttl_seconds":300,"buffer_max_messages":100,"buffer_max_bytes":262144}} in planned_tool_calls. +- For background-watch requests, include {"tool":"dsx_exchange_subscribe","arguments":{"topic_filter":"...","max_messages":100,"max_duration_s":30}} in planned_tool_calls. - Use unprefixed canonical tool names in planned_tool_calls even if the MCP endpoint exposes gateway-prefixed names. - Do not invent raw data values. This eval is about choosing the right tools and topic filters.` } diff --git a/mcp/dsx-exchange-mcp/internal/server/server.go b/mcp/dsx-exchange-mcp/internal/server/server.go index f3e6d89..08488cf 100644 --- a/mcp/dsx-exchange-mcp/internal/server/server.go +++ b/mcp/dsx-exchange-mcp/internal/server/server.go @@ -10,29 +10,28 @@ import ( "github.com/modelcontextprotocol/go-sdk/mcp" "github.com/NVIDIA/dsx-exchange/mcp/dsx-exchange-mcp/internal/auth" - "github.com/NVIDIA/dsx-exchange/mcp/dsx-exchange-mcp/internal/metrics" "github.com/NVIDIA/dsx-exchange/mcp/dsx-exchange-mcp/internal/mqttbus" ) type Config struct { - MQTT mqttbus.Config - Metrics *metrics.Recorder - DefaultMaxMessages int - MaxMessages int - DefaultDurationS int - MaxDurationS int - MQTTCollectMaxConcurrent int - MQTTWatchStartMaxConcurrent int - WatchDefaultTTLS int - WatchMaxTTLS int - WatchDefaultBufferMessages int - WatchMaxBufferMessages int - WatchDefaultBufferBytes int - WatchMaxBufferBytes int - WatchMaxPerSession int - WatchMaxPerPod int - FindTopicsDefaultLimit int - FindTopicsMaxLimit int + MQTT mqttbus.Config + DefaultMaxMessages int + MaxMessages int + DefaultDurationS int + MaxDurationS int + MQTTCollectMaxConcurrent int + MQTTWatchStartMaxConcurrent int + WatchDefaultTTLS int + WatchMaxTTLS int + WatchDefaultBufferMessages int + WatchMaxBufferMessages int + WatchDefaultBufferBytes int + WatchMaxBufferBytes int + WatchMaxPerSession int + WatchMaxPerPod int + FindTopicsDefaultLimit int + FindTopicsMaxLimit int + EnableExperimentalWatchTools bool collectAdmission *admissionLimiter watchStartAdmission *admissionLimiter @@ -51,17 +50,14 @@ func Build(cfg Config) *mcp.Server { ) normalizeConfig(&cfg) - cfg.MQTT.Metrics = cfg.Metrics cfg.collectAdmission = newAdmissionLimiter(cfg.MQTTCollectMaxConcurrent) - cfg.watchStartAdmission = newAdmissionLimiter(cfg.MQTTWatchStartMaxConcurrent) - srv.AddReceivingMiddleware(callerContextMiddleware(cfg.Metrics)) - watches := newWatchManager(cfg) - registerTools(srv, cfg, watches) + srv.AddReceivingMiddleware(callerContextMiddleware()) + registerTools(srv, cfg) registerResources(srv) return srv } -func callerContextMiddleware(rec *metrics.Recorder) func(mcp.MethodHandler) mcp.MethodHandler { +func callerContextMiddleware() func(mcp.MethodHandler) mcp.MethodHandler { return func(next mcp.MethodHandler) mcp.MethodHandler { return func(ctx context.Context, method string, req mcp.Request) (mcp.Result, error) { sessionID := "" @@ -77,9 +73,6 @@ func callerContextMiddleware(rec *metrics.Recorder) func(mcp.MethodHandler) mcp. } else if sessionID != "" { ctx = auth.WithSessionID(ctx, sessionID) } - if rec != nil { - rec.ObserveSession(sessionID) - } return next(ctx, method, req) } } diff --git a/mcp/dsx-exchange-mcp/internal/server/testdata/tool_call_expectations.json b/mcp/dsx-exchange-mcp/internal/server/testdata/tool_call_expectations.json index 08be6b1..7127070 100644 --- a/mcp/dsx-exchange-mcp/internal/server/testdata/tool_call_expectations.json +++ b/mcp/dsx-exchange-mcp/internal/server/testdata/tool_call_expectations.json @@ -203,7 +203,7 @@ { "id": "bms-rack-power-background-watch", "domain": "bms", - "question": "Start a background watch for rack power telemetry for 5 minutes. Keep up to 100 messages and 262144 bytes in the buffer.", + "question": "Run a background subscription for rack power telemetry using a 30 second sampling window. Collect up to 100 messages.", "expected_tool_calls": [ { "tool": "dsx_exchange_describe_topic", @@ -212,12 +212,11 @@ } }, { - "tool": "dsx_exchange_start_subscription", + "tool": "dsx_exchange_subscribe", "arguments": { "topic_filter": "BMS/v1/PUB/Value/Rack/RackPower/#", - "ttl_seconds": 300, - "buffer_max_messages": 100, - "buffer_max_bytes": 262144 + "max_messages": 100, + "max_duration_s": 30 } } ], diff --git a/mcp/dsx-exchange-mcp/internal/server/tools.go b/mcp/dsx-exchange-mcp/internal/server/tools.go index 10eb15f..439f642 100644 --- a/mcp/dsx-exchange-mcp/internal/server/tools.go +++ b/mcp/dsx-exchange-mcp/internal/server/tools.go @@ -80,16 +80,31 @@ type errorBody struct { RetryAfterSeconds int `json:"retry_after_seconds,omitempty"` } -func registerTools(s *mcp.Server, cfg Config, watches *watchManager) { +func registerTools(s *mcp.Server, cfg Config) { mcp.AddTool(s, &mcp.Tool{ - Name: toolSubscribe, - Description: "Subscribe to a DSX Exchange MQTT topic filter and return live messages " + - "received within bounded limits. Use this for BMS Value channels; BMS live value paths " + - "are under BMS/v1/PUB/Value/{objectType}/{pointType}/{tagPath}. Good discovery filters " + - "are BMS/v1/PUB/Value/# and BMS/v1/PUB/Value/Rack/RackLiquidIsolationStatus/#. " + + Meta: mcp.Meta{ + "x-dsx-exchange-background-preferred": true, + "x-dsx-exchange-bounded-window": true, + }, + Name: toolSubscribe, + Annotations: readOnlyOpenWorldAnnotations("Bounded MQTT subscribe"), + Description: "Background-friendly bounded MQTT subscribe. For watch/listen/monitor prompts, " + + "invoke this tool as a native MCP tool call and let the MCP client run it in the " + + "background (same UX as long-running shell/make targets): do not block the chat on the " + + "full max_duration_s window, do not delegate to a subagent, and do not shell out to " + + "mosquitto_sub. The call may take up to max_duration_s; the host keeps the conversation " + + "usable while it runs. Use this for BMS Value channels under " + + "BMS/v1/PUB/Value/{objectType}/{pointType}/{tagPath}. Good discovery filters are " + + "BMS/v1/PUB/Value/# and BMS/v1/PUB/Value/Rack/RackLiquidIsolationStatus/#. " + + "This is a finite server request: one temporary MQTT client, subscribe, collect until " + + "max_messages or max_duration_s, disconnect, return bounded results. Prefer " + + "max_duration_s=30 and max_messages=100 unless the deployment documents a higher cap. " + + "Repeat the call for ongoing sampling; this does not keep MQTT open after the window ends. " + + "If background execution is unavailable, use short windows and repeat. " + "Consult dsx-exchange://specs/* before inventing topic segments such as Data or Telemetry. " + - "The caller bearer is passed to MQTT as the configured OAuth username/password=; " + - "DSX Exchange auth-callout enforces OAuth2 validity and topic ACLs.", + "In jwt_passthrough mode, the caller bearer is passed to MQTT as the configured OAuth " + + "username/password=; in noauth mode, no MQTT username/password is sent. " + + "DSX Exchange auth-callout enforces token validity, anonymous fallback, and topic ACLs.", }, func(ctx context.Context, _ *mcp.CallToolRequest, in subscribeInput) (*mcp.CallToolResult, collectOutput, error) { maxMessages := in.MaxMessages durationS := in.MaxDurationS @@ -97,53 +112,47 @@ func registerTools(s *mcp.Server, cfg Config, watches *watchManager) { }) mcp.AddTool(s, &mcp.Tool{ - Name: toolReadRetained, + Name: toolReadRetained, + Annotations: readOnlyOpenWorldAnnotations("Read retained MQTT messages"), Description: "Read currently-retained messages on a DSX Exchange MQTT topic filter. " + "Use this for retained BMS Metadata, for example BMS/v1/PUB/Metadata/#, before " + "deriving specific value topics. Do not use this tool for BMS live Value channels; " + "a zero-count retained_idle result means no retained messages matched that filter. " + - "The caller bearer is passed to MQTT as the configured OAuth username/password=.", + "In jwt_passthrough mode, the caller bearer is passed to MQTT as the configured OAuth " + + "username/password=; in noauth mode, no MQTT username/password is sent.", }, func(ctx context.Context, _ *mcp.CallToolRequest, in readRetainedInput) (*mcp.CallToolResult, collectOutput, error) { return collectTool(ctx, cfg, toolReadRetained, in.TopicFilter, in.MaxMessages, cfg.DefaultDurationS, true) }) mcp.AddTool(s, &mcp.Tool{ - Name: toolDescribeTopic, + Name: toolDescribeTopic, + Annotations: readOnlyClosedWorldAnnotations("Describe Exchange schema topic"), Description: "Schema Exploration: describe the AsyncAPI channel matching a DSX Exchange topic filter. " + "Returns the schema channel, payload shape, retained/live behavior, examples, and related metadata/value topics. " + "Use this before subscribing when the caller knows roughly which MQTT path they want but needs schema context.", }, func(ctx context.Context, _ *mcp.CallToolRequest, in describeTopicInput) (*mcp.CallToolResult, describeTopicOutput, error) { - start := time.Now() - if cfg.Metrics != nil { - cfg.Metrics.BeginToolCall() - defer cfg.Metrics.EndToolCall() - } - result, out, err := describeTopicTool(ctx, in) - if cfg.Metrics != nil { - cfg.Metrics.RecordToolCall(toolDescribeTopic, toolResultErrorCode(result, err), "", time.Since(start), out.Count) - } - return result, out, err + return describeTopicTool(ctx, in) }) mcp.AddTool(s, &mcp.Tool{ - Name: toolFindTopics, + Name: toolFindTopics, + Annotations: readOnlyClosedWorldAnnotations("Find Exchange topics"), Description: "Schema Exploration: find AsyncAPI-derived DSX Exchange MQTT topic filters by domain, text query, role, object type, point type, or operation action. " + "Use this before starting a long-running subscription when the caller describes a domain or signal but does not know the raw MQTT topic path. " + "Returned topic filters still require broker ACL approval when used by MQTT tools.", }, func(ctx context.Context, _ *mcp.CallToolRequest, in findTopicsInput) (*mcp.CallToolResult, findTopicsOutput, error) { - start := time.Now() - if cfg.Metrics != nil { - cfg.Metrics.BeginToolCall() - defer cfg.Metrics.EndToolCall() - } - result, out, err := findTopicsTool(ctx, cfg, in) - if cfg.Metrics != nil { - cfg.Metrics.RecordToolCall(toolFindTopics, toolResultErrorCode(result, err), "", time.Since(start), out.Count) - } - return result, out, err + return findTopicsTool(ctx, cfg, in) }) +} + +func readOnlyOpenWorldAnnotations(title string) *mcp.ToolAnnotations { + openWorld := true + return &mcp.ToolAnnotations{Title: title, ReadOnlyHint: true, OpenWorldHint: &openWorld} +} - registerWatchTools(s, cfg, watches) +func readOnlyClosedWorldAnnotations(title string) *mcp.ToolAnnotations { + openWorld := false + return &mcp.ToolAnnotations{Title: title, ReadOnlyHint: true, OpenWorldHint: &openWorld} } func describeTopicTool(ctx context.Context, in describeTopicInput) (*mcp.CallToolResult, describeTopicOutput, error) { @@ -260,18 +269,14 @@ func collectTool( ) (*mcp.CallToolResult, collectOutput, error) { start := time.Now() caller := auth.FromContext(ctx) - if cfg.Metrics != nil { - cfg.Metrics.BeginToolCall() - defer cfg.Metrics.EndToolCall() - } maxMessages, durationS, err := applyLimits(cfg, maxMessages, durationS) if err != nil { - return finishTool(tool, caller, topicFilter, maxMessages, durationS, start, collectOutput{}, err, cfg) + return finishTool(tool, caller, topicFilter, maxMessages, durationS, start, collectOutput{}, err) } if !cfg.collectAdmission.tryAcquire() { - return finishTool(tool, caller, topicFilter, maxMessages, durationS, start, collectOutput{}, admissionLimitedError(), cfg) + return finishTool(tool, caller, topicFilter, maxMessages, durationS, start, collectOutput{}, admissionLimitedError()) } defer cfg.collectAdmission.release() @@ -283,7 +288,7 @@ func collectTool( StoppedReason: result.StoppedReason, Truncated: result.Truncated, } - return finishTool(tool, caller, topicFilter, maxMessages, durationS, start, out, err, cfg) + return finishTool(tool, caller, topicFilter, maxMessages, durationS, start, out, err) } func finishTool( @@ -295,7 +300,6 @@ func finishTool( start time.Time, out collectOutput, err error, - cfg Config, ) (*mcp.CallToolResult, collectOutput, error) { duration := time.Since(start) if out.DurationMS == 0 { @@ -303,9 +307,6 @@ func finishTool( } code := errorCode(err) - if cfg.Metrics != nil { - cfg.Metrics.RecordToolCall(tool, code, out.StoppedReason, duration, out.Count) - } auditToolCall(tool, caller, topicFilter, maxMessages, durationS, out, duration, code) if err != nil { @@ -437,26 +438,6 @@ func errorCode(err error) string { return mqttbus.ErrorCode(err) } -func toolResultErrorCode(result *mcp.CallToolResult, err error) string { - if err != nil { - return errorCode(err) - } - if result == nil || !result.IsError { - return "" - } - for _, item := range result.Content { - text, ok := item.(*mcp.TextContent) - if !ok || text.Text == "" { - continue - } - var body structuredError - if json.Unmarshal([]byte(text.Text), &body) == nil && body.Error.Code != "" { - return body.Error.Code - } - } - return mqttbus.CodeInternalError -} - func publicMessage(err error) string { var busErr *mqttbus.BusError if errors.As(err, &busErr) { diff --git a/mcp/dsx-exchange-mcp/internal/server/tools_test.go b/mcp/dsx-exchange-mcp/internal/server/tools_test.go index d7732fb..bd446b5 100644 --- a/mcp/dsx-exchange-mcp/internal/server/tools_test.go +++ b/mcp/dsx-exchange-mcp/internal/server/tools_test.go @@ -295,11 +295,16 @@ func TestMCPClientListsAndCallsDescribeTopic(t *testing.T) { for _, tool := range tools.Tools { toolNames[tool.Name] = true } - for _, name := range []string{toolDescribeTopic, toolFindTopics, toolReadRetained, toolSubscribe, toolStartSubscription, toolReadSubscription, toolStatusSubscription, toolStopSubscription} { + for _, name := range []string{toolDescribeTopic, toolFindTopics, toolReadRetained, toolSubscribe} { if !toolNames[name] { t.Fatalf("ListTools did not expose %q; saw %#v", name, toolNames) } } + for _, name := range []string{toolStartSubscription, toolReadSubscription, toolStatusSubscription, toolStopSubscription} { + if toolNames[name] { + t.Fatalf("ListTools exposed experimental watch tool %q without opt-in; saw %#v", name, toolNames) + } + } for _, fixture := range fixtures { t.Run(fixture.ID, func(t *testing.T) { @@ -337,6 +342,31 @@ func TestMCPClientListsAndCallsDescribeTopic(t *testing.T) { } } +func TestMCPClientDoesNotExposeExperimentalWatchToolsWhenFlagSet(t *testing.T) { + session, cleanup := newTestMCPClientWithConfig(t, Config{ + DefaultMaxMessages: 100, + MaxMessages: 1000, + DefaultDurationS: 30, + MaxDurationS: 30, + EnableExperimentalWatchTools: true, + }) + defer cleanup() + + tools, err := session.ListTools(context.Background(), nil) + if err != nil { + t.Fatalf("ListTools failed: %v", err) + } + toolNames := map[string]bool{} + for _, tool := range tools.Tools { + toolNames[tool.Name] = true + } + for _, name := range []string{toolStartSubscription, toolReadSubscription, toolStatusSubscription, toolStopSubscription} { + if toolNames[name] { + t.Fatalf("ListTools exposed retired watch tool %q; saw %#v", name, toolNames) + } + } +} + func TestMCPClientDescribeTopicInvalidFilterReturnsToolError(t *testing.T) { session, cleanup := newTestMCPClient(t) defer cleanup() @@ -402,15 +432,23 @@ func loadToolCallFixtures(t *testing.T) []toolCallFixture { func newTestMCPClient(t *testing.T) (*mcp.ClientSession, func()) { t.Helper() - srv := Build(Config{ + return newTestMCPClientWithConfig(t, Config{ DefaultMaxMessages: 100, MaxMessages: 1000, DefaultDurationS: 30, MaxDurationS: 30, }) +} + +func newTestMCPClientWithConfig(t *testing.T, cfg Config) (*mcp.ClientSession, func()) { + t.Helper() + srv := Build(cfg) handler := mcp.NewStreamableHTTPHandler(func(*http.Request) *mcp.Server { return srv - }, nil) + }, &mcp.StreamableHTTPOptions{ + Stateless: true, + JSONResponse: true, + }) mux := http.NewServeMux() mux.Handle("/mcp", auth.Middleware(handler)) diff --git a/mcp/dsx-exchange-mcp/internal/server/watch.go b/mcp/dsx-exchange-mcp/internal/server/watch.go index abb2227..71de5c5 100644 --- a/mcp/dsx-exchange-mcp/internal/server/watch.go +++ b/mcp/dsx-exchange-mcp/internal/server/watch.go @@ -306,9 +306,6 @@ func (m *watchManager) start(req watchStartRequest) (watchStartOutput, error) { m.total++ m.activeTotal++ m.mu.Unlock() - if m.cfg.Metrics != nil { - m.cfg.Metrics.BeginWatch() - } go func() { result, err := m.runner(ctx, m.cfg.MQTT, req.Caller.Bearer, w.topicFilter, mqttbus.StreamOptions{ @@ -590,17 +587,11 @@ func (m *watchManager) recordMessage(sessionID, subscriptionID string, msg mqttb msg: msg, }) w.bufferBytes += size - droppedCount := int64(0) for len(w.buffer) > w.maxMessages || w.bufferBytes > w.maxBytes { dropped := w.buffer[0] w.buffer = w.buffer[1:] w.bufferBytes -= dropped.size w.droppedCount++ - droppedCount++ - } - if m.cfg.Metrics != nil { - m.cfg.Metrics.RecordWatchMessage() - m.cfg.Metrics.RecordWatchDrop(droppedCount) } } @@ -767,11 +758,9 @@ func (m *watchManager) finish(sessionID, subscriptionID string, result mqttbus.S m.mu.Unlock() return } - endWatch := false if w.active { w.active = false m.activeTotal-- - endWatch = true } switch { case w.stopped: @@ -792,9 +781,6 @@ func (m *watchManager) finish(sessionID, subscriptionID string, result mqttbus.S } w.finishedAt = m.now() m.mu.Unlock() - if endWatch && m.cfg.Metrics != nil { - m.cfg.Metrics.EndWatch() - } if w.status == watchStatusExpired || w.status == watchStatusFailed { time.AfterFunc(m.retention, func() { @@ -829,11 +815,9 @@ func (m *watchManager) remove(sessionID, subscriptionID string) { return } w := sessionWatches[subscriptionID] - endWatch := false if w.active { w.active = false m.activeTotal-- - endWatch = true } delete(sessionWatches, subscriptionID) m.total-- @@ -841,9 +825,6 @@ func (m *watchManager) remove(sessionID, subscriptionID string) { delete(m.watches, sessionID) } m.mu.Unlock() - if endWatch && m.cfg.Metrics != nil { - m.cfg.Metrics.EndWatch() - } } func activeSessionCount(sessionWatches map[string]*watch) int { diff --git a/mcp/dsx-exchange-mcp/internal/server/watch_tools.go b/mcp/dsx-exchange-mcp/internal/server/watch_tools.go index d968a33..464357e 100644 --- a/mcp/dsx-exchange-mcp/internal/server/watch_tools.go +++ b/mcp/dsx-exchange-mcp/internal/server/watch_tools.go @@ -81,14 +81,10 @@ func registerWatchTools(s *mcp.Server, cfg Config, watches *watchManager) { func startSubscriptionTool(ctx context.Context, cfg Config, watches *watchManager, in startSubscriptionInput) (*mcp.CallToolResult, watchStartOutput, error) { start := time.Now() caller := auth.FromContext(ctx) - if cfg.Metrics != nil { - cfg.Metrics.BeginToolCall() - defer cfg.Metrics.EndToolCall() - } topicFilter, err := resolveSubscriptionTopic(cfg, in) if err != nil { - recordWatchAudit(toolStartSubscription, caller, "", "", 0, start, err, cfg) + recordWatchAudit(toolStartSubscription, caller, "", "", 0, start, err) return toolErrorFromErr[watchStartOutput](err) } @@ -99,7 +95,7 @@ func startSubscriptionTool(ctx context.Context, cfg Config, watches *watchManage BufferMaxMessages: in.BufferMaxMessages, BufferMaxBytes: in.BufferMaxBytes, }) - recordWatchAudit(toolStartSubscription, caller, out.SubscriptionID, topicFilter, 0, start, err, cfg) + recordWatchAudit(toolStartSubscription, caller, out.SubscriptionID, topicFilter, 0, start, err) if err != nil { return toolErrorFromErr[watchStartOutput](err) } @@ -109,10 +105,6 @@ func startSubscriptionTool(ctx context.Context, cfg Config, watches *watchManage func readSubscriptionTool(ctx context.Context, cfg Config, watches *watchManager, in readSubscriptionInput) (*mcp.CallToolResult, watchReadOutput, error) { start := time.Now() caller := auth.FromContext(ctx) - if cfg.Metrics != nil { - cfg.Metrics.BeginToolCall() - defer cfg.Metrics.EndToolCall() - } out, err := watches.read(watchReadRequest{ Caller: caller, SubscriptionID: in.SubscriptionID, @@ -120,7 +112,7 @@ func readSubscriptionTool(ctx context.Context, cfg Config, watches *watchManager MaxMessages: in.MaxMessages, MaxBytes: in.MaxBytes, }) - recordWatchAudit(toolReadSubscription, caller, in.SubscriptionID, "", out.Count, start, err, cfg) + recordWatchAudit(toolReadSubscription, caller, in.SubscriptionID, "", out.Count, start, err) if err != nil { return toolErrorFromErr[watchReadOutput](err) } @@ -130,15 +122,11 @@ func readSubscriptionTool(ctx context.Context, cfg Config, watches *watchManager func statusSubscriptionTool(ctx context.Context, cfg Config, watches *watchManager, in subscriptionIDInput) (*mcp.CallToolResult, watchStatusOutput, error) { start := time.Now() caller := auth.FromContext(ctx) - if cfg.Metrics != nil { - cfg.Metrics.BeginToolCall() - defer cfg.Metrics.EndToolCall() - } out, err := watches.status(watchStatusRequest{ Caller: caller, SubscriptionID: in.SubscriptionID, }) - recordWatchAudit(toolStatusSubscription, caller, in.SubscriptionID, out.TopicFilter, 0, start, err, cfg) + recordWatchAudit(toolStatusSubscription, caller, in.SubscriptionID, out.TopicFilter, 0, start, err) if err != nil { return toolErrorFromErr[watchStatusOutput](err) } @@ -148,15 +136,11 @@ func statusSubscriptionTool(ctx context.Context, cfg Config, watches *watchManag func stopSubscriptionTool(ctx context.Context, cfg Config, watches *watchManager, in subscriptionIDInput) (*mcp.CallToolResult, watchStopOutput, error) { start := time.Now() caller := auth.FromContext(ctx) - if cfg.Metrics != nil { - cfg.Metrics.BeginToolCall() - defer cfg.Metrics.EndToolCall() - } out, err := watches.stop(watchStopRequest{ Caller: caller, SubscriptionID: in.SubscriptionID, }) - recordWatchAudit(toolStopSubscription, caller, in.SubscriptionID, "", 0, start, err, cfg) + recordWatchAudit(toolStopSubscription, caller, in.SubscriptionID, "", 0, start, err) if err != nil { return toolErrorFromErr[watchStopOutput](err) } @@ -243,12 +227,9 @@ func toolErrorWithRetry[T any](code, message string, retryAfter int) (*mcp.CallT }, zero, nil } -func recordWatchAudit(tool string, caller auth.Caller, subscriptionID, topicFilter string, messages int, start time.Time, err error, cfg Config) { +func recordWatchAudit(tool string, caller auth.Caller, subscriptionID, topicFilter string, messages int, start time.Time, err error) { code := errorCode(err) duration := time.Since(start) - if cfg.Metrics != nil { - cfg.Metrics.RecordToolCall(tool, code, "", duration, messages) - } decision := "allowed" if code != "" { decision = "error" diff --git a/mcp/dsx-exchange-mcp/skaffold.yaml b/mcp/dsx-exchange-mcp/skaffold.yaml new file mode 100644 index 0000000..68bfe8f --- /dev/null +++ b/mcp/dsx-exchange-mcp/skaffold.yaml @@ -0,0 +1,39 @@ +# Copyright 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +apiVersion: skaffold/v4beta14 +kind: Config +metadata: + name: dsx-exchange-mcp +build: + tagPolicy: + inputDigest: {} + local: + push: true + useDockerCLI: true + useBuildkit: true + artifacts: + - image: localhost:5001/dsx-exchange-mcp + context: . + docker: + dockerfile: Dockerfile + cliFlags: + - --provenance=false +deploy: + kubeContext: kind-csc + statusCheck: true + statusCheckDeadlineSeconds: 300 + tolerateFailuresUntilDeadline: true + helm: + releases: + - name: dsx-exchange-mcp + chartPath: deploy/helm/dsx-exchange-mcp + namespace: mcp-backends + createNamespace: true + wait: true + valuesFiles: + - deploy/helm/dsx-exchange-mcp/values.kind.yaml + setValues: + image.repository: localhost:5001/dsx-exchange-mcp + image.tag: local + image.pullPolicy: Always diff --git a/skaffold.yaml b/skaffold.yaml index 01e00b4..f6e30b9 100644 --- a/skaffold.yaml +++ b/skaffold.yaml @@ -16,3 +16,6 @@ requires: - path: local/nats/skaffold.yaml configs: - nats + - path: mcp/dsx-exchange-mcp/skaffold.yaml + configs: + - dsx-exchange-mcp From ce61681a98b969f11bfd12529d77feb87d3885ce Mon Sep 17 00:00:00 2001 From: Daniyal Rana Date: Fri, 12 Jun 2026 12:16:13 -0700 Subject: [PATCH 04/27] docs(mcp): align gateway service target port Signed-off-by: Daniyal Rana --- mcp/dsx-exchange-mcp/Architecture.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/mcp/dsx-exchange-mcp/Architecture.md b/mcp/dsx-exchange-mcp/Architecture.md index 6e73ccb..58bc8bc 100644 --- a/mcp/dsx-exchange-mcp/Architecture.md +++ b/mcp/dsx-exchange-mcp/Architecture.md @@ -373,7 +373,7 @@ This repo's Helm Service advertises the MCP port: ports: - name: {{ .Values.service.portName }} port: {{ .Values.service.port }} - targetPort: http + targetPort: mcp protocol: TCP appProtocol: agentgateway.dev/mcp ``` From e90c280f4703c6cba1fc05f536f1538be229e612 Mon Sep 17 00:00:00 2001 From: Daniyal Rana Date: Mon, 22 Jun 2026 01:25:00 -0500 Subject: [PATCH 05/27] refactor(mcp): rename auth context files Signed-off-by: Daniyal Rana --- mcp/dsx-exchange-mcp/internal/auth/{context.go => caller.go} | 0 .../internal/auth/{context_test.go => caller_test.go} | 0 2 files changed, 0 insertions(+), 0 deletions(-) rename mcp/dsx-exchange-mcp/internal/auth/{context.go => caller.go} (100%) rename mcp/dsx-exchange-mcp/internal/auth/{context_test.go => caller_test.go} (100%) diff --git a/mcp/dsx-exchange-mcp/internal/auth/context.go b/mcp/dsx-exchange-mcp/internal/auth/caller.go similarity index 100% rename from mcp/dsx-exchange-mcp/internal/auth/context.go rename to mcp/dsx-exchange-mcp/internal/auth/caller.go diff --git a/mcp/dsx-exchange-mcp/internal/auth/context_test.go b/mcp/dsx-exchange-mcp/internal/auth/caller_test.go similarity index 100% rename from mcp/dsx-exchange-mcp/internal/auth/context_test.go rename to mcp/dsx-exchange-mcp/internal/auth/caller_test.go From c58e08e2eac1e16d5a3950be56b72d12dae4f65e Mon Sep 17 00:00:00 2001 From: Daniyal Rana Date: Mon, 22 Jun 2026 01:27:25 -0500 Subject: [PATCH 06/27] refactor(mcp): remove server-side watch lifecycle Signed-off-by: Daniyal Rana --- .../cmd/dsx-exchange-mcp/main.go | 20 +- .../internal/mqttbus/client.go | 188 +--- .../internal/server/e2e_test.go | 178 ---- .../internal/server/llm_eval_test.go | 6 +- .../internal/server/server.go | 29 +- mcp/dsx-exchange-mcp/internal/server/tools.go | 52 +- .../internal/server/tools_test.go | 86 +- mcp/dsx-exchange-mcp/internal/server/watch.go | 890 ------------------ .../internal/server/watch_test.go | 315 ------- .../internal/server/watch_tools.go | 253 ----- 10 files changed, 60 insertions(+), 1957 deletions(-) delete mode 100644 mcp/dsx-exchange-mcp/internal/server/watch.go delete mode 100644 mcp/dsx-exchange-mcp/internal/server/watch_test.go delete mode 100644 mcp/dsx-exchange-mcp/internal/server/watch_tools.go diff --git a/mcp/dsx-exchange-mcp/cmd/dsx-exchange-mcp/main.go b/mcp/dsx-exchange-mcp/cmd/dsx-exchange-mcp/main.go index 488dca0..7d70c91 100644 --- a/mcp/dsx-exchange-mcp/cmd/dsx-exchange-mcp/main.go +++ b/mcp/dsx-exchange-mcp/cmd/dsx-exchange-mcp/main.go @@ -38,20 +38,15 @@ func main() { SubscribeTimeout: time.Duration(envInt("MQTT_SUBSCRIBE_TIMEOUT_S", 5)) * time.Second, MaxResultBytes: envInt("MQTT_MAX_RESULT_BYTES", 1048576), }, - DefaultMaxMessages: envInt("MCP_DEFAULT_MAX_MESSAGES", 100), - MaxMessages: envInt("MCP_MAX_MESSAGES", 1000), - DefaultDurationS: envInt("MCP_DEFAULT_MAX_DURATION_S", 30), - MaxDurationS: envInt("MCP_MAX_DURATION_S", 30), - MQTTCollectMaxConcurrent: envInt("MCP_MQTT_COLLECT_MAX_CONCURRENT_PER_POD", 100), - FindTopicsDefaultLimit: envInt("MCP_FIND_TOPICS_DEFAULT_LIMIT", 20), - FindTopicsMaxLimit: envInt("MCP_FIND_TOPICS_MAX_LIMIT", 100), - EnableExperimentalWatchTools: envBool("MCP_ENABLE_EXPERIMENTAL_WATCH_TOOLS", false), + DefaultMaxMessages: envInt("MCP_DEFAULT_MAX_MESSAGES", 100), + MaxMessages: envInt("MCP_MAX_MESSAGES", 1000), + DefaultDurationS: envInt("MCP_DEFAULT_MAX_DURATION_S", 30), + MaxDurationS: envInt("MCP_MAX_DURATION_S", 30), + MQTTCollectMaxConcurrent: envInt("MCP_MQTT_COLLECT_MAX_CONCURRENT_PER_POD", 100), + FindTopicsDefaultLimit: envInt("MCP_FIND_TOPICS_DEFAULT_LIMIT", 20), + FindTopicsMaxLimit: envInt("MCP_FIND_TOPICS_MAX_LIMIT", 100), } - if cfg.EnableExperimentalWatchTools { - logger.Error("experimental watch tools are no longer exposed; use bounded dsx_exchange_subscribe calls") - os.Exit(2) - } if err := cfg.MQTT.Validate(); err != nil { logger.Error("invalid MQTT configuration", "err", err) os.Exit(2) @@ -82,7 +77,6 @@ func main() { "max_messages", cfg.MaxMessages, "max_duration_s", cfg.MaxDurationS, "mqtt_collect_max_concurrent_per_pod", cfg.MQTTCollectMaxConcurrent, - "experimental_watch_tools_enabled", cfg.EnableExperimentalWatchTools, ) if err := http.ListenAndServe(addr, mux); err != nil { logger.Error("server exited", "err", err) diff --git a/mcp/dsx-exchange-mcp/internal/mqttbus/client.go b/mcp/dsx-exchange-mcp/internal/mqttbus/client.go index 4eb48b4..b376986 100644 --- a/mcp/dsx-exchange-mcp/internal/mqttbus/client.go +++ b/mcp/dsx-exchange-mcp/internal/mqttbus/client.go @@ -42,7 +42,6 @@ const ( StoppedMaxDuration = "max_duration" StoppedRetainedIdle = "retained_idle" StoppedCallerCancel = "caller_cancelled" - StoppedBrokerError = "broker_error" StoppedResultTooLarge = "result_too_large" ) @@ -86,19 +85,6 @@ type CollectResult struct { Duration time.Duration `json:"-"` } -type StreamOptions struct { - ClientID string - MaxMessages int - MaxDuration time.Duration - OnSubscribed func() -} - -type StreamResult struct { - Count int - StoppedReason string - Duration time.Duration -} - type BusError struct { Code string Message string @@ -179,7 +165,7 @@ func configureClientAuth(opts *mqtt.ClientOptions, cfg Config, bearer string) er // Collect opens a one-shot MQTT connection, subscribes to topicFilter, and // returns up to maxMessages messages or until maxDuration elapses. The caller's // bearer is passed as the MQTT password in jwt_passthrough mode; noauth mode -// sends no MQTT username/password. DSX Exchange auth-callout owns token +// sends no MQTT username/password. Event Bus auth-callout owns token // validation, anonymous profile matching, and topic ACL enforcement. func Collect( ctx context.Context, @@ -247,6 +233,8 @@ func Collect( done <- reason } } + // Mutex ensures thread-safe adding of incoming MQTT messages and checks message size limits + // Ensures messages are processed and appended in order, preventing overlap from concurrent handler calls. opts.SetDefaultPublishHandler(func(_ mqtt.Client, m mqtt.Message) { mu.Lock() @@ -304,6 +292,7 @@ func Collect( } select { case <-ctx.Done(): + // MCP client disconnects mu.Lock() finish(StoppedCallerCancel) out.Messages = append([]Message(nil), messages...) @@ -313,6 +302,7 @@ func Collect( mu.Unlock() return out, ctx.Err() case <-deadline.C: + // Exceeded max duration mu.Lock() finish(StoppedMaxDuration) out.Messages = append([]Message(nil), messages...) @@ -322,16 +312,21 @@ func Collect( mu.Unlock() return out, nil case <-messageSeen: + // Only relevant in retained-only mode: + // Checks if no new messages have been received in the last 750ms. + // Resets idle timer to 750ms. if idle != nil { if !idle.Stop() { select { - case <-idle.C: + case <-idle.C: // drains stale timer channel to prevent premature expiration. default: } } - idle.Reset(750 * time.Millisecond) + idle.Reset(750 * time.Millisecond) // reset idle timer } case <-idleC: + // Only relevant in retained-only mode: + // Finish reading retained messages after 750ms of inactivity. mu.Lock() finish(StoppedRetainedIdle) out.Messages = append([]Message(nil), messages...) @@ -341,6 +336,7 @@ func Collect( mu.Unlock() return out, nil case reason := <-done: + // Exceeded Max Number / Size of Messages mu.Lock() out.Messages = append([]Message(nil), messages...) out.StoppedReason = reason @@ -352,162 +348,6 @@ func Collect( } } -// Stream opens an MQTT connection, subscribes to topicFilter, and calls -// onMessage for each message until a bound is reached or the context is -// cancelled. It is intended for async task workers that persist messages -// outside this package. -func Stream( - ctx context.Context, - cfg Config, - bearer, topicFilter string, - opts StreamOptions, - onMessage func(Message) error, -) (StreamResult, error) { - start := time.Now() - out := StreamResult{} - - if err := ValidateTopicFilter(topicFilter); err != nil { - return out, err - } - if opts.MaxMessages <= 0 { - return out, &BusError{Code: CodeInvalidArgument, Message: "max_messages must be greater than zero"} - } - if opts.MaxDuration <= 0 { - return out, &BusError{Code: CodeInvalidArgument, Message: "max_duration_s must be greater than zero"} - } - if cfg.BrokerURL == "" { - return out, &BusError{Code: CodeInvalidArgument, Message: "broker URL is required"} - } - if onMessage == nil { - return out, &BusError{Code: CodeInvalidArgument, Message: "onMessage callback is required"} - } - - connectTimeout := cfg.ConnectTimeout - if connectTimeout <= 0 { - connectTimeout = 5 * time.Second - } - subscribeTimeout := cfg.SubscribeTimeout - if subscribeTimeout <= 0 { - subscribeTimeout = 5 * time.Second - } - clientID := opts.ClientID - if clientID == "" { - clientID = fmt.Sprintf("dsx-exchange-mcp-task-%d", time.Now().UnixNano()) - } - - done := make(chan string, 1) - errs := make(chan error, 1) - finish := func(reason string) { - select { - case done <- reason: - default: - } - } - fail := func(err error) { - select { - case errs <- err: - default: - } - } - - optsMQTT := mqtt.NewClientOptions(). - AddBroker(cfg.BrokerURL). - SetClientID(clientID). - SetCleanSession(true). - SetAutoReconnect(false). - SetConnectTimeout(connectTimeout). - SetConnectionLostHandler(func(_ mqtt.Client, err error) { - if err != nil { - fail(&BusError{Code: CodeBusUnavailable, Message: "mqtt connection lost", Err: err}) - return - } - finish(StoppedBrokerError) - }) - if err := configureClientAuth(optsMQTT, cfg, bearer); err != nil { - return out, err - } - - if usesTLS(cfg.BrokerURL) || cfg.TLS.CAFile != "" || cfg.TLS.ServerName != "" || cfg.TLS.InsecureSkipVerify { - tlsCfg, err := buildTLSConfig(cfg.TLS) - if err != nil { - return out, err - } - optsMQTT.SetTLSConfig(tlsCfg) - } - - var mu sync.Mutex - count := 0 - optsMQTT.SetDefaultPublishHandler(func(_ mqtt.Client, m mqtt.Message) { - msg := convertMessage(m) - if err := onMessage(msg); err != nil { - fail(&BusError{Code: CodeInternalError, Message: "persist MQTT stream message", Err: err}) - return - } - mu.Lock() - count++ - reached := count >= opts.MaxMessages - mu.Unlock() - if reached { - finish(StoppedMaxMessages) - } - }) - - c := mqtt.NewClient(optsMQTT) - if tok := c.Connect(); !tok.WaitTimeout(connectTimeout) { - return out, &BusError{Code: CodeBusUnavailable, Message: "mqtt connect timeout"} - } else if tok.Error() != nil { - return out, classifyConnectError(tok.Error()) - } - defer c.Disconnect(250) - - if tok := c.Subscribe(topicFilter, 0, nil); !tok.WaitTimeout(subscribeTimeout) { - return out, &BusError{Code: CodeBusUnavailable, Message: fmt.Sprintf("mqtt subscribe %q timeout", topicFilter)} - } else if tok.Error() != nil { - return out, classifySubscribeError(topicFilter, tok.Error()) - } else if err := classifySubscribeResult(topicFilter, tok); err != nil { - return out, err - } - if opts.OnSubscribed != nil { - opts.OnSubscribed() - } - - deadline := time.NewTimer(opts.MaxDuration) - defer deadline.Stop() - - for { - select { - case <-ctx.Done(): - mu.Lock() - out.Count = count - mu.Unlock() - out.StoppedReason = StoppedCallerCancel - out.Duration = time.Since(start) - return out, ctx.Err() - case <-deadline.C: - mu.Lock() - out.Count = count - mu.Unlock() - out.StoppedReason = StoppedMaxDuration - out.Duration = time.Since(start) - return out, nil - case err := <-errs: - mu.Lock() - out.Count = count - mu.Unlock() - out.StoppedReason = StoppedBrokerError - out.Duration = time.Since(start) - return out, err - case reason := <-done: - mu.Lock() - out.Count = count - mu.Unlock() - out.StoppedReason = reason - out.Duration = time.Since(start) - return out, nil - } - } -} - func ValidateTopicFilter(filter string) error { if filter == "" { return &BusError{Code: CodeInvalidTopicFilter, Message: "topic_filter is required"} @@ -559,6 +399,8 @@ func usesTLS(url string) bool { return strings.HasPrefix(lower, "tls://") || strings.HasPrefix(lower, "ssl://") || strings.HasPrefix(lower, "mqtts://") } +// convertMessage converts an mqtt.Message to a Message struct, storing the payload as a UTF-8 string if valid, +// or base64-encoding it otherwise. Sets encoding and message metadata accordingly. func convertMessage(m mqtt.Message) Message { payload := m.Payload() encoding := "utf8" diff --git a/mcp/dsx-exchange-mcp/internal/server/e2e_test.go b/mcp/dsx-exchange-mcp/internal/server/e2e_test.go index 8a420c9..10138a1 100644 --- a/mcp/dsx-exchange-mcp/internal/server/e2e_test.go +++ b/mcp/dsx-exchange-mcp/internal/server/e2e_test.go @@ -144,121 +144,6 @@ func TestStagedMCPSchemaDescribeThroughEndpoint(t *testing.T) { } } -func TestStagedMCPWatchThroughEndpoint(t *testing.T) { - if os.Getenv("RUN_EXCHANGE_MCP_WATCH_E2E") != "1" { - t.Skip("set RUN_EXCHANGE_MCP_WATCH_E2E=1 to run staged MCP background watch e2e") - } - t.Skip("background watch tools are retired from the public MCP surface; validate bounded dsx_exchange_subscribe instead") - - endpoint := requiredEnv(t, "DSX_EXCHANGE_MCP_URL") - bearer := requiredEnv(t, "DSX_EXCHANGE_E2E_BEARER") - allowedTopic := requiredEnv(t, "DSX_EXCHANGE_E2E_ALLOWED_TOPIC") - - ctx, cancel := context.WithTimeout(context.Background(), 90*time.Second) - defer cancel() - - client := &mcpHTTPClient{ - endpoint: endpoint, - bearer: bearer, - httpc: &http.Client{Timeout: 30 * time.Second}, - } - - sessionID, err := client.initialize(ctx) - if err != nil { - t.Fatalf("initialize through MCP endpoint failed: %v", err) - } - if err := client.initialized(ctx, sessionID); err != nil { - t.Fatalf("notifications/initialized failed: %v", err) - } - - tools, err := client.listTools(ctx, sessionID) - if err != nil { - t.Fatalf("tools/list failed: %v", err) - } - startTool := chooseStartSubscriptionToolName(tools, os.Getenv("DSX_EXCHANGE_E2E_START_TOOL_NAME")) - readTool := chooseReadSubscriptionToolName(tools, os.Getenv("DSX_EXCHANGE_E2E_READ_TOOL_NAME")) - statusTool := chooseStatusSubscriptionToolName(tools, os.Getenv("DSX_EXCHANGE_E2E_STATUS_TOOL_NAME")) - stopTool := chooseStopSubscriptionToolName(tools, os.Getenv("DSX_EXCHANGE_E2E_STOP_TOOL_NAME")) - if startTool == "" || readTool == "" || statusTool == "" || stopTool == "" { - t.Fatalf("tools/list missing watch tool(s): start=%q read=%q status=%q stop=%q tools=%v", startTool, readTool, statusTool, stopTool, tools) - } - - started, err := client.callTool(ctx, sessionID, startTool, map[string]any{ - "topic_filter": allowedTopic, - "ttl_seconds": 30, - "buffer_max_messages": 10, - "buffer_max_bytes": 32768, - }) - if err != nil { - t.Fatalf("tools/call(%q start watch) failed: %v", startTool, err) - } - if started.IsError { - t.Fatalf("tools/call(%q start watch) returned MCP tool error: %s", startTool, started.textSummary()) - } - var startOut watchStartOutput - if err := json.Unmarshal([]byte(started.lastText()), &startOut); err != nil { - t.Fatalf("decode watch start response: %v; content=%s", err, started.textSummary()) - } - if startOut.SubscriptionID == "" { - t.Fatalf("watch start response missing subscription_id: %#v", startOut) - } - t.Logf("started watch %s with status %s on %s", startOut.SubscriptionID, startOut.Status, startOut.TopicFilter) - - status, err := client.callTool(ctx, sessionID, statusTool, map[string]any{ - "subscription_id": startOut.SubscriptionID, - }) - if err != nil { - t.Fatalf("tools/call(%q status watch) failed: %v", statusTool, err) - } - if status.IsError { - t.Fatalf("tools/call(%q status watch) returned MCP tool error: %s", statusTool, status.textSummary()) - } - var statusOut watchStatusOutput - if err := json.Unmarshal([]byte(status.lastText()), &statusOut); err != nil { - t.Fatalf("decode watch status response: %v; content=%s", err, status.textSummary()) - } - if statusOut.SubscriptionID != startOut.SubscriptionID { - t.Fatalf("watch status subscription_id = %q, want %q", statusOut.SubscriptionID, startOut.SubscriptionID) - } - - read, err := client.callTool(ctx, sessionID, readTool, map[string]any{ - "subscription_id": startOut.SubscriptionID, - "cursor": startOut.Cursor, - "max_messages": 10, - "max_bytes": 32768, - }) - if err != nil { - t.Fatalf("tools/call(%q read watch) failed: %v", readTool, err) - } - if read.IsError { - t.Fatalf("tools/call(%q read watch) returned MCP tool error: %s", readTool, read.textSummary()) - } - var readOut watchReadOutput - if err := json.Unmarshal([]byte(read.lastText()), &readOut); err != nil { - t.Fatalf("decode watch read response: %v; content=%s", err, read.textSummary()) - } - if readOut.SubscriptionID != startOut.SubscriptionID { - t.Fatalf("watch read subscription_id = %q, want %q", readOut.SubscriptionID, startOut.SubscriptionID) - } - - stopped, err := client.callTool(ctx, sessionID, stopTool, map[string]any{ - "subscription_id": startOut.SubscriptionID, - }) - if err != nil { - t.Fatalf("tools/call(%q stop watch) failed: %v", stopTool, err) - } - if stopped.IsError { - t.Fatalf("tools/call(%q stop watch) returned MCP tool error: %s", stopTool, stopped.textSummary()) - } - var stopOut watchStopOutput - if err := json.Unmarshal([]byte(stopped.lastText()), &stopOut); err != nil { - t.Fatalf("decode watch stop response: %v; content=%s", err, stopped.textSummary()) - } - if stopOut.SubscriptionID != startOut.SubscriptionID || stopOut.Status != watchStatusStopped { - t.Fatalf("watch stop response = %#v, want stopped %q", stopOut, startOut.SubscriptionID) - } -} - func TestStagedMCPQualityFixturesThroughEndpoint(t *testing.T) { if os.Getenv("RUN_EXCHANGE_MCP_QUALITY_E2E") != "1" { t.Skip("set RUN_EXCHANGE_MCP_QUALITY_E2E=1 to run staged MCP quality fixture replay") @@ -345,31 +230,6 @@ func TestStagedMCPQualityFixturesThroughEndpoint(t *testing.T) { t.Fatalf("tools/call(%q fixture call %d) returned MCP tool error: %s", toolName, i, res.textSummary()) } validateCollectResponseShape(t, res, fixture.ID, i) - case toolStartSubscription: - if !executeLiveTools { - t.Logf("skipping live fixture call %d %s; set DSX_EXCHANGE_MCP_QUALITY_EXECUTE_LIVE_TOOLS=1 to execute", i, call.Tool) - continue - } - args := qualityLiveArguments(call, liveMaxDurationS) - res, err := client.callTool(ctx, sessionID, toolName, args) - if err != nil { - t.Fatalf("tools/call(%q fixture call %d) failed: %v", toolName, i, err) - } - if res.IsError { - t.Fatalf("tools/call(%q fixture call %d) returned MCP tool error: %s", toolName, i, res.textSummary()) - } - startOut := validateWatchStartResponseShape(t, res, fixture.ID, i) - stopTool := chooseStopSubscriptionToolName(tools, "") - if stopTool == "" { - t.Fatalf("tools/list missing %s needed to clean up fixture watch", toolStopSubscription) - } - stopped, err := client.callTool(ctx, sessionID, stopTool, map[string]any{"subscription_id": startOut.SubscriptionID}) - if err != nil { - t.Fatalf("cleanup tools/call(%q) failed for fixture watch %q: %v", stopTool, startOut.SubscriptionID, err) - } - if stopped.IsError { - t.Fatalf("cleanup tools/call(%q) returned MCP tool error for fixture watch %q: %s", stopTool, startOut.SubscriptionID, stopped.textSummary()) - } default: t.Fatalf("fixture call %d has unsupported tool %q", i, call.Tool) } @@ -581,22 +441,6 @@ func chooseDescribeTopicToolName(names []string, explicit string) string { return chooseToolName(names, toolDescribeTopic, explicit) } -func chooseStartSubscriptionToolName(names []string, explicit string) string { - return chooseToolName(names, toolStartSubscription, explicit) -} - -func chooseReadSubscriptionToolName(names []string, explicit string) string { - return chooseToolName(names, toolReadSubscription, explicit) -} - -func chooseStatusSubscriptionToolName(names []string, explicit string) string { - return chooseToolName(names, toolStatusSubscription, explicit) -} - -func chooseStopSubscriptionToolName(names []string, explicit string) string { - return chooseToolName(names, toolStopSubscription, explicit) -} - func chooseToolName(names []string, baseName string, explicit string) string { if explicit != "" { for _, name := range names { @@ -653,10 +497,6 @@ func qualityLiveArguments(call fixtureToolCall, maxDurationS int) map[string]any args["max_messages"] = minPositiveIntArg(args, "max_messages", 10) case toolReadRetained: args["max_messages"] = minPositiveIntArg(args, "max_messages", 10) - case toolStartSubscription: - args["ttl_seconds"] = minPositiveIntArg(args, "ttl_seconds", 30) - args["buffer_max_messages"] = minPositiveIntArg(args, "buffer_max_messages", 10) - args["buffer_max_bytes"] = minPositiveIntArg(args, "buffer_max_bytes", 32768) } return args } @@ -714,24 +554,6 @@ func validateCollectResponseShape(t *testing.T, res toolCallResult, fixtureID st } } -func validateWatchStartResponseShape(t *testing.T, res toolCallResult, fixtureID string, callIndex int) watchStartOutput { - t.Helper() - var out watchStartOutput - if err := json.Unmarshal([]byte(res.lastText()), &out); err != nil { - t.Fatalf("%s call %d decode watch start response: %v; content=%s", fixtureID, callIndex, err, res.textSummary()) - } - if out.SubscriptionID == "" { - t.Fatalf("%s call %d watch start response missing subscription_id: %#v", fixtureID, callIndex, out) - } - if out.TopicFilter == "" { - t.Fatalf("%s call %d watch start response missing topic_filter: %#v", fixtureID, callIndex, out) - } - if out.Status == "" { - t.Fatalf("%s call %d watch start response missing status: %#v", fixtureID, callIndex, out) - } - return out -} - func assertOptionalDeniedSubscribe(t *testing.T, ctx context.Context, client *mcpHTTPClient, sessionID string, tools []string) { t.Helper() deniedTopic := os.Getenv("DSX_EXCHANGE_E2E_DENIED_TOPIC") diff --git a/mcp/dsx-exchange-mcp/internal/server/llm_eval_test.go b/mcp/dsx-exchange-mcp/internal/server/llm_eval_test.go index 0a0e9ab..61d8ff0 100644 --- a/mcp/dsx-exchange-mcp/internal/server/llm_eval_test.go +++ b/mcp/dsx-exchange-mcp/internal/server/llm_eval_test.go @@ -233,7 +233,7 @@ func llmEvalSystemPrompt(allowLiveTools bool) string { Use the available MCP tools before answering. Prefer dsx_exchange_describe_topic, or the gateway-prefixed equivalent, to discover matching AsyncAPI schema channels and related metadata/value topics. For "most recent" or snapshot-style requests, plan a retained metadata read before sampling live values when the schema exposes related metadata and value topics. For live stream requests, plan dsx_exchange_subscribe with bounded max_messages and max_duration_s. -For background watch/listen/monitor requests, plan dsx_exchange_subscribe with a concrete topic_filter plus bounded max_messages and max_duration_s; use max_duration_s=30 unless the deployment documents a higher cap, and invoke it as a native background MCP tool call in Cursor (not a subagent); the host should run the tool while the chat stays usable, like long make targets. +For get/fetch/read/sample/watch/listen/monitor/subscribe requests over live values, plan dsx_exchange_subscribe with a concrete topic_filter plus bounded max_messages and max_duration_s; use max_duration_s=30 unless the deployment documents a higher cap. When the MCP client supports a background agent, subagent, task, or equivalent mechanism, use it by default for every dsx_exchange_subscribe call so the active chat remains usable during the sampling window; direct tools/call may block the active chat until the subscribe returns. ` + liveToolInstruction + ` Final response requirements: @@ -457,10 +457,6 @@ func normalizeToolName(name string) string { toolFindTopics, toolReadRetained, toolSubscribe, - toolStartSubscription, - toolReadSubscription, - toolStatusSubscription, - toolStopSubscription, } { if name == canonical || strings.HasSuffix(name, "_"+canonical) { return canonical diff --git a/mcp/dsx-exchange-mcp/internal/server/server.go b/mcp/dsx-exchange-mcp/internal/server/server.go index 08488cf..60d4820 100644 --- a/mcp/dsx-exchange-mcp/internal/server/server.go +++ b/mcp/dsx-exchange-mcp/internal/server/server.go @@ -14,27 +14,16 @@ import ( ) type Config struct { - MQTT mqttbus.Config - DefaultMaxMessages int - MaxMessages int - DefaultDurationS int - MaxDurationS int - MQTTCollectMaxConcurrent int - MQTTWatchStartMaxConcurrent int - WatchDefaultTTLS int - WatchMaxTTLS int - WatchDefaultBufferMessages int - WatchMaxBufferMessages int - WatchDefaultBufferBytes int - WatchMaxBufferBytes int - WatchMaxPerSession int - WatchMaxPerPod int - FindTopicsDefaultLimit int - FindTopicsMaxLimit int - EnableExperimentalWatchTools bool + MQTT mqttbus.Config + DefaultMaxMessages int + MaxMessages int + DefaultDurationS int + MaxDurationS int + MQTTCollectMaxConcurrent int + FindTopicsDefaultLimit int + FindTopicsMaxLimit int - collectAdmission *admissionLimiter - watchStartAdmission *admissionLimiter + collectAdmission *admissionLimiter } // Build constructs the singleton MCP server. The same *mcp.Server is returned diff --git a/mcp/dsx-exchange-mcp/internal/server/tools.go b/mcp/dsx-exchange-mcp/internal/server/tools.go index 439f642..95f3471 100644 --- a/mcp/dsx-exchange-mcp/internal/server/tools.go +++ b/mcp/dsx-exchange-mcp/internal/server/tools.go @@ -88,12 +88,16 @@ func registerTools(s *mcp.Server, cfg Config) { }, Name: toolSubscribe, Annotations: readOnlyOpenWorldAnnotations("Bounded MQTT subscribe"), - Description: "Background-friendly bounded MQTT subscribe. For watch/listen/monitor prompts, " + - "invoke this tool as a native MCP tool call and let the MCP client run it in the " + - "background (same UX as long-running shell/make targets): do not block the chat on the " + - "full max_duration_s window, do not delegate to a subagent, and do not shell out to " + - "mosquitto_sub. The call may take up to max_duration_s; the host keeps the conversation " + - "usable while it runs. Use this for BMS Value channels under " + + Description: "Background-friendly bounded MQTT subscribe. Any MCP client planning to call " + + "this tool should default to its nonblocking background-agent/subagent/task " + + "mechanism when available, including when the user asks to get, fetch, read, " + + "or sample live values and the agent infers that a subscription is needed. " + + "The user does not need to explicitly ask for a background agent. Some clients " + + "block active chat while a direct long-running tools/call is in flight. Use " + + "direct foreground calls only for explicitly short/inline probes. Do not shell " + + "out to mosquitto_sub. " + + "The call may take up to max_duration_s. " + + "Use this for BMS Value channels under " + "BMS/v1/PUB/Value/{objectType}/{pointType}/{tagPath}. Good discovery filters are " + "BMS/v1/PUB/Value/# and BMS/v1/PUB/Value/Rack/RackLiquidIsolationStatus/#. " + "This is a finite server request: one temporary MQTT client, subscribe, collect until " + @@ -343,42 +347,6 @@ func normalizeConfig(cfg *Config) { if cfg.MQTTCollectMaxConcurrent <= 0 { cfg.MQTTCollectMaxConcurrent = 100 } - if cfg.MQTTWatchStartMaxConcurrent <= 0 { - cfg.MQTTWatchStartMaxConcurrent = 500 - } - if cfg.WatchDefaultTTLS <= 0 { - cfg.WatchDefaultTTLS = 300 - } - if cfg.WatchMaxTTLS <= 0 { - cfg.WatchMaxTTLS = 900 - } - if cfg.WatchDefaultTTLS > cfg.WatchMaxTTLS { - cfg.WatchDefaultTTLS = cfg.WatchMaxTTLS - } - if cfg.WatchDefaultBufferMessages <= 0 { - cfg.WatchDefaultBufferMessages = 100 - } - if cfg.WatchMaxBufferMessages <= 0 { - cfg.WatchMaxBufferMessages = 1000 - } - if cfg.WatchDefaultBufferMessages > cfg.WatchMaxBufferMessages { - cfg.WatchDefaultBufferMessages = cfg.WatchMaxBufferMessages - } - if cfg.WatchDefaultBufferBytes <= 0 { - cfg.WatchDefaultBufferBytes = 262144 - } - if cfg.WatchMaxBufferBytes <= 0 { - cfg.WatchMaxBufferBytes = 1048576 - } - if cfg.WatchDefaultBufferBytes > cfg.WatchMaxBufferBytes { - cfg.WatchDefaultBufferBytes = cfg.WatchMaxBufferBytes - } - if cfg.WatchMaxPerSession <= 0 { - cfg.WatchMaxPerSession = 10 - } - if cfg.WatchMaxPerPod <= 0 { - cfg.WatchMaxPerPod = 1000 - } if cfg.FindTopicsDefaultLimit <= 0 { cfg.FindTopicsDefaultLimit = 20 } diff --git a/mcp/dsx-exchange-mcp/internal/server/tools_test.go b/mcp/dsx-exchange-mcp/internal/server/tools_test.go index bd446b5..0d1f5a0 100644 --- a/mcp/dsx-exchange-mcp/internal/server/tools_test.go +++ b/mcp/dsx-exchange-mcp/internal/server/tools_test.go @@ -127,25 +127,6 @@ func TestFindTopicsToolMatchesSelector(t *testing.T) { } } -func TestResolveSubscriptionTopicFromSelector(t *testing.T) { - cfg := Config{} - normalizeConfig(&cfg) - topicFilter, err := resolveSubscriptionTopic(cfg, startSubscriptionInput{ - Selector: findTopicsInput{ - Domain: "bms", - Role: "value", - ObjectType: "Rack", - PointType: "RackLiquidIsolationStatus", - }, - }) - if err != nil { - t.Fatalf("resolveSubscriptionTopic returned error: %v", err) - } - if topicFilter != "BMS/v1/PUB/Value/Rack/RackLiquidIsolationStatus/#" { - t.Fatalf("topic filter = %q, want RackLiquidIsolationStatus value filter", topicFilter) - } -} - type toolCallFixture struct { ID string `json:"id"` Domain string `json:"domain"` @@ -179,21 +160,12 @@ func TestToolCallExpectationFixtures(t *testing.T) { } cfg := Config{ - DefaultMaxMessages: 100, - MaxMessages: 1000, - DefaultDurationS: 30, - MaxDurationS: 30, - WatchDefaultTTLS: 300, - WatchMaxTTLS: 900, - WatchDefaultBufferMessages: 100, - WatchMaxBufferMessages: 1000, - WatchDefaultBufferBytes: 262144, - WatchMaxBufferBytes: 1048576, - WatchMaxPerSession: 10, - WatchMaxPerPod: 1000, + DefaultMaxMessages: 100, + MaxMessages: 1000, + DefaultDurationS: 30, + MaxDurationS: 30, } normalizeConfig(&cfg) - watches := newWatchManager(cfg) for _, fixture := range fixtures { t.Run(fixture.ID, func(t *testing.T) { @@ -249,17 +221,6 @@ func TestToolCallExpectationFixtures(t *testing.T) { if _, _, err := applyLimits(cfg, maxMessages, maxDurationS); err != nil { t.Fatalf("subscribe limits invalid for %q: %v", topicFilter, err) } - case toolStartSubscription: - ttlS := intArg(t, fixture.ID, i, call.Arguments, "ttl_seconds") - bufferMaxMessages := intArg(t, fixture.ID, i, call.Arguments, "buffer_max_messages") - bufferMaxBytes := intArg(t, fixture.ID, i, call.Arguments, "buffer_max_bytes") - if _, _, _, err := watches.applyStartLimits(watchStartRequest{ - TTLS: ttlS, - BufferMaxMessages: bufferMaxMessages, - BufferMaxBytes: bufferMaxBytes, - }); err != nil { - t.Fatalf("start_subscription limits invalid for %q: %v", topicFilter, err) - } default: t.Fatalf("call %d has unknown tool %q", i, call.Tool) } @@ -300,10 +261,13 @@ func TestMCPClientListsAndCallsDescribeTopic(t *testing.T) { t.Fatalf("ListTools did not expose %q; saw %#v", name, toolNames) } } - for _, name := range []string{toolStartSubscription, toolReadSubscription, toolStatusSubscription, toolStopSubscription} { - if toolNames[name] { - t.Fatalf("ListTools exposed experimental watch tool %q without opt-in; saw %#v", name, toolNames) - } + + subscribeTool := toolByName(t, tools.Tools, toolSubscribe) + if got := subscribeTool.Meta["x-dsx-exchange-background-preferred"]; got != true { + t.Fatalf("%s metadata background-preferred = %#v, want true", toolSubscribe, got) + } + if got := subscribeTool.Meta["x-dsx-exchange-bounded-window"]; got != true { + t.Fatalf("%s metadata bounded-window = %#v, want true", toolSubscribe, got) } for _, fixture := range fixtures { @@ -342,29 +306,15 @@ func TestMCPClientListsAndCallsDescribeTopic(t *testing.T) { } } -func TestMCPClientDoesNotExposeExperimentalWatchToolsWhenFlagSet(t *testing.T) { - session, cleanup := newTestMCPClientWithConfig(t, Config{ - DefaultMaxMessages: 100, - MaxMessages: 1000, - DefaultDurationS: 30, - MaxDurationS: 30, - EnableExperimentalWatchTools: true, - }) - defer cleanup() - - tools, err := session.ListTools(context.Background(), nil) - if err != nil { - t.Fatalf("ListTools failed: %v", err) - } - toolNames := map[string]bool{} - for _, tool := range tools.Tools { - toolNames[tool.Name] = true - } - for _, name := range []string{toolStartSubscription, toolReadSubscription, toolStatusSubscription, toolStopSubscription} { - if toolNames[name] { - t.Fatalf("ListTools exposed retired watch tool %q; saw %#v", name, toolNames) +func toolByName(t *testing.T, tools []*mcp.Tool, name string) *mcp.Tool { + t.Helper() + for _, tool := range tools { + if tool.Name == name { + return tool } } + t.Fatalf("tools/list did not expose %q", name) + return nil } func TestMCPClientDescribeTopicInvalidFilterReturnsToolError(t *testing.T) { diff --git a/mcp/dsx-exchange-mcp/internal/server/watch.go b/mcp/dsx-exchange-mcp/internal/server/watch.go deleted file mode 100644 index 71de5c5..0000000 --- a/mcp/dsx-exchange-mcp/internal/server/watch.go +++ /dev/null @@ -1,890 +0,0 @@ -// Copyright 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. -// SPDX-License-Identifier: Apache-2.0 - -package server - -import ( - "context" - "crypto/rand" - "encoding/hex" - "encoding/json" - "fmt" - "math" - "sort" - "strconv" - "strings" - "sync" - "time" - "unicode/utf8" - - "github.com/NVIDIA/dsx-exchange/mcp/dsx-exchange-mcp/internal/auth" - "github.com/NVIDIA/dsx-exchange/mcp/dsx-exchange-mcp/internal/mqttbus" -) - -const ( - watchStatusStarting = "starting" - watchStatusRunning = "running" - watchStatusExpired = "expired" - watchStatusFailed = "failed" - watchStatusStopped = "stopped" - - codeStatefulSessionRequired = "stateful_session_required" - codeSubscriptionNotFound = "subscription_not_found" - codeSubscriptionNotOwner = "subscription_not_owner" - codeSchemaTopicNotFound = "schema_topic_not_found" - codeSchemaTopicAmbiguous = "schema_topic_ambiguous" - codeBufferOverflow = "buffer_overflow" -) - -const ( - finishedWatchRetention = 5 * time.Minute - maxWatchStatusUpdates = 50 - maxWatchStatusPayloadBytes = 4096 -) - -type streamRunner func(context.Context, mqttbus.Config, string, string, mqttbus.StreamOptions, func(mqttbus.Message) error) (mqttbus.StreamResult, error) - -type watchManager struct { - cfg Config - runner streamRunner - - mu sync.Mutex - watches map[string]map[string]*watch - total int - activeTotal int - now func() time.Time - newID func() string - retention time.Duration -} - -type watch struct { - id string - sessionID string - authKey string - topicFilter string - status string - createdAt time.Time - expiresAt time.Time - finishedAt time.Time - lastMessage time.Time - lastError *errorBody - - cursor int64 - droppedCount int64 - messageCount int64 - bufferBytes int - buffer []bufferedWatchMessage - updates map[string]*watchTopicUpdate - updatesDropped int64 - - maxMessages int - maxBytes int - cancel context.CancelFunc - stopped bool - active bool -} - -type bufferedWatchMessage struct { - cursor string - size int - msg mqttbus.Message -} - -type watchStartRequest struct { - Caller auth.Caller - TopicFilter string - TTLS int - BufferMaxMessages int - BufferMaxBytes int -} - -type watchReadRequest struct { - Caller auth.Caller - SubscriptionID string - Cursor string - MaxMessages int - MaxBytes int -} - -type watchStatusRequest struct { - Caller auth.Caller - SubscriptionID string -} - -type watchStopRequest struct { - Caller auth.Caller - SubscriptionID string -} - -type watchLimitsOutput struct { - TTLSeconds int `json:"ttl_seconds"` - BufferMaxMessages int `json:"buffer_max_messages"` - BufferMaxBytes int `json:"buffer_max_bytes"` - OverflowPolicy string `json:"overflow_policy"` -} - -type watchWatermark struct { - OldestCursor string `json:"oldest_cursor"` - NewestCursor string `json:"newest_cursor"` -} - -type watchMessageOutput struct { - Cursor string `json:"cursor"` - Topic string `json:"topic"` - Payload string `json:"payload"` - PayloadEncoding string `json:"payload_encoding"` - Retained bool `json:"retained"` - QoS byte `json:"qos"` - ReceivedAt time.Time `json:"received_at"` -} - -type watchTopicUpdate struct { - topic string - count int64 - latestCursor string - latestPayload string - latestPayloadEncoding string - latestPayloadTruncated bool - retained bool - qos byte - latestReceivedAt time.Time - numeric *watchNumericAggregate -} - -type watchTopicUpdateOutput struct { - Topic string `json:"topic"` - Count int64 `json:"count"` - LatestCursor string `json:"latest_cursor"` - LatestPayload string `json:"latest_payload,omitempty"` - LatestPayloadEncoding string `json:"latest_payload_encoding,omitempty"` - LatestPayloadTruncated bool `json:"latest_payload_truncated,omitempty"` - Retained bool `json:"retained"` - QoS byte `json:"qos"` - LatestReceivedAt time.Time `json:"latest_received_at"` - Numeric *watchNumericOutput `json:"numeric,omitempty"` -} - -type watchNumericAggregate struct { - field string - count int64 - min float64 - max float64 - sum float64 - latest float64 -} - -type watchNumericOutput struct { - Field string `json:"field"` - Count int64 `json:"count"` - Min float64 `json:"min"` - Max float64 `json:"max"` - Mean float64 `json:"mean"` - Latest float64 `json:"latest"` -} - -type watchStartOutput struct { - SubscriptionID string `json:"subscription_id"` - Status string `json:"status"` - TopicFilter string `json:"topic_filter"` - Cursor string `json:"cursor"` - ExpiresAt time.Time `json:"expires_at"` - RecommendedReadAfterSeconds int `json:"recommended_read_after_seconds"` - Limits watchLimitsOutput `json:"limits"` -} - -type watchReadOutput struct { - SubscriptionID string `json:"subscription_id"` - Status string `json:"status"` - Messages []watchMessageOutput `json:"messages"` - Count int `json:"count"` - NextCursor string `json:"next_cursor"` - DroppedCount int64 `json:"dropped_count"` - BufferWatermark watchWatermark `json:"buffer_watermark"` - ExpiresAt time.Time `json:"expires_at"` - LastError *errorBody `json:"last_error,omitempty"` -} - -type watchStatusOutput struct { - SubscriptionID string `json:"subscription_id"` - Status string `json:"status"` - TopicFilter string `json:"topic_filter"` - MessageCount int64 `json:"message_count"` - DroppedCount int64 `json:"dropped_count"` - UpdateCount int `json:"update_count"` - UpdatesDropped int64 `json:"updates_dropped"` - UpdatesTruncated bool `json:"updates_truncated"` - Updates []watchTopicUpdateOutput `json:"updates,omitempty"` - OldestCursor string `json:"oldest_cursor"` - NewestCursor string `json:"newest_cursor"` - ExpiresAt time.Time `json:"expires_at"` - LastMessageAt *time.Time `json:"last_message_at,omitempty"` - LastError *errorBody `json:"last_error,omitempty"` - BufferWatermark watchWatermark `json:"buffer_watermark"` -} - -type watchStopOutput struct { - SubscriptionID string `json:"subscription_id"` - Status string `json:"status"` - StoppedReason string `json:"stopped_reason"` - MessageCount int64 `json:"message_count"` - DroppedCount int64 `json:"dropped_count"` -} - -type streamFinished struct { - result mqttbus.StreamResult - err error -} - -func newWatchManager(cfg Config) *watchManager { - return &watchManager{ - cfg: cfg, - runner: mqttbus.Stream, - watches: map[string]map[string]*watch{}, - now: time.Now, - newID: randomSubscriptionID, - retention: finishedWatchRetention, - } -} - -func (m *watchManager) start(req watchStartRequest) (watchStartOutput, error) { - if err := validateWatchCaller(req.Caller); err != nil { - return watchStartOutput{}, err - } - if err := mqttbus.ValidateTopicFilter(req.TopicFilter); err != nil { - return watchStartOutput{}, err - } - ttlS, bufferMessages, bufferBytes, err := m.applyStartLimits(req) - if err != nil { - return watchStartOutput{}, err - } - - started := m.now() - ctx, cancel := context.WithCancel(context.Background()) - w := &watch{ - id: m.newID(), - sessionID: req.Caller.SessionID, - authKey: callerAuthKey(req.Caller), - topicFilter: strings.TrimSpace(req.TopicFilter), - status: watchStatusStarting, - createdAt: started, - expiresAt: started.Add(time.Duration(ttlS) * time.Second), - updates: map[string]*watchTopicUpdate{}, - maxMessages: bufferMessages, - maxBytes: bufferBytes, - cancel: cancel, - active: true, - } - - ready := make(chan struct{}, 1) - finished := make(chan streamFinished, 1) - - if !m.cfg.watchStartAdmission.tryAcquire() { - cancel() - return watchStartOutput{}, admissionLimitedError() - } - releaseStartup := sync.OnceFunc(m.cfg.watchStartAdmission.release) - - m.mu.Lock() - if m.activeTotal >= m.cfg.WatchMaxPerPod { - m.mu.Unlock() - releaseStartup() - cancel() - return watchStartOutput{}, &mqttbus.BusError{Code: mqttbus.CodeInvalidArgument, Message: fmt.Sprintf("active watch count exceeds per-pod cap %d", m.cfg.WatchMaxPerPod)} - } - sessionWatches := m.watches[w.sessionID] - if activeSessionCount(sessionWatches) >= m.cfg.WatchMaxPerSession { - m.mu.Unlock() - releaseStartup() - cancel() - return watchStartOutput{}, &mqttbus.BusError{Code: mqttbus.CodeInvalidArgument, Message: fmt.Sprintf("active watch count exceeds per-session cap %d", m.cfg.WatchMaxPerSession)} - } - if sessionWatches == nil { - sessionWatches = map[string]*watch{} - m.watches[w.sessionID] = sessionWatches - } - sessionWatches[w.id] = w - m.total++ - m.activeTotal++ - m.mu.Unlock() - - go func() { - result, err := m.runner(ctx, m.cfg.MQTT, req.Caller.Bearer, w.topicFilter, mqttbus.StreamOptions{ - ClientID: "dsx-exchange-mcp-watch-" + w.id, - MaxMessages: math.MaxInt32, - MaxDuration: time.Duration(ttlS) * time.Second, - OnSubscribed: func() { - releaseStartup() - m.markRunning(w.sessionID, w.id) - select { - case ready <- struct{}{}: - default: - } - }, - }, func(msg mqttbus.Message) error { - m.recordMessage(w.sessionID, w.id, msg) - return nil - }) - releaseStartup() - m.finish(w.sessionID, w.id, result, err) - select { - case finished <- streamFinished{result: result, err: err}: - default: - } - }() - - select { - case <-ready: - return m.startOutput(w.sessionID, w.id), nil - case done := <-finished: - if done.err != nil { - m.remove(w.sessionID, w.id) - return watchStartOutput{}, done.err - } - return m.startOutput(w.sessionID, w.id), nil - case <-time.After(m.startWait()): - return m.startOutput(w.sessionID, w.id), nil - } -} - -func (m *watchManager) read(req watchReadRequest) (watchReadOutput, error) { - if err := validateWatchCaller(req.Caller); err != nil { - return watchReadOutput{}, err - } - cursor, err := parseCursor(req.Cursor) - if err != nil { - return watchReadOutput{}, err - } - maxMessages, maxBytes, err := m.applyReadLimits(req.MaxMessages, req.MaxBytes) - if err != nil { - return watchReadOutput{}, err - } - - m.mu.Lock() - defer m.mu.Unlock() - w, err := m.lookupLocked(req.Caller, req.SubscriptionID) - if err != nil { - return watchReadOutput{}, err - } - - messages := make([]watchMessageOutput, 0, maxMessages) - bytes := 0 - nextCursor := strconv.FormatInt(w.cursor, 10) - for _, buffered := range w.buffer { - messageCursor, _ := strconv.ParseInt(buffered.cursor, 10, 64) - if messageCursor <= cursor { - continue - } - if len(messages) >= maxMessages { - break - } - if len(messages) > 0 && bytes+buffered.size > maxBytes { - break - } - bytes += buffered.size - nextCursor = buffered.cursor - messages = append(messages, watchMessageOutput{ - Cursor: buffered.cursor, - Topic: buffered.msg.Topic, - Payload: buffered.msg.Payload, - PayloadEncoding: buffered.msg.PayloadEncoding, - Retained: buffered.msg.Retained, - QoS: buffered.msg.QoS, - ReceivedAt: buffered.msg.ReceivedAt, - }) - } - - return watchReadOutput{ - SubscriptionID: w.id, - Status: w.status, - Messages: messages, - Count: len(messages), - NextCursor: nextCursor, - DroppedCount: w.droppedCount, - BufferWatermark: w.watermark(), - ExpiresAt: w.expiresAt, - LastError: w.lastError, - }, nil -} - -func (m *watchManager) status(req watchStatusRequest) (watchStatusOutput, error) { - if err := validateWatchCaller(req.Caller); err != nil { - return watchStatusOutput{}, err - } - - m.mu.Lock() - defer m.mu.Unlock() - w, err := m.lookupLocked(req.Caller, req.SubscriptionID) - if err != nil { - return watchStatusOutput{}, err - } - updates, updatesTruncated := w.statusUpdates() - out := watchStatusOutput{ - SubscriptionID: w.id, - Status: w.status, - TopicFilter: w.topicFilter, - MessageCount: w.messageCount, - DroppedCount: w.droppedCount, - UpdateCount: len(w.updates), - UpdatesDropped: w.updatesDropped, - UpdatesTruncated: updatesTruncated || w.updatesDropped > 0, - Updates: updates, - OldestCursor: w.oldestCursor(), - NewestCursor: strconv.FormatInt(w.cursor, 10), - ExpiresAt: w.expiresAt, - LastError: w.lastError, - BufferWatermark: w.watermark(), - } - if !w.lastMessage.IsZero() { - last := w.lastMessage - out.LastMessageAt = &last - } - return out, nil -} - -func (m *watchManager) stop(req watchStopRequest) (watchStopOutput, error) { - if err := validateWatchCaller(req.Caller); err != nil { - return watchStopOutput{}, err - } - - m.mu.Lock() - w, err := m.lookupLocked(req.Caller, req.SubscriptionID) - if err != nil { - m.mu.Unlock() - return watchStopOutput{}, err - } - out := watchStopOutput{ - SubscriptionID: w.id, - Status: watchStatusStopped, - StoppedReason: "user_requested", - MessageCount: w.messageCount, - DroppedCount: w.droppedCount, - } - w.stopped = true - w.status = watchStatusStopped - cancel := w.cancel - m.mu.Unlock() - - if cancel != nil { - cancel() - } - m.remove(req.Caller.SessionID, req.SubscriptionID) - return out, nil -} - -func (m *watchManager) applyStartLimits(req watchStartRequest) (int, int, int, error) { - ttlS := req.TTLS - if ttlS == 0 { - ttlS = m.cfg.WatchDefaultTTLS - } - if ttlS <= 0 { - return 0, 0, 0, &mqttbus.BusError{Code: mqttbus.CodeInvalidArgument, Message: "ttl_seconds must be greater than zero"} - } - if ttlS > m.cfg.WatchMaxTTLS { - return 0, 0, 0, &mqttbus.BusError{Code: mqttbus.CodeInvalidArgument, Message: fmt.Sprintf("ttl_seconds exceeds cap %d", m.cfg.WatchMaxTTLS)} - } - - bufferMessages := req.BufferMaxMessages - if bufferMessages == 0 { - bufferMessages = m.cfg.WatchDefaultBufferMessages - } - if bufferMessages <= 0 { - return 0, 0, 0, &mqttbus.BusError{Code: mqttbus.CodeInvalidArgument, Message: "buffer_max_messages must be greater than zero"} - } - if bufferMessages > m.cfg.WatchMaxBufferMessages { - return 0, 0, 0, &mqttbus.BusError{Code: mqttbus.CodeInvalidArgument, Message: fmt.Sprintf("buffer_max_messages exceeds cap %d", m.cfg.WatchMaxBufferMessages)} - } - - bufferBytes := req.BufferMaxBytes - if bufferBytes == 0 { - bufferBytes = m.cfg.WatchDefaultBufferBytes - } - if bufferBytes <= 0 { - return 0, 0, 0, &mqttbus.BusError{Code: mqttbus.CodeInvalidArgument, Message: "buffer_max_bytes must be greater than zero"} - } - if bufferBytes > m.cfg.WatchMaxBufferBytes { - return 0, 0, 0, &mqttbus.BusError{Code: mqttbus.CodeInvalidArgument, Message: fmt.Sprintf("buffer_max_bytes exceeds cap %d", m.cfg.WatchMaxBufferBytes)} - } - return ttlS, bufferMessages, bufferBytes, nil -} - -func (m *watchManager) applyReadLimits(maxMessages, maxBytes int) (int, int, error) { - if maxMessages == 0 { - maxMessages = m.cfg.WatchDefaultBufferMessages - } - if maxMessages <= 0 { - return 0, 0, &mqttbus.BusError{Code: mqttbus.CodeInvalidArgument, Message: "max_messages must be greater than zero"} - } - if maxMessages > m.cfg.WatchMaxBufferMessages { - return 0, 0, &mqttbus.BusError{Code: mqttbus.CodeInvalidArgument, Message: fmt.Sprintf("max_messages exceeds cap %d", m.cfg.WatchMaxBufferMessages)} - } - if maxBytes == 0 { - maxBytes = m.cfg.WatchDefaultBufferBytes - } - if maxBytes <= 0 { - return 0, 0, &mqttbus.BusError{Code: mqttbus.CodeInvalidArgument, Message: "max_bytes must be greater than zero"} - } - if maxBytes > m.cfg.WatchMaxBufferBytes { - return 0, 0, &mqttbus.BusError{Code: mqttbus.CodeInvalidArgument, Message: fmt.Sprintf("max_bytes exceeds cap %d", m.cfg.WatchMaxBufferBytes)} - } - return maxMessages, maxBytes, nil -} - -func (m *watchManager) startWait() time.Duration { - timeout := m.cfg.MQTT.ConnectTimeout + m.cfg.MQTT.SubscribeTimeout + time.Second - if timeout <= time.Second { - return 11 * time.Second - } - return timeout -} - -func (m *watchManager) startOutput(sessionID, subscriptionID string) watchStartOutput { - m.mu.Lock() - defer m.mu.Unlock() - w := m.watches[sessionID][subscriptionID] - if w == nil { - return watchStartOutput{} - } - return watchStartOutput{ - SubscriptionID: w.id, - Status: w.status, - TopicFilter: w.topicFilter, - Cursor: strconv.FormatInt(w.cursor, 10), - ExpiresAt: w.expiresAt, - RecommendedReadAfterSeconds: 30, - Limits: watchLimitsOutput{ - TTLSeconds: int(w.expiresAt.Sub(w.createdAt).Seconds()), - BufferMaxMessages: w.maxMessages, - BufferMaxBytes: w.maxBytes, - OverflowPolicy: "drop_oldest", - }, - } -} - -func (m *watchManager) markRunning(sessionID, subscriptionID string) { - m.mu.Lock() - defer m.mu.Unlock() - if w := m.watches[sessionID][subscriptionID]; w != nil && w.status == watchStatusStarting { - w.status = watchStatusRunning - } -} - -func (m *watchManager) recordMessage(sessionID, subscriptionID string, msg mqttbus.Message) { - m.mu.Lock() - defer m.mu.Unlock() - w := m.watches[sessionID][subscriptionID] - if w == nil { - return - } - w.cursor++ - w.messageCount++ - w.lastMessage = msg.ReceivedAt - cursor := strconv.FormatInt(w.cursor, 10) - w.recordTopicUpdate(cursor, msg) - size := len(msg.Topic) + len(msg.Payload) - w.buffer = append(w.buffer, bufferedWatchMessage{ - cursor: cursor, - size: size, - msg: msg, - }) - w.bufferBytes += size - for len(w.buffer) > w.maxMessages || w.bufferBytes > w.maxBytes { - dropped := w.buffer[0] - w.buffer = w.buffer[1:] - w.bufferBytes -= dropped.size - w.droppedCount++ - } -} - -func (w *watch) recordTopicUpdate(cursor string, msg mqttbus.Message) { - if w.updates == nil { - w.updates = map[string]*watchTopicUpdate{} - } - update := w.updates[msg.Topic] - if update == nil { - if len(w.updates) >= maxWatchStatusUpdates { - w.evictOldestTopicUpdate() - } - update = &watchTopicUpdate{topic: msg.Topic} - w.updates[msg.Topic] = update - } - payload, truncated := truncatePayloadSample(msg.Payload, maxWatchStatusPayloadBytes) - update.count++ - update.latestCursor = cursor - update.latestPayload = payload - update.latestPayloadEncoding = msg.PayloadEncoding - update.latestPayloadTruncated = truncated - update.retained = msg.Retained - update.qos = msg.QoS - update.latestReceivedAt = msg.ReceivedAt - if field, value, ok := extractNumericPayloadValue(msg.Payload); ok { - update.recordNumeric(field, value) - } -} - -func (w *watch) evictOldestTopicUpdate() { - var oldest *watchTopicUpdate - oldestTopic := "" - for topic, update := range w.updates { - if oldest == nil || - update.latestReceivedAt.Before(oldest.latestReceivedAt) || - (update.latestReceivedAt.Equal(oldest.latestReceivedAt) && topic < oldestTopic) { - oldest = update - oldestTopic = topic - } - } - if oldestTopic == "" { - return - } - delete(w.updates, oldestTopic) - w.updatesDropped++ -} - -func (w *watch) statusUpdates() ([]watchTopicUpdateOutput, bool) { - updates := make([]watchTopicUpdateOutput, 0, len(w.updates)) - for _, update := range w.updates { - var numeric *watchNumericOutput - if update.numeric != nil { - numeric = update.numeric.output() - } - updates = append(updates, watchTopicUpdateOutput{ - Topic: update.topic, - Count: update.count, - LatestCursor: update.latestCursor, - LatestPayload: update.latestPayload, - LatestPayloadEncoding: update.latestPayloadEncoding, - LatestPayloadTruncated: update.latestPayloadTruncated, - Retained: update.retained, - QoS: update.qos, - LatestReceivedAt: update.latestReceivedAt, - Numeric: numeric, - }) - } - sort.Slice(updates, func(i, j int) bool { - left := updates[i] - right := updates[j] - if !left.LatestReceivedAt.Equal(right.LatestReceivedAt) { - return left.LatestReceivedAt.After(right.LatestReceivedAt) - } - return left.Topic < right.Topic - }) - if len(updates) <= maxWatchStatusUpdates { - return updates, false - } - return updates[:maxWatchStatusUpdates], true -} - -func (u *watchTopicUpdate) recordNumeric(field string, value float64) { - if u.numeric == nil || u.numeric.field != field { - u.numeric = &watchNumericAggregate{ - field: field, - count: 1, - min: value, - max: value, - sum: value, - latest: value, - } - return - } - u.numeric.count++ - u.numeric.sum += value - u.numeric.latest = value - if value < u.numeric.min { - u.numeric.min = value - } - if value > u.numeric.max { - u.numeric.max = value - } -} - -func (a *watchNumericAggregate) output() *watchNumericOutput { - if a == nil || a.count == 0 { - return nil - } - return &watchNumericOutput{ - Field: a.field, - Count: a.count, - Min: a.min, - Max: a.max, - Mean: a.sum / float64(a.count), - Latest: a.latest, - } -} - -func extractNumericPayloadValue(payload string) (string, float64, bool) { - var body map[string]any - if err := json.Unmarshal([]byte(payload), &body); err != nil { - return "", 0, false - } - if value, ok := numericJSONValue(body["value"]); ok { - return "value", value, true - } - data, ok := body["data"].(map[string]any) - if !ok { - return "", 0, false - } - if value, ok := numericJSONValue(data["value"]); ok { - return "data.value", value, true - } - return "", 0, false -} - -func numericJSONValue(v any) (float64, bool) { - switch n := v.(type) { - case float64: - if math.IsNaN(n) || math.IsInf(n, 0) { - return 0, false - } - return n, true - default: - return 0, false - } -} - -func truncatePayloadSample(payload string, maxBytes int) (string, bool) { - if maxBytes <= 0 || len(payload) <= maxBytes { - return payload, false - } - sample := payload[:maxBytes] - for len(sample) > 0 && !utf8.ValidString(sample) { - sample = sample[:len(sample)-1] - } - return sample, true -} - -func (m *watchManager) finish(sessionID, subscriptionID string, result mqttbus.StreamResult, err error) { - m.mu.Lock() - w := m.watches[sessionID][subscriptionID] - if w == nil { - m.mu.Unlock() - return - } - if w.active { - w.active = false - m.activeTotal-- - } - switch { - case w.stopped: - w.status = watchStatusStopped - case err != nil: - w.status = watchStatusFailed - w.lastError = &errorBody{Code: errorCode(err), Message: publicMessage(err)} - case result.StoppedReason == mqttbus.StoppedMaxDuration: - w.status = watchStatusExpired - case result.StoppedReason == mqttbus.StoppedMaxMessages: - w.status = watchStatusFailed - w.lastError = &errorBody{Code: codeBufferOverflow, Message: "watch stream reached internal message cap"} - default: - w.status = watchStatusFailed - if result.StoppedReason != "" { - w.lastError = &errorBody{Code: result.StoppedReason, Message: "watch stream stopped"} - } - } - w.finishedAt = m.now() - m.mu.Unlock() - - if w.status == watchStatusExpired || w.status == watchStatusFailed { - time.AfterFunc(m.retention, func() { - m.remove(sessionID, subscriptionID) - }) - } -} - -func (m *watchManager) lookupLocked(caller auth.Caller, subscriptionID string) (*watch, error) { - if strings.TrimSpace(subscriptionID) == "" { - return nil, &mqttbus.BusError{Code: codeSubscriptionNotFound, Message: "subscription_id is required"} - } - sessionWatches := m.watches[caller.SessionID] - if sessionWatches == nil { - return nil, &mqttbus.BusError{Code: codeSubscriptionNotFound, Message: "subscription is not active on this MCP session; it may have expired, been stopped, or been lost due to pod restart"} - } - w := sessionWatches[subscriptionID] - if w == nil { - return nil, &mqttbus.BusError{Code: codeSubscriptionNotFound, Message: "subscription is not active on this MCP session; it may have expired, been stopped, or been lost due to pod restart"} - } - if w.authKey != callerAuthKey(caller) { - return nil, &mqttbus.BusError{Code: codeSubscriptionNotOwner, Message: "caller does not own this subscription"} - } - return w, nil -} - -func (m *watchManager) remove(sessionID, subscriptionID string) { - m.mu.Lock() - sessionWatches := m.watches[sessionID] - if sessionWatches == nil || sessionWatches[subscriptionID] == nil { - m.mu.Unlock() - return - } - w := sessionWatches[subscriptionID] - if w.active { - w.active = false - m.activeTotal-- - } - delete(sessionWatches, subscriptionID) - m.total-- - if len(sessionWatches) == 0 { - delete(m.watches, sessionID) - } - m.mu.Unlock() -} - -func activeSessionCount(sessionWatches map[string]*watch) int { - count := 0 - for _, w := range sessionWatches { - if w != nil && w.active { - count++ - } - } - return count -} - -func (w *watch) oldestCursor() string { - if len(w.buffer) == 0 { - return strconv.FormatInt(w.cursor, 10) - } - return w.buffer[0].cursor -} - -func (w *watch) watermark() watchWatermark { - return watchWatermark{ - OldestCursor: w.oldestCursor(), - NewestCursor: strconv.FormatInt(w.cursor, 10), - } -} - -func validateWatchCaller(caller auth.Caller) error { - if strings.TrimSpace(caller.SessionID) == "" { - return &mqttbus.BusError{Code: codeStatefulSessionRequired, Message: "background subscriptions require Mcp-Session-Id stateful routing"} - } - if strings.TrimSpace(caller.Bearer) == "" { - return &mqttbus.BusError{Code: mqttbus.CodeMissingBearer, Message: "missing caller bearer; gateway should pass Authorization through"} - } - return nil -} - -func callerAuthKey(caller auth.Caller) string { - return strings.Join([]string{ - caller.Tenant, - caller.Issuer, - caller.Subject, - caller.SpiffeID, - }, "\x00") -} - -func parseCursor(cursor string) (int64, error) { - if strings.TrimSpace(cursor) == "" { - return 0, nil - } - parsed, err := strconv.ParseInt(cursor, 10, 64) - if err != nil || parsed < 0 { - return 0, &mqttbus.BusError{Code: mqttbus.CodeInvalidArgument, Message: "cursor must be a non-negative integer string"} - } - return parsed, nil -} - -func randomSubscriptionID() string { - var raw [8]byte - if _, err := rand.Read(raw[:]); err != nil { - return "sub_" + strconv.FormatInt(time.Now().UnixNano(), 36) - } - return "sub_" + hex.EncodeToString(raw[:]) -} diff --git a/mcp/dsx-exchange-mcp/internal/server/watch_test.go b/mcp/dsx-exchange-mcp/internal/server/watch_test.go deleted file mode 100644 index 24de1b8..0000000 --- a/mcp/dsx-exchange-mcp/internal/server/watch_test.go +++ /dev/null @@ -1,315 +0,0 @@ -// Copyright 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. -// SPDX-License-Identifier: Apache-2.0 - -package server - -import ( - "context" - "fmt" - "strings" - "testing" - "time" - - "github.com/NVIDIA/dsx-exchange/mcp/dsx-exchange-mcp/internal/auth" - "github.com/NVIDIA/dsx-exchange/mcp/dsx-exchange-mcp/internal/mqttbus" -) - -func TestWatchManagerLifecycleReadOverflowAndStop(t *testing.T) { - cfg := Config{ - WatchDefaultTTLS: 30, - WatchMaxTTLS: 60, - WatchDefaultBufferMessages: 2, - WatchMaxBufferMessages: 10, - WatchDefaultBufferBytes: 1024, - WatchMaxBufferBytes: 2048, - WatchMaxPerSession: 2, - WatchMaxPerPod: 10, - } - normalizeConfig(&cfg) - m := newWatchManager(cfg) - m.newID = func() string { return "sub_test" } - m.retention = time.Millisecond - m.runner = fakeStreamRunner([]mqttbus.Message{ - {Topic: "BMS/v1/PUB/Value/Rack/RackPower/a", Payload: `{"value":1}`, PayloadEncoding: "utf8", ReceivedAt: time.Unix(1, 0)}, - {Topic: "BMS/v1/PUB/Value/Rack/RackPower/a", Payload: `{"value":2}`, PayloadEncoding: "utf8", ReceivedAt: time.Unix(2, 0)}, - {Topic: "BMS/v1/PUB/Value/Rack/RackPower/a", Payload: `{"value":3}`, PayloadEncoding: "utf8", ReceivedAt: time.Unix(3, 0)}, - }) - caller := testCaller() - - start, err := m.start(watchStartRequest{ - Caller: caller, - TopicFilter: "BMS/v1/PUB/Value/Rack/RackPower/#", - }) - if err != nil { - t.Fatalf("start returned error: %v", err) - } - if start.SubscriptionID != "sub_test" || start.Status != watchStatusRunning { - t.Fatalf("start = %#v, want running sub_test", start) - } - - read, err := m.read(watchReadRequest{ - Caller: caller, - SubscriptionID: "sub_test", - Cursor: "0", - MaxMessages: 10, - MaxBytes: 2048, - }) - if err != nil { - t.Fatalf("read returned error: %v", err) - } - if read.Count != 2 { - t.Fatalf("read count = %d, want 2", read.Count) - } - if read.Messages[0].Cursor != "2" || read.Messages[1].Cursor != "3" { - t.Fatalf("read cursors = %#v, want retained messages 2 and 3", read.Messages) - } - if read.DroppedCount != 1 { - t.Fatalf("dropped_count = %d, want 1", read.DroppedCount) - } - - status, err := m.status(watchStatusRequest{ - Caller: caller, - SubscriptionID: "sub_test", - }) - if err != nil { - t.Fatalf("status returned error: %v", err) - } - if status.MessageCount != 3 || status.DroppedCount != 1 { - t.Fatalf("status = %#v, want 3 messages and 1 drop", status) - } - if status.UpdateCount != 1 || status.UpdatesTruncated { - t.Fatalf("status updates = count %d truncated %v, want one untruncated update", status.UpdateCount, status.UpdatesTruncated) - } - if status.UpdatesDropped != 0 { - t.Fatalf("updates_dropped = %d, want 0", status.UpdatesDropped) - } - if len(status.Updates) != 1 { - t.Fatalf("status updates len = %d, want 1", len(status.Updates)) - } - update := status.Updates[0] - if update.Topic != "BMS/v1/PUB/Value/Rack/RackPower/a" || update.Count != 3 || update.LatestCursor != "3" { - t.Fatalf("status update = %#v, want latest cursor 3 with count 3", update) - } - if update.LatestPayload != `{"value":3}` || update.LatestReceivedAt != time.Unix(3, 0) { - t.Fatalf("status update latest = payload %q time %s, want value 3 at unix 3", update.LatestPayload, update.LatestReceivedAt) - } - if update.Numeric == nil { - t.Fatal("status update numeric aggregate missing") - } - if update.Numeric.Field != "value" || update.Numeric.Count != 3 || update.Numeric.Min != 1 || update.Numeric.Max != 3 || update.Numeric.Mean != 2 || update.Numeric.Latest != 3 { - t.Fatalf("numeric aggregate = %#v, want field=value count=3 min=1 max=3 mean=2 latest=3", update.Numeric) - } - - stop, err := m.stop(watchStopRequest{ - Caller: caller, - SubscriptionID: "sub_test", - }) - if err != nil { - t.Fatalf("stop returned error: %v", err) - } - if stop.Status != watchStatusStopped { - t.Fatalf("stop status = %q, want stopped", stop.Status) - } - - _, err = m.read(watchReadRequest{ - Caller: caller, - SubscriptionID: "sub_test", - }) - if got := mqttbus.ErrorCode(err); got != codeSubscriptionNotFound { - t.Fatalf("read after stop code = %q, want %q", got, codeSubscriptionNotFound) - } -} - -func TestWatchManagerStatusAggregatesBoundedTopicUpdates(t *testing.T) { - cfg := Config{ - WatchDefaultTTLS: 30, - WatchMaxTTLS: 60, - WatchDefaultBufferMessages: 2, - WatchMaxBufferMessages: 100, - WatchDefaultBufferBytes: 8192, - WatchMaxBufferBytes: 8192, - WatchMaxPerSession: 2, - WatchMaxPerPod: 10, - } - normalizeConfig(&cfg) - m := newWatchManager(cfg) - m.newID = func() string { return "sub_aggregate" } - - messages := make([]mqttbus.Message, 0, maxWatchStatusUpdates+1) - for i := 0; i < maxWatchStatusUpdates+1; i++ { - payload := fmt.Sprintf(`{"value":%d}`, i) - if i == maxWatchStatusUpdates { - payload = strings.Repeat("x", maxWatchStatusPayloadBytes+10) - } - messages = append(messages, mqttbus.Message{ - Topic: fmt.Sprintf("BMS/v1/PUB/Value/Rack/RackPower/%03d", i), - Payload: payload, - PayloadEncoding: "utf8", - ReceivedAt: time.Unix(int64(i), 0), - }) - } - m.runner = fakeStreamRunner(messages) - caller := testCaller() - - start, err := m.start(watchStartRequest{ - Caller: caller, - TopicFilter: "BMS/v1/PUB/Value/Rack/RackPower/#", - }) - if err != nil { - t.Fatalf("start returned error: %v", err) - } - - status, err := m.status(watchStatusRequest{ - Caller: caller, - SubscriptionID: start.SubscriptionID, - }) - if err != nil { - t.Fatalf("status returned error: %v", err) - } - if status.UpdateCount != maxWatchStatusUpdates { - t.Fatalf("update_count = %d, want %d", status.UpdateCount, maxWatchStatusUpdates) - } - if len(status.Updates) != maxWatchStatusUpdates || status.UpdatesDropped != 1 || !status.UpdatesTruncated { - t.Fatalf("updates len/dropped/truncated = %d/%d/%v, want %d/1/true", len(status.Updates), status.UpdatesDropped, status.UpdatesTruncated, maxWatchStatusUpdates) - } - latest := status.Updates[0] - if latest.Topic != "BMS/v1/PUB/Value/Rack/RackPower/050" || latest.LatestCursor != "51" { - t.Fatalf("latest update = %#v, want newest topic/cursor", latest) - } - if len(latest.LatestPayload) != maxWatchStatusPayloadBytes || !latest.LatestPayloadTruncated { - t.Fatalf("latest payload len/truncated = %d/%v, want %d/true", len(latest.LatestPayload), latest.LatestPayloadTruncated, maxWatchStatusPayloadBytes) - } - - _, err = m.stop(watchStopRequest{ - Caller: caller, - SubscriptionID: start.SubscriptionID, - }) - if err != nil { - t.Fatalf("stop returned error: %v", err) - } -} - -func TestWatchManagerStatusOmitsNumericForMetadataAndTracksCloudEventValue(t *testing.T) { - cfg := Config{ - WatchDefaultTTLS: 30, - WatchMaxTTLS: 60, - WatchDefaultBufferMessages: 10, - WatchMaxBufferMessages: 100, - WatchDefaultBufferBytes: 8192, - WatchMaxBufferBytes: 8192, - WatchMaxPerSession: 2, - WatchMaxPerPod: 10, - } - normalizeConfig(&cfg) - m := newWatchManager(cfg) - m.newID = func() string { return "sub_mixed_aggregates" } - m.runner = fakeStreamRunner([]mqttbus.Message{ - {Topic: "BMS/v1/PUB/Metadata/Rack/RackPower/a", Payload: `{"unit":"kW","displayName":"Rack A"}`, PayloadEncoding: "utf8", ReceivedAt: time.Unix(1, 0)}, - {Topic: "grid/v1/poweragent/a/powerstate/status", Payload: `{"specversion":"1.0","data":{"value":10}}`, PayloadEncoding: "utf8", ReceivedAt: time.Unix(2, 0)}, - {Topic: "grid/v1/poweragent/a/powerstate/status", Payload: `{"specversion":"1.0","data":{"value":20}}`, PayloadEncoding: "utf8", ReceivedAt: time.Unix(3, 0)}, - }) - caller := testCaller() - - start, err := m.start(watchStartRequest{ - Caller: caller, - TopicFilter: "BMS/v1/PUB/#", - }) - if err != nil { - t.Fatalf("start returned error: %v", err) - } - status, err := m.status(watchStatusRequest{ - Caller: caller, - SubscriptionID: start.SubscriptionID, - }) - if err != nil { - t.Fatalf("status returned error: %v", err) - } - - updates := map[string]watchTopicUpdateOutput{} - for _, update := range status.Updates { - updates[update.Topic] = update - } - metadata := updates["BMS/v1/PUB/Metadata/Rack/RackPower/a"] - if metadata.Count != 1 || metadata.Numeric != nil { - t.Fatalf("metadata update = %#v, want count-only without numeric aggregate", metadata) - } - event := updates["grid/v1/poweragent/a/powerstate/status"] - if event.Numeric == nil { - t.Fatal("CloudEvent-style numeric aggregate missing") - } - if event.Numeric.Field != "data.value" || event.Numeric.Count != 2 || event.Numeric.Min != 10 || event.Numeric.Max != 20 || event.Numeric.Mean != 15 || event.Numeric.Latest != 20 { - t.Fatalf("CloudEvent numeric aggregate = %#v, want field=data.value count=2 min=10 max=20 mean=15 latest=20", event.Numeric) - } -} - -func TestWatchManagerStartAdmissionLimitFailsFast(t *testing.T) { - cfg := Config{ - WatchDefaultTTLS: 30, - WatchMaxTTLS: 60, - WatchDefaultBufferMessages: 10, - WatchMaxBufferMessages: 100, - WatchDefaultBufferBytes: 8192, - WatchMaxBufferBytes: 8192, - WatchMaxPerSession: 2, - WatchMaxPerPod: 10, - } - normalizeConfig(&cfg) - cfg.watchStartAdmission = newAdmissionLimiter(1) - if !cfg.watchStartAdmission.tryAcquire() { - t.Fatal("pre-acquire watch-start admission failed") - } - m := newWatchManager(cfg) - - _, err := m.start(watchStartRequest{ - Caller: testCaller(), - TopicFilter: "BMS/v1/PUB/Value/Rack/RackPower/#", - }) - if got := mqttbus.ErrorCode(err); got != mqttbus.CodeMQTTAdmissionLimited { - t.Fatalf("start admission error code = %q, want %q", got, mqttbus.CodeMQTTAdmissionLimited) - } - if retry := retryAfterSeconds(err); retry != 1 { - t.Fatalf("retry_after_seconds = %d, want 1", retry) - } -} - -func TestWatchManagerRequiresStatefulSession(t *testing.T) { - cfg := Config{} - normalizeConfig(&cfg) - m := newWatchManager(cfg) - caller := testCaller() - caller.SessionID = "" - - _, err := m.start(watchStartRequest{ - Caller: caller, - TopicFilter: "BMS/v1/PUB/Value/Rack/RackPower/#", - }) - if got := mqttbus.ErrorCode(err); got != codeStatefulSessionRequired { - t.Fatalf("start without session code = %q, want %q", got, codeStatefulSessionRequired) - } -} - -func fakeStreamRunner(messages []mqttbus.Message) streamRunner { - return func(ctx context.Context, _ mqttbus.Config, _, _ string, opts mqttbus.StreamOptions, onMessage func(mqttbus.Message) error) (mqttbus.StreamResult, error) { - for _, msg := range messages { - if err := onMessage(msg); err != nil { - return mqttbus.StreamResult{}, err - } - } - if opts.OnSubscribed != nil { - opts.OnSubscribed() - } - <-ctx.Done() - return mqttbus.StreamResult{Count: len(messages), StoppedReason: mqttbus.StoppedCallerCancel}, ctx.Err() - } -} - -func testCaller() auth.Caller { - return auth.Caller{ - Bearer: "test-token", - SessionID: "session-1", - Tenant: "tenant-1", - Issuer: "issuer-1", - Subject: "subject-1", - SpiffeID: "spiffe://test", - } -} diff --git a/mcp/dsx-exchange-mcp/internal/server/watch_tools.go b/mcp/dsx-exchange-mcp/internal/server/watch_tools.go deleted file mode 100644 index 464357e..0000000 --- a/mcp/dsx-exchange-mcp/internal/server/watch_tools.go +++ /dev/null @@ -1,253 +0,0 @@ -// Copyright 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. -// SPDX-License-Identifier: Apache-2.0 - -package server - -import ( - "context" - "encoding/json" - "fmt" - "log/slog" - "strings" - "time" - - "github.com/modelcontextprotocol/go-sdk/mcp" - - "github.com/NVIDIA/dsx-exchange/mcp/dsx-exchange-mcp/internal/auth" - "github.com/NVIDIA/dsx-exchange/mcp/dsx-exchange-mcp/internal/mqttbus" - "github.com/NVIDIA/dsx-exchange/mcp/dsx-exchange-mcp/internal/schemaindex" -) - -const ( - toolStartSubscription = "dsx_exchange_start_subscription" - toolReadSubscription = "dsx_exchange_read_subscription" - toolStatusSubscription = "dsx_exchange_subscription_status" - toolStopSubscription = "dsx_exchange_stop_subscription" -) - -type startSubscriptionInput struct { - TopicFilter string `json:"topic_filter,omitempty" jsonschema:"Explicit MQTT topic filter to watch. Use either topic_filter or selector, not both."` - Selector findTopicsInput `json:"selector,omitempty" jsonschema:"AsyncAPI selector used to derive one topic filter when the caller does not know the raw MQTT path."` - TTLSeconds int `json:"ttl_seconds,omitempty" jsonschema:"Watch TTL in seconds. Defaults to configured value and is capped by MCP_WATCH_MAX_TTL_S."` - BufferMaxMessages int `json:"buffer_max_messages,omitempty" jsonschema:"Maximum messages retained in the pod-local ring buffer."` - BufferMaxBytes int `json:"buffer_max_bytes,omitempty" jsonschema:"Maximum payload/topic bytes retained in the pod-local ring buffer."` -} - -type readSubscriptionInput struct { - SubscriptionID string `json:"subscription_id" jsonschema:"Subscription ID returned by dsx_exchange_start_subscription."` - Cursor string `json:"cursor,omitempty" jsonschema:"Last cursor seen by the caller. Empty means read from the beginning of the retained local buffer."` - MaxMessages int `json:"max_messages,omitempty" jsonschema:"Maximum messages to return from the local buffer."` - MaxBytes int `json:"max_bytes,omitempty" jsonschema:"Maximum topic/payload bytes to return from the local buffer."` -} - -type subscriptionIDInput struct { - SubscriptionID string `json:"subscription_id" jsonschema:"Subscription ID returned by dsx_exchange_start_subscription."` -} - -func registerWatchTools(s *mcp.Server, cfg Config, watches *watchManager) { - mcp.AddTool(s, &mcp.Tool{ - Name: toolStartSubscription, - Description: "Start a pod-local background MQTT watch and return a subscription_id immediately after the initial MQTT subscribe succeeds or is accepted as starting. " + - "The caller bearer is used only in memory as the MQTT password. Broker/auth-callout enforces topic ACLs. " + - "Requires stateful MCP routing via Mcp-Session-Id. The watch is lost on owning pod restart or session loss.", - }, func(ctx context.Context, _ *mcp.CallToolRequest, in startSubscriptionInput) (*mcp.CallToolResult, watchStartOutput, error) { - return startSubscriptionTool(ctx, cfg, watches, in) - }) - - mcp.AddTool(s, &mcp.Tool{ - Name: toolReadSubscription, - Description: "Read a bounded raw batch of messages from a pod-local background watch by cursor. " + - "Use this as a debug or detail path when raw payloads are needed; prefer dsx_exchange_subscription_status for scalable update summaries. " + - "This reads only the owning pod's in-memory ring buffer; if the session or pod-local state was lost, the tool returns subscription_not_found or session_lost.", - }, func(ctx context.Context, _ *mcp.CallToolRequest, in readSubscriptionInput) (*mcp.CallToolResult, watchReadOutput, error) { - return readSubscriptionTool(ctx, cfg, watches, in) - }) - - mcp.AddTool(s, &mcp.Tool{ - Name: toolStatusSubscription, - Description: "Return pod-local status, counters, watermarks, expiry, last error, and bounded per-topic update summaries for a background watch owned by the current Mcp-Session-Id.", - }, func(ctx context.Context, _ *mcp.CallToolRequest, in subscriptionIDInput) (*mcp.CallToolResult, watchStatusOutput, error) { - return statusSubscriptionTool(ctx, cfg, watches, in) - }) - - mcp.AddTool(s, &mcp.Tool{ - Name: toolStopSubscription, - Description: "Stop a pod-local background watch owned by the current Mcp-Session-Id, disconnect its MQTT stream, and release the local buffer.", - }, func(ctx context.Context, _ *mcp.CallToolRequest, in subscriptionIDInput) (*mcp.CallToolResult, watchStopOutput, error) { - return stopSubscriptionTool(ctx, cfg, watches, in) - }) -} - -func startSubscriptionTool(ctx context.Context, cfg Config, watches *watchManager, in startSubscriptionInput) (*mcp.CallToolResult, watchStartOutput, error) { - start := time.Now() - caller := auth.FromContext(ctx) - - topicFilter, err := resolveSubscriptionTopic(cfg, in) - if err != nil { - recordWatchAudit(toolStartSubscription, caller, "", "", 0, start, err) - return toolErrorFromErr[watchStartOutput](err) - } - - out, err := watches.start(watchStartRequest{ - Caller: caller, - TopicFilter: topicFilter, - TTLS: in.TTLSeconds, - BufferMaxMessages: in.BufferMaxMessages, - BufferMaxBytes: in.BufferMaxBytes, - }) - recordWatchAudit(toolStartSubscription, caller, out.SubscriptionID, topicFilter, 0, start, err) - if err != nil { - return toolErrorFromErr[watchStartOutput](err) - } - return toolOK("started subscription "+out.SubscriptionID, out) -} - -func readSubscriptionTool(ctx context.Context, cfg Config, watches *watchManager, in readSubscriptionInput) (*mcp.CallToolResult, watchReadOutput, error) { - start := time.Now() - caller := auth.FromContext(ctx) - out, err := watches.read(watchReadRequest{ - Caller: caller, - SubscriptionID: in.SubscriptionID, - Cursor: in.Cursor, - MaxMessages: in.MaxMessages, - MaxBytes: in.MaxBytes, - }) - recordWatchAudit(toolReadSubscription, caller, in.SubscriptionID, "", out.Count, start, err) - if err != nil { - return toolErrorFromErr[watchReadOutput](err) - } - return toolOK(fmt.Sprintf("read %d subscription messages", out.Count), out) -} - -func statusSubscriptionTool(ctx context.Context, cfg Config, watches *watchManager, in subscriptionIDInput) (*mcp.CallToolResult, watchStatusOutput, error) { - start := time.Now() - caller := auth.FromContext(ctx) - out, err := watches.status(watchStatusRequest{ - Caller: caller, - SubscriptionID: in.SubscriptionID, - }) - recordWatchAudit(toolStatusSubscription, caller, in.SubscriptionID, out.TopicFilter, 0, start, err) - if err != nil { - return toolErrorFromErr[watchStatusOutput](err) - } - return toolOK("subscription status "+out.Status, out) -} - -func stopSubscriptionTool(ctx context.Context, cfg Config, watches *watchManager, in subscriptionIDInput) (*mcp.CallToolResult, watchStopOutput, error) { - start := time.Now() - caller := auth.FromContext(ctx) - out, err := watches.stop(watchStopRequest{ - Caller: caller, - SubscriptionID: in.SubscriptionID, - }) - recordWatchAudit(toolStopSubscription, caller, in.SubscriptionID, "", 0, start, err) - if err != nil { - return toolErrorFromErr[watchStopOutput](err) - } - return toolOK("stopped subscription "+out.SubscriptionID, out) -} - -func resolveSubscriptionTopic(cfg Config, in startSubscriptionInput) (string, error) { - topicFilter := strings.TrimSpace(in.TopicFilter) - hasSelector := selectorPresent(in.Selector) - if topicFilter != "" && hasSelector { - return "", &mqttbus.BusError{Code: mqttbus.CodeInvalidArgument, Message: "use either topic_filter or selector, not both"} - } - if topicFilter != "" { - if err := mqttbus.ValidateTopicFilter(topicFilter); err != nil { - return "", err - } - return topicFilter, nil - } - if !hasSelector { - return "", &mqttbus.BusError{Code: mqttbus.CodeInvalidArgument, Message: "topic_filter or selector is required"} - } - idx, err := schemaindex.Default() - if err != nil { - return "", &mqttbus.BusError{Code: mqttbus.CodeInternalError, Message: err.Error()} - } - matches := idx.Search(schemaindex.SearchOptions{ - Domain: in.Selector.Domain, - Query: in.Selector.Query, - Role: in.Selector.Role, - ObjectType: in.Selector.ObjectType, - PointType: in.Selector.PointType, - OperationAction: in.Selector.OperationAction, - Limit: cfg.FindTopicsMaxLimit, - }) - if len(matches) == 0 { - return "", &mqttbus.BusError{Code: codeSchemaTopicNotFound, Message: "selector did not match any AsyncAPI topic"} - } - if len(matches) > 1 { - return "", &mqttbus.BusError{Code: codeSchemaTopicAmbiguous, Message: fmt.Sprintf("selector matched %d AsyncAPI topics; call dsx_exchange_find_topics and choose a topic_filter", len(matches))} - } - if err := mqttbus.ValidateTopicFilter(matches[0].TopicFilter); err != nil { - return "", err - } - return matches[0].TopicFilter, nil -} - -func selectorPresent(in findTopicsInput) bool { - return strings.TrimSpace(in.Domain) != "" || - strings.TrimSpace(in.Query) != "" || - strings.TrimSpace(in.Role) != "" || - strings.TrimSpace(in.ObjectType) != "" || - strings.TrimSpace(in.PointType) != "" || - strings.TrimSpace(in.OperationAction) != "" -} - -func toolOK[T any](summary string, out T) (*mcp.CallToolResult, T, error) { - raw, _ := json.Marshal(out) - return &mcp.CallToolResult{ - Content: []mcp.Content{ - &mcp.TextContent{Text: summary}, - &mcp.TextContent{Text: string(raw)}, - }, - }, out, nil -} - -func toolError[T any](code, message string) (*mcp.CallToolResult, T, error) { - return toolErrorWithRetry[T](code, message, 0) -} - -func toolErrorFromErr[T any](err error) (*mcp.CallToolResult, T, error) { - return toolErrorWithRetry[T](mqttbus.ErrorCode(err), publicMessage(err), retryAfterSeconds(err)) -} - -func toolErrorWithRetry[T any](code, message string, retryAfter int) (*mcp.CallToolResult, T, error) { - var zero T - if code == "" { - code = mqttbus.CodeInternalError - } - body := structuredError{Error: errorBody{Code: code, Message: message, RetryAfterSeconds: retryAfter}} - raw, _ := json.Marshal(body) - return &mcp.CallToolResult{ - IsError: true, - Content: []mcp.Content{&mcp.TextContent{Text: string(raw)}}, - }, zero, nil -} - -func recordWatchAudit(tool string, caller auth.Caller, subscriptionID, topicFilter string, messages int, start time.Time, err error) { - code := errorCode(err) - duration := time.Since(start) - decision := "allowed" - if code != "" { - decision = "error" - } - slog.Info("watch tool invocation", - "audit", true, - "tool", tool, - "caller_tenant", caller.Tenant, - "caller_issuer", caller.Issuer, - "caller_subject", caller.Subject, - "caller_spiffe_id", caller.SpiffeID, - "mcp_session_id", caller.SessionID, - "bearer_present", caller.Bearer != "", - "subscription_id", subscriptionID, - "topic_filter", topicFilter, - "decision", decision, - "message_count", messages, - "duration_ms", duration.Milliseconds(), - "error_code", code, - ) -} From df59422087c20de5fe8b58899d4cfe94bd792712 Mon Sep 17 00:00:00 2001 From: Daniyal Rana Date: Mon, 22 Jun 2026 01:30:35 -0500 Subject: [PATCH 07/27] docs(mcp): document stateless subscribe UX Signed-off-by: Daniyal Rana --- mcp/dsx-exchange-mcp/Architecture.md | 459 +++++++++++------- mcp/dsx-exchange-mcp/README.md | 27 +- mcp/dsx-exchange-mcp/docs/current-v1-scope.md | 36 +- 3 files changed, 304 insertions(+), 218 deletions(-) diff --git a/mcp/dsx-exchange-mcp/Architecture.md b/mcp/dsx-exchange-mcp/Architecture.md index 58bc8bc..7db0016 100644 --- a/mcp/dsx-exchange-mcp/Architecture.md +++ b/mcp/dsx-exchange-mcp/Architecture.md @@ -1,10 +1,17 @@ # dsx-exchange-mcp Architecture -This document is for a new developer trying to understand how the code works. It is intentionally code-centric: which files own which behavior, how a request flows through the service, and how this MCP server plugs into Agent Gateway / Latinum MCP Gateway. +This document is for a new developer trying to understand how the code works. It +is intentionally code-centric: which files own which behavior, how a request flows +through the service, and how configuration shapes runtime behavior. + +The server is designed to run **standalone**. Any HTTP MCP client that speaks +Streamable HTTP can call it directly at `/mcp`. AgentGateway (or any +other reverse proxy) is an optional front door for production aggregation and +coarse auth — not a requirement for the server to function. ## Big Picture -`dsx-exchange-mcp` is an MCP server that exposes DSX exchange data over MCP. +`dsx-exchange-mcp` is an MCP server that exposes DSX Exchange data over MCP. At runtime it does three main things: @@ -12,31 +19,49 @@ At runtime it does three main things: 2. Exposes embedded exchange specs as MCP resources. 3. Exposes schema exploration and bounded MQTT/NATS reads as MCP tools. -In production it is expected to sit behind Gateway: +Standalone deployment shape: ```text -MCP client - -> Latinum / Agent Gateway - -> Kubernetes Service: dsx-exchange-mcp - -> dsx-exchange-mcp pod - -> MQTT/NATS broker +MCP client or auth-capable proxy + -> HTTP POST /mcp + -> dsx-exchange-mcp process or pod + -> MQTT/NATS broker (when a broker-backed tool runs) ``` -The MCP server does not implement topic authorization itself. The caller JWT is passed through Gateway, forwarded to this service, then used as the MQTT password when connecting to NATS/MQTT. NATS auth callout / ACLs enforce topic access. +The same binary and container work in all of these placements: + + +| Placement | Typical use | +| -------------------------- | ----------------------------------------------------------- | +| Local process (`make run`) | Dev, prompt eval, direct MCP client checks | +| Docker container | Portable standalone service | +| Kubernetes Deployment | Production or local Kind backend | +| Behind a gateway | Optional — gateway forwards the same HTTP contract upstream | + + +The server does not implement topic authorization itself. It defers to the event bus. + +In `jwt_passthrough` mode it takes the caller bearer from the incoming HTTP +request and presents it to the broker as the MQTT password. NATS auth-callout / +ACLs enforce topic access. + +In `noauth` mode it sends no MQTT credentials, matching local Event Bus +deployments that allow anonymous fallback. ## Request Flow -For an MCP tool call such as `dsx_exchange_subscribe`: +Every MCP request hits the same HTTP entrypoint regardless of who sits in front +of the server. The upstream caller — desktop MCP client, test harness, load +generator, auth-capable proxy, or gateway — is responsible for attaching +credentials to the HTTP request. This service reads those headers and does not +mint, refresh, or store tokens. + +### Broker-backed tool (e.g. `dsx_exchange_subscribe`) ```text -client - sends MCP request with JWT - | - v -gateway - validates identity - forwards Authorization: Bearer - forwards x-mcp-* identity headers +MCP caller + POST /mcp with optional Authorization: Bearer + optional identity headers (x-mcp-*, Mcp-Session-Id) | v cmd/dsx-exchange-mcp/main.go @@ -44,7 +69,7 @@ cmd/dsx-exchange-mcp/main.go wraps handler with auth.Middleware | v -internal/auth/context.go +internal/auth/caller.go extracts bearer + identity headers into request context | v @@ -55,8 +80,8 @@ internal/server/tools.go | v internal/mqttbus/client.go - creates MQTT client - uses bearer token as MQTT password + creates short-lived MQTT client + uses bearer as MQTT password (jwt_passthrough) or no credentials (noauth) subscribes to topic filter collects bounded messages | @@ -66,24 +91,57 @@ internal/server/tools.go returns MCP result ``` -For an MCP resource read, the flow stops inside `internal/specs`; no MQTT connection is opened. +### Schema-only paths (resources and discovery tools) + +For MCP resource reads (`dsx-exchange://specs/...`) and schema tools +(`dsx_exchange_find_topics`, `dsx_exchange_describe_topic`), the flow stops +inside `internal/specs` or `internal/schemaindex`. No MQTT connection is +opened and no bearer is required. + +## Deployment Modes + +### Standalone (direct `/mcp`) + +The primary integration surface is Streamable HTTP on `MCP_ADDR` (default +`:8080`): + +```text +http://:8080/mcp +``` + +Configure any MCP client that supports Streamable HTTP with that URL. For +broker-backed tools in `jwt_passthrough` mode, the client (or an adjacent token +proxy) must send `Authorization: Bearer ` on **each** MCP request. The +server does not cache credentials across requests. + +Local Kind deploys this way by default: port-forward the backend Service and +point the client at `http://127.0.0.1:18080/mcp` with `MCP_MQTT_AUTH_MODE=noauth`. + +### Optional gateway front door + +In multi-upstream production topologies, a gateway may sit in front of one or +more MCP backends. From this server's perspective nothing changes: it still +accepts the same `/mcp` requests and reads the same headers. See +[Optional Gateway Integration](#optional-gateway-integration) below. ## File Map -| Path | Responsibility | -| --- | --- | -| `cmd/dsx-exchange-mcp/main.go` | Process entrypoint. Reads env config, builds the MCP server, registers HTTP routes, starts `ListenAndServe`. | -| `internal/server/server.go` | Creates the MCP server instance and registers tools/resources. | -| `internal/server/tools.go` | Defines MCP tools, parses tool inputs, describes schema topics, enforces bounds, calls MQTT collection, and emits audit logs. | -| `internal/server/resources.go` | Defines MCP resources backed by embedded DSX specs. | -| `internal/specs/specs.go` | Exposes raw spec resources from the embedded `schemas/` tree. | -| `internal/schemaindex/index.go` | Parses AsyncAPI channel/message/operation primitives into a topic catalogue for schema exploration tools. | -| `schemas/` | Generated copy of the monorepo root `schemas/`, embedded into the binary by `schemas/embed.go`. | -| `internal/mqttbus/client.go` | MQTT/NATS client logic: connect, subscribe, collect messages, classify broker errors. | -| `internal/auth/context.go` | Pulls Gateway-provided bearer and identity headers into Go context. | -| `deploy/helm/dsx-exchange-mcp/templates/deployment.yaml` | Kubernetes Deployment: env vars, probes, security context, runtime class. | -| `deploy/helm/dsx-exchange-mcp/templates/service.yaml` | Kubernetes Service that Gateway discovers/routes to. | -| `deploy/helm/dsx-exchange-mcp/values.yaml` | Default deploy-time configuration. | + +| Path | Responsibility | +| -------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------- | +| `cmd/dsx-exchange-mcp/main.go` | Process entrypoint. Reads env config, builds the MCP server, registers HTTP routes, starts `ListenAndServe`. | +| `internal/server/server.go` | Creates the MCP server instance and registers tools/resources. | +| `internal/server/tools.go` | Defines MCP tools, parses tool inputs, describes schema topics, enforces bounds, calls MQTT collection, and emits audit logs. | +| `internal/server/resources.go` | Defines MCP resources backed by embedded DSX specs. | +| `internal/specs/specs.go` | Exposes raw spec resources from the embedded `schemas/` tree. | +| `internal/schemaindex/index.go` | Parses AsyncAPI channel/message/operation primitives into a topic catalogue for schema exploration tools. | +| `schemas/` | Generated copy of the monorepo root `schemas/`, embedded into the binary by `schemas/embed.go`. | +| `internal/mqttbus/client.go` | MQTT/NATS client logic: connect, subscribe, collect messages, classify broker errors. | +| `internal/auth/caller.go` | Pulls caller bearer and optional identity headers from the HTTP request into Go context. | +| `deploy/helm/dsx-exchange-mcp/templates/deployment.yaml` | Kubernetes Deployment: env vars, probes, security context, runtime class. | +| `deploy/helm/dsx-exchange-mcp/templates/service.yaml` | Kubernetes Service exposing the MCP port (optionally annotated for gateway discovery). | +| `deploy/helm/dsx-exchange-mcp/values.yaml` | Default deploy-time configuration. | + ## Process Startup @@ -101,6 +159,7 @@ cfg := server.Config{ MQTT: mqttbus.Config{ BrokerURL: natsURL, Username: envOr("MQTT_USERNAME", mqttbus.DefaultUsername), + AuthMode: mqttbus.AuthMode(envOr("MCP_MQTT_AUTH_MODE", string(mqttbus.DefaultAuthMode))), }, DefaultMaxMessages: intEnvOr("MCP_DEFAULT_MAX_MESSAGES", 100), MaxMessages: intEnvOr("MCP_MAX_MESSAGES", 1000), @@ -115,14 +174,16 @@ Then it creates the MCP server and attaches it to HTTP: srv := server.Build(cfg) handler := mcp.NewStreamableHTTPHandler(func(*http.Request) *mcp.Server { return srv -}, nil) +}, &mcp.StreamableHTTPOptions{Stateless: true, JSONResponse: true}) mux.Handle("/mcp", auth.Middleware(handler)) mux.HandleFunc("/healthz/live", healthOK) mux.HandleFunc("/healthz/ready", healthOK) ``` -Important detail: this service uses MCP Streamable HTTP, but the current tools are bounded request/response calls. It does not currently maintain long-lived background subscriptions for clients. +Important detail: this service uses stateless MCP Streamable HTTP. Each tool +call is a bounded request/response operation. It does not currently maintain +long-lived background subscriptions for clients. ## MCP Server Construction @@ -146,43 +207,87 @@ The same `*mcp.Server` is returned for each HTTP request: // context injected by auth.Middleware. ``` -That means per-caller information must not be stored globally on the server object. Caller-specific data flows through `context.Context`. +That means per-caller information must not be stored globally on the server +object. Caller-specific data flows through `context.Context`. + +## Auth And Caller Credentials + +Authentication is split between **HTTP request headers** (what this server +reads) and **broker enforcement** (what auth-callout decides at MQTT CONNECT / +SUBSCRIBE). + +This server is not the identity policy engine. It extracts credentials from +the incoming HTTP request and, for broker-backed tools, delegates them to the +broker. Whoever calls `/mcp` — client, proxy, or gateway — must supply the +headers below. -## Auth And JWT Passthrough +### HTTP contract -`internal/auth/context.go` extracts identity from the incoming HTTP request. -Gateway is expected to forward: +| Header | Required | Used for | +| ----------------------------- | ---------------------------------------------- | ------------------------------------------------------------------------ | +| `Authorization: Bearer ` | Required for broker tools in `jwt_passthrough` | Delegated credential presented as MQTT password | +| `Mcp-Session-Id` | Optional | Session correlation in audit logs; relevant when a gateway pins sessions | +| `x-mcp-tenant` | Optional | Audit label | +| `x-mcp-issuer` | Optional | Audit label | +| `x-mcp-sub` | Optional | Audit label | +| `x-mcp-spiffe-id` | Optional | Audit label | -| Header | Used for | -| --- | --- | -| `Authorization: Bearer ` | Delegated credential used as MQTT password. | -| `x-mcp-tenant` | Audit label. | -| `x-mcp-issuer` | Audit label. | -| `x-mcp-sub` | Audit label. | -| `x-mcp-spiffe-id` | Audit label. | -The middleware: +The middleware in `internal/auth/caller.go`: ```go caller := Caller{ - Bearer: bearerFromHeader(r.Header.Get("Authorization")), - Tenant: r.Header.Get("x-mcp-tenant"), - Issuer: r.Header.Get("x-mcp-issuer"), - Subject: r.Header.Get("x-mcp-sub"), - SpiffeID: r.Header.Get("x-mcp-spiffe-id"), + Bearer: bearerFromHeader(r.Header.Get("Authorization")), + SessionID: r.Header.Get("Mcp-Session-Id"), + Tenant: r.Header.Get("x-mcp-tenant"), + Issuer: r.Header.Get("x-mcp-issuer"), + Subject: r.Header.Get("x-mcp-sub"), + SpiffeID: r.Header.Get("x-mcp-spiffe-id"), } -r = r.WithContext(context.WithValue(r.Context(), ctxKey{}, caller)) +r = r.WithContext(WithCaller(r.Context(), caller)) ``` -The code comment describes the intended trust boundary: +The bearer is never accepted as a tool argument and is never logged. Audit logs +record only `bearer_present` plus the optional identity labels. -```go -// The raw bearer is used only as the delegated credential for the MQTT/NATS -// password. The x-mcp-* fields are audit labels emitted by gateway ext_authz. +### MQTT auth modes + +Controlled by `MCP_MQTT_AUTH_MODE`: + + +| Mode | HTTP bearer | MQTT CONNECT | +| --------------------------- | -------------------------------- | ----------------------------------------------- | +| `jwt_passthrough` (default) | Required for broker-backed tools | `username=`, `password=` | +| `noauth` | Ignored for MQTT | No username or password | + + +Broker-backed tools in `jwt_passthrough` return structured `missing_bearer` +when the request has no bearer. Schema tools work without a bearer in either +mode. + +### Credential path to the broker + +```text +HTTP Authorization: Bearer + -> auth.Middleware stores bearer in request context + -> mqttbus.Collect receives caller.Bearer + -> Paho MQTT SetPassword(bearer) + -> NATS auth-callout validates token and enforces topic ACLs ``` -So this service is not the main identity policy engine. It preserves identity for audit and delegates topic authorization to the broker by connecting with the caller token. +Responsibility split: + + +| Layer | Responsibility | +| ------------------------------- | --------------------------------------------------------------------------------- | +| MCP caller / proxy / gateway | Obtain and attach caller credentials on each HTTP request | +| `dsx-exchange-mcp` | Extract credentials, translate MCP tools to embedded specs and bounded MQTT reads | +| NATS/MQTT broker + auth-callout | Authenticate the delegated token (or noauth profile) and enforce topic ACLs | + + +For gateway-specific auth interactions when a gateway is deployed, see +`docs/gateway-auth-interactions.md`. ## MCP Tools @@ -190,11 +295,14 @@ Tool registration lives in `internal/server/tools.go`. Current tools: -| Tool | Purpose | -| --- | --- | -| `dsx_exchange_describe_topic` | Describe the AsyncAPI channel matching a topic filter, including payload shape, retained/live behavior, examples, and related metadata/value topics. | -| `dsx_exchange_subscribe` | Subscribe to a topic filter and collect a bounded batch of live messages. | -| `dsx_exchange_read_retained` | Subscribe briefly and return retained messages for a topic filter. | + +| Tool | Purpose | MQTT | +| ----------------------------- | --------------------------------------------------------- | ---- | +| `dsx_exchange_find_topics` | Search embedded AsyncAPI index for relevant topics | No | +| `dsx_exchange_describe_topic` | Describe channel schema, retained/live behavior, examples | No | +| `dsx_exchange_subscribe` | Subscribe and collect a bounded batch of live messages | Yes | +| `dsx_exchange_read_retained` | Drain retained messages for a topic filter | Yes | + The subscribe tool is registered like this: @@ -225,7 +333,8 @@ res, err := mqttbus.Collect(ctx, cfg.MQTT, caller.Bearer, topicFilter, mqttbus.C }) ``` -If a tool fails, the service returns a structured MCP error result rather than a raw Go error: +If a tool fails, the service returns a structured MCP error result rather than a +raw Go error: ```go return &mcp.CallToolResult{ @@ -234,7 +343,8 @@ return &mcp.CallToolResult{ }, nil, nil ``` -This matters for clients: the MCP transport request may succeed while the tool result itself is an error. +This matters for clients: the MCP transport request may succeed while the tool +result itself is an error. ## MCP Resources @@ -278,9 +388,8 @@ sync-specs: cp -R $(SCHEMA_SRC)/. schemas/ ``` -Resource calls are therefore local file reads from embedded data. They do not call NATS/MQTT. - -`dsx_exchange_describe_topic` also does not call NATS/MQTT. It reads the embedded AsyncAPI catalogue through `internal/schemaindex`. +Resource calls are therefore local file reads from embedded data. They do not +call NATS/MQTT. ## MQTT/NATS Client Behavior @@ -292,7 +401,7 @@ The default username is: const DefaultUsername = "oauthtoken" ``` -`Collect` requires a bearer token: +In `jwt_passthrough` mode, `Collect` requires a bearer token: ```go if strings.TrimSpace(bearer) == "" { @@ -320,16 +429,19 @@ token := c.Subscribe(topicFilter, 0, nil) The collection loop stops for bounded reasons: -| Stop reason | Meaning | -| --- | --- | -| `max_messages` | Hit requested or configured message count. | -| `max_duration` | Hit requested or configured duration. | -| `retained_idle` | Retained-read mode saw no more retained messages for the idle window. | -| `max_result_bytes` | Payload would exceed configured response size. | -| `client_cancelled` | Request context was cancelled. | -| `completed` | Normal completion path. | -Payload conversion is also handled here. UTF-8 payloads are returned as strings; non-UTF-8 payloads are base64 encoded: +| Stop reason | Meaning | +| ------------------ | --------------------------------------------------------------------- | +| `max_messages` | Hit requested or configured message count. | +| `max_duration` | Hit requested or configured duration. | +| `retained_idle` | Retained-read mode saw no more retained messages for the idle window. | +| `max_result_bytes` | Payload would exceed configured response size. | +| `client_cancelled` | Request context was cancelled. | +| `completed` | Normal completion path. | + + +Payload conversion is also handled here. UTF-8 payloads are returned as strings; +non-UTF-8 payloads are base64 encoded: ```go if utf8.Valid(payload) { @@ -341,103 +453,18 @@ if utf8.Valid(payload) { } ``` -### Current Streaming Boundary - -`internal/mqttbus/client.go` also has a lower-level `Stream` function: - -```go -// Stream opens an MQTT subscription and invokes onMessage for every received -// message until the context is cancelled or a bound is reached. It is intended -// for async task workers that need to persist messages outside this package. -``` - -That is scaffolding for a future async/background watch design. It is not currently registered as an MCP tool. The current MQTT data tools collect bounded batches inside the request lifecycle. - -## Gateway Integration - -In production, MCP clients should normally talk to Gateway, not directly to the pod. +### MQTT collection boundary -The intended Gateway-facing shape is: - -```text -MCP client - -> Gateway /mcp - -> upstream route for dsx-exchange-mcp - -> Kubernetes Service dsx-exchange-mcp: - -> pod /mcp -``` - -This repo's Helm Service advertises the MCP port: - -```yaml -ports: - - name: {{ .Values.service.portName }} - port: {{ .Values.service.port }} - targetPort: mcp - protocol: TCP - appProtocol: agentgateway.dev/mcp -``` - -The important field is: - -```yaml -appProtocol: agentgateway.dev/mcp -``` - -That tells Gateway discovery that this service port speaks MCP. - -A Gateway upstream entry is expected to target this service by service name, namespace, labels, port, and pod selector. The README shows the shape: - -```yaml -upstreams: - - serviceName: dsx-exchange-mcp - portName: mcp - namespace: mcp-backends - serviceLabels: - app: dsx-exchange-mcp - port: 8080 - podSelector: - app: dsx-exchange-mcp -``` - -In multi-upstream Gateway deployments, tool names may be exposed with an upstream prefix. For example, the local tool `dsx_exchange_subscribe` may appear to an external client as something like: - -```text -dsx-exchange-mcp-mcp_dsx_exchange_subscribe -``` - -The exact external name depends on Gateway's upstream naming behavior. - -### JWT Passthrough Contract - -The service expects Gateway to forward the caller token: - -```text -Authorization: Bearer -``` - -The service does not exchange this token. It passes it to MQTT/NATS as the password: - -```text -Gateway-validated JWT - -> Authorization header to dsx-exchange-mcp - -> auth.Middleware stores bearer in context - -> mqttbus.Collect receives caller.Bearer - -> Paho MQTT SetPassword(bearer) - -> NATS auth callout / ACL policy -``` - -This gives a clean responsibility split: - -| Component | Responsibility | -| --- | --- | -| Gateway | Validate incoming identity, route MCP traffic, forward delegated identity. | -| `dsx-exchange-mcp` | Translate MCP resources/tools to local embedded specs and MQTT reads. | -| NATS/MQTT broker | Authenticate delegated token and enforce topic ACLs. | +`internal/mqttbus/client.go` exposes `Collect` for bounded subscribe/read flows. +Each tool call creates a temporary MQTT client, collects messages until a limit +or timeout, then disconnects. There is no long-lived server-side subscription +state in the current public MCP surface. ## Kubernetes Deployment -The Helm chart under `deploy/helm/dsx-exchange-mcp` owns production deployment shape. +The Helm chart under `deploy/helm/dsx-exchange-mcp` deploys the standalone +server as its own Deployment and Service. Gateway registration is optional and +configured in the gateway chart, not here. Default values include two replicas and the NATS/MQTT endpoint: @@ -447,6 +474,7 @@ replicaCount: 2 natsURL: tcp://nats.nats.svc:1883 mqtt: + authMode: jwt_passthrough username: oauthtoken connectTimeoutSeconds: 5 subscribeTimeoutSeconds: 5 @@ -460,6 +488,8 @@ The Deployment maps those values into environment variables: value: ":8080" - name: NATS_URL value: {{ .Values.natsURL | quote }} +- name: MCP_MQTT_AUTH_MODE + value: {{ .Values.mqtt.authMode | quote }} - name: MQTT_USERNAME value: {{ .Values.mqtt.username | quote }} - name: MCP_MAX_MESSAGES @@ -474,11 +504,11 @@ The chart also configures health probes: livenessProbe: httpGet: path: /healthz/live - port: http + port: mcp readinessProbe: httpGet: path: /healthz/ready - port: http + port: mcp ``` And a locked-down runtime profile: @@ -498,7 +528,9 @@ The default `values.yaml` also sets: runtimeClassName: kata ``` -That means pods are intended to run with the configured Kata runtime class in the target cluster. +Local Kind overrides in `values.kind.yaml` use `MCP_MQTT_AUTH_MODE=noauth` and +point at the in-cluster Event Bus broker so the backend can be exercised without +a gateway or bearer token. ## Observability @@ -536,7 +568,8 @@ slog.Info("mcp tool call", ) ``` -These logs are where you correlate Gateway identity, requested topic filter, broker decision, result size, and error code. +Use these logs to correlate caller identity labels, requested topic filter, +broker decision, result size, and error code. ## Local Development @@ -553,6 +586,53 @@ test: go test ./... ``` +Direct local path: + +```text +make run +# configure MCP client with http://127.0.0.1:8080/mcp +``` + +Kind path (Event Bus + MCP backend, no gateway): + +```text +make -C local skaffold-run +make port-forward-kind +# configure MCP client with http://127.0.0.1:18080/mcp +``` + +## Optional Gateway Integration + +When deployed behind a Latinum MCP Gateway, this server is one upstream backend +among potentially many. The gateway validates caller JWTs, applies coarse MCP +authorization, and forwards the original HTTP headers unchanged. From the +server's perspective the request flow is identical to a direct client call. + +```text +MCP client + -> Gateway /mcp + -> Kubernetes Service dsx-exchange-mcp: + -> pod /mcp +``` + +The Helm Service optionally advertises MCP to gateway discovery: + +```yaml +ports: + - name: mcp + port: 8080 + targetPort: mcp + appProtocol: agentgateway.dev/mcp +``` + +A gateway upstream entry targets this service by name, namespace, labels, port, +and pod selector. In multi-upstream gateway deployments, tool names may appear +with an upstream prefix (for example +`dsx-exchange-mcp-mcp_dsx_exchange_subscribe`). The exact external name depends +on gateway upstream naming. + +See `docs/gateway-auth-interactions.md` for the full gateway ↔ upstream ↔ broker +auth matrix. ## What To Change For Common Tasks @@ -564,7 +644,9 @@ Start in: internal/server/tools.go ``` -Add the tool registration next to the existing `mcp.AddTool` calls. If the tool touches MQTT, prefer adding focused behavior in `internal/mqttbus` rather than embedding client logic in the server layer. +Add the tool registration next to the existing `mcp.AddTool` calls. If the tool +touches MQTT, prefer adding focused behavior in `internal/mqttbus` rather than +embedding client logic in the server layer. ### Change topic validation or MQTT error handling @@ -574,7 +656,8 @@ Start in: internal/mqttbus/client.go ``` -This file owns topic filter validation, connection setup, subscribe behavior, message conversion, and broker error classification. +This file owns topic filter validation, connection setup, subscribe behavior, +message conversion, and broker error classification. ### Add or change embedded specs @@ -593,7 +676,7 @@ internal/server/resources.go internal/schemaindex/index.go ``` -### Change Gateway-facing deployment metadata +### Change Service metadata for gateway discovery Start in: @@ -602,7 +685,9 @@ deploy/helm/dsx-exchange-mcp/templates/service.yaml deploy/helm/dsx-exchange-mcp/values.yaml ``` -Gateway discovery depends on the Service name, labels, port name, and `appProtocol`. +Only needed when registering the backend with an MCP gateway. Direct standalone +clients use the Service ClusterIP or a port-forward and do not depend on +`appProtocol`. ### Change runtime limits @@ -614,7 +699,8 @@ cmd/dsx-exchange-mcp/main.go internal/server/tools.go ``` -The chart sets deploy defaults, `main.go` reads env vars, and `tools.go` applies bounds per request. +The chart sets deploy defaults, `main.go` reads env vars, and `tools.go` applies +bounds per request. ## Current Design Boundaries @@ -623,7 +709,10 @@ The current implementation is intentionally thin: 1. It does not store durable watch state. 2. It does not maintain cross-pod subscription continuity. 3. It does not reimplement broker authorization. -4. It does not expose a long-lived async subscription API yet. +4. It does not expose a long-lived async subscription API. 5. It does not persist MQTT messages outside the request. +6. It does not mint, refresh, or cache caller JWTs — every broker-backed tool + call expects fresh credentials on the HTTP request. -That means a pod restart can interrupt an in-flight bounded tool call. Clients should retry tool calls. If future UX requires long-lived background watches, the likely next code boundary is to build around `mqttbus.Stream` with an explicit task/watch model and a bounded external or broker-backed message store. +That means a pod restart can interrupt an in-flight bounded tool call. Clients +should retry tool calls. diff --git a/mcp/dsx-exchange-mcp/README.md b/mcp/dsx-exchange-mcp/README.md index 8d2e1b2..2ff6875 100644 --- a/mcp/dsx-exchange-mcp/README.md +++ b/mcp/dsx-exchange-mcp/README.md @@ -30,10 +30,11 @@ return within configured message, duration, and byte limits: - `dsx_exchange_subscribe(topic_filter, max_messages, max_duration_s)` — subscribe and collect messages over a bounded window. Use this for live - values. For watch/listen/monitor requests, MCP clients that support - background tool calls should run this tool in the background so the main - agent can keep working. If background execution is unavailable, use short - sampling windows and repeat the call. + values. For live-value get/fetch/read/sample/watch/listen/monitor requests, + MCP clients that support background agent, subagent, task, or equivalent + execution should run this tool through that mechanism so the main chat can + keep working. If background execution is unavailable, use short sampling + windows and repeat the call. - `dsx_exchange_read_retained(topic_filter, max_messages)` — drain retained messages currently held by the broker. Use this for metadata; BMS values are not retained (republished on change every ~100 s). @@ -45,10 +46,11 @@ Why this split exists: MCP tool calls are fundamentally request/response. A long MQTT subscription inside one foreground tool call can tie up the MCP client while it waits for stream data, which is a poor fit for sparse or ongoing telemetry. The preferred stateless pattern is to use `dsx_exchange_subscribe` -with bounded limits and have agent runtimes run long sampling calls in the -background when they support that primitive. MCP Tasks or response streaming may -eventually provide a cleaner protocol-level answer, but those paths are still -experimental for this use case. The public v1 surface intentionally avoids +with bounded limits and have agent runtimes run long sampling calls through a +background agent, subagent, task, or equivalent mechanism when they support that +primitive. MCP Tasks or response streaming may eventually provide a cleaner +protocol-level answer, but those paths are still experimental for this use case. +The public v1 surface intentionally avoids server-side watch/listen/monitor state: one MQTT tool call creates a temporary client, subscribes for a finite window, returns bounded results, and disconnects. @@ -141,10 +143,10 @@ Health endpoints are served on the same listener: TLS trust is deployment configuration, not MCP tool input. For deployed-bus tests or production, mount the broker root CA and set `MQTT_TLS_CA_FILE`. -Agents provide bearer credentials through MCP request headers and tool -arguments only. In `noauth` local mode, do not provide a dummy token; the MQTT -client intentionally sends no username/password so the Event Bus noauth -fallback can match. +Agents provide bearer credentials through MCP request headers only, never tool +arguments. In `noauth` local mode, do not provide a dummy token; the MQTT client +intentionally sends no username/password so the Event Bus noauth fallback can +match. The public schema tree is copied from the monorepo root `schemas/` directory. Override the location with `SCHEMA_SRC=/path/to/schemas make sync-specs`. @@ -280,6 +282,7 @@ schema discovery plus finite bounded MQTT reads. - Schema repo — `gitlab-master.nvidia.com/ncp/dsx/event-bus/schema` - Current v1 scope — `docs/current-v1-scope.md` +- Gateway auth interactions — `docs/gateway-auth-interactions.md` - Load validation findings — `docs/load-testing.md` - MCP spec — https://modelcontextprotocol.io/specification/2025-06-18/ - Go SDK — https://github.com/modelcontextprotocol/go-sdk diff --git a/mcp/dsx-exchange-mcp/docs/current-v1-scope.md b/mcp/dsx-exchange-mcp/docs/current-v1-scope.md index 2bad47a..170049e 100644 --- a/mcp/dsx-exchange-mcp/docs/current-v1-scope.md +++ b/mcp/dsx-exchange-mcp/docs/current-v1-scope.md @@ -42,17 +42,10 @@ In scope: - Let the broker and auth-callout remain authoritative for topic ACL decisions. - Return structured tool errors for missing bearer, invalid topics, broker unavailability, auth failure, and ACL denial. -- Provide pod-local background watch tools: - - `dsx_exchange_start_subscription` - - `dsx_exchange_read_subscription` - - `dsx_exchange_subscription_status` - - `dsx_exchange_stop_subscription` -- Keep active watch state, MQTT connections, cursors, and raw ring buffers - pod-local and session-pinned. -- Use short TTLs, bounded buffers, per-session limits, per-pod limits, metrics, - and audit logs to keep this safe. -- Document that pod restart, pod eviction, rollout interruption, or MCP session - loss can end a watch and require the client to start a new one. +- For live-value get/fetch/read/sample/watch/listen/monitor UX, use repeated + bounded `dsx_exchange_subscribe` calls (client-side background + agent/subagent/task execution when the MCP host supports it), not server-side + subscription lifecycle tools. ## Explicitly Out Of Scope For Current V1 @@ -67,6 +60,9 @@ Do not treat these as current v1 gaps: - Implementing `dsx_exchange_summarize_subscription`. - Implementing `dsx_exchange_aggregate_subscription`. - Implementing `dsx_exchange_export_subscription`. +- Server-side watch/listen/monitor lifecycle tools + (`start_subscription`, `read_subscription`, `subscription_status`, + `stop_subscription`). - Implementing MCP notifications for watch events. - Making watches durable across pod restart or cross-pod failover. - Storing raw JWTs, refreshing caller tokens, or resuming MQTT clients without a @@ -79,13 +75,12 @@ branch useful or complete for its intended scope. ## Possible Later Work Aggregation is the most plausible next feature after this scope because it can -reduce high-volume streams into smaller operator-facing results. If added, it -should be introduced as a focused extension to the existing pod-local watch -model before adding distributed watch state. +reduce high-volume streams into smaller operator-facing results. -Durable watch state, external workers, cross-pod recovery, entitlement-driven -discovery filtering, graph construction, and export sinks should wait for clear -product demand or benchmark evidence. +Durable watch state, pod-local background subscription lifecycle tools, external +workers, cross-pod recovery, entitlement-driven discovery filtering, graph +construction, and export sinks should wait for clear product demand or benchmark +evidence. ## Completion Bar @@ -93,10 +88,9 @@ For this scope, the branch is complete enough when: - Default MCP unit tests pass. - Helm rendering/linting for the MCP chart passes. -- The MCP server can be deployed behind the gateway with stateful session - routing. -- A caller can discover schema topics, read retained metadata, collect bounded - live messages, and use start/read/status/stop background watches. +- The MCP server can be deployed behind the gateway. +- A caller can discover schema topics, read retained metadata, and collect bounded + live messages with `dsx_exchange_subscribe`. - Unauthorized MQTT topics fail through broker-backed structured errors instead of being treated as empty data. - Docs and examples describe the smaller v1 scope instead of implying the full From 95c942afcd968034f3898307f73dddae569f68b3 Mon Sep 17 00:00:00 2001 From: Daniyal Rana Date: Mon, 22 Jun 2026 11:23:31 -0500 Subject: [PATCH 08/27] docs(mcp): clarify admission limiter behavior Signed-off-by: Daniyal Rana --- mcp/dsx-exchange-mcp/internal/server/admission.go | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/mcp/dsx-exchange-mcp/internal/server/admission.go b/mcp/dsx-exchange-mcp/internal/server/admission.go index 9d1e968..803203a 100644 --- a/mcp/dsx-exchange-mcp/internal/server/admission.go +++ b/mcp/dsx-exchange-mcp/internal/server/admission.go @@ -9,6 +9,7 @@ type admissionLimiter struct { func newAdmissionLimiter(limit int) *admissionLimiter { if limit <= 0 { + // no limit, allow all tool-calls through return nil } return &admissionLimiter{ch: make(chan struct{}, limit)} @@ -16,22 +17,27 @@ func newAdmissionLimiter(limit int) *admissionLimiter { func (l *admissionLimiter) tryAcquire() bool { if l == nil { + // no limit, allow all tool-calls through return true } select { case l.ch <- struct{}{}: + // admit request into channel buffer return true default: + // no available slots in channel, reject request immediately return false } } func (l *admissionLimiter) release() { if l == nil { + // no limit, allow all tool-calls through return } select { case <-l.ch: + // release channel slot default: } } From ea0cac7e5a4ed5a037a0fd058f8876bc2a4b9c25 Mon Sep 17 00:00:00 2001 From: Daniyal Rana Date: Tue, 23 Jun 2026 13:48:36 -0500 Subject: [PATCH 09/27] refactor(mcp): split schema index implementation Signed-off-by: Daniyal Rana --- .../internal/schemaindex/index.go | 565 +----------------- .../internal/schemaindex/index_test.go | 119 +++- .../internal/schemaindex/match.go | 213 +++++++ .../internal/schemaindex/parse.go | 236 ++++++++ .../internal/schemaindex/topic_helpers.go | 144 +++++ .../internal/server/tools_test.go | 40 ++ 6 files changed, 769 insertions(+), 548 deletions(-) create mode 100644 mcp/dsx-exchange-mcp/internal/schemaindex/match.go create mode 100644 mcp/dsx-exchange-mcp/internal/schemaindex/parse.go create mode 100644 mcp/dsx-exchange-mcp/internal/schemaindex/topic_helpers.go diff --git a/mcp/dsx-exchange-mcp/internal/schemaindex/index.go b/mcp/dsx-exchange-mcp/internal/schemaindex/index.go index 7002107..4767492 100644 --- a/mcp/dsx-exchange-mcp/internal/schemaindex/index.go +++ b/mcp/dsx-exchange-mcp/internal/schemaindex/index.go @@ -1,19 +1,19 @@ // Copyright 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 +// Package schemaindex builds a lightweight, model-friendly index over embedded +// AsyncAPI channels. It intentionally extracts only the topic, payload, and +// relationship fields needed by DSX Exchange MCP schema tools; it is not a full +// AsyncAPI engine. package schemaindex import ( "errors" - "fmt" "io/fs" "path" - "sort" "strings" "sync" - "gopkg.in/yaml.v3" - "github.com/NVIDIA/dsx-exchange/mcp/dsx-exchange-mcp/schemas" ) @@ -22,9 +22,13 @@ type Index struct { } type SearchOptions struct { - Domain string - Query string - Role string + Domain string + Query string + // Role is a coarse topic role hint: + // "metadata"/"value": for BMS channels following path convention + // "event": fallback for channels not matching metadata/value + Role string + // ObjectType and PointType are BMS-oriented selectors. ObjectType string PointType string OperationAction string @@ -92,69 +96,6 @@ type RelatedTopic struct { TopicFilter string `json:"topic_filter"` } -type document struct { - AsyncAPI string `yaml:"asyncapi"` - Info info `yaml:"info"` - Channels map[string]channel `yaml:"channels"` - Operations map[string]operation `yaml:"operations"` - Components components `yaml:"components"` -} - -type info struct { - Title string `yaml:"title"` - Version string `yaml:"version"` - Description string `yaml:"description"` -} - -type channel struct { - Ref string `yaml:"$ref"` - Address string `yaml:"address"` - Description string `yaml:"description"` - Parameters map[string]parameter `yaml:"parameters"` - Messages map[string]messageRef `yaml:"messages"` -} - -type parameter struct { - Ref string `yaml:"$ref"` - Description string `yaml:"description"` - Enum []string `yaml:"enum"` -} - -type messageRef struct { - Ref string `yaml:"$ref"` - Name string `yaml:"name"` - Title string `yaml:"title"` - Summary string `yaml:"summary"` - Description string `yaml:"description"` - Payload map[string]any `yaml:"payload"` -} - -type message struct { - Name string `yaml:"name"` - Title string `yaml:"title"` - Summary string `yaml:"summary"` - Description string `yaml:"description"` - Payload map[string]any `yaml:"payload"` -} - -type operation struct { - Action string `yaml:"action"` - Summary string `yaml:"summary"` - Description string `yaml:"description"` - Channel reference `yaml:"channel"` - Messages []reference `yaml:"messages"` -} - -type reference struct { - Ref string `yaml:"$ref"` -} - -type components struct { - Messages map[string]message `yaml:"messages"` - Parameters map[string]parameter `yaml:"parameters"` - Schemas map[string]map[string]any `yaml:"schemas"` -} - var errMissingAsyncAPI = errors.New("missing asyncapi version") var ( @@ -248,6 +189,13 @@ func (idx *Index) Search(opts SearchOptions) []Topic { if role != "" && !matchesRole(topic, role) { continue } + // ObjectType/PointType filtering intentionally follows the BMS topic + // convention: + // + // BMS/v1/{publisher}/{Value|Metadata}/{objectType}/{pointType}/{tagPath} + // + // This keeps BMS discovery ergonomic without pretending every AsyncAPI + // schema has object/point semantics. if objectType != "" && (!matchesAddressValue(topic.Address, "objectType", objectType) || !parameterAllows(topic.Parameters, "objectType", objectType)) { continue } @@ -280,480 +228,3 @@ func (idx *Index) Search(opts SearchOptions) []Topic { sortTopics(out) return out } - -func parseDocument(name string, body []byte) (document, error) { - var doc document - if err := yaml.Unmarshal(body, &doc); err != nil { - return document{}, fmt.Errorf("parse %s: %w", name, err) - } - if strings.TrimSpace(doc.AsyncAPI) == "" { - return document{}, fmt.Errorf("parse %s: %w", name, errMissingAsyncAPI) - } - return doc, nil -} - -func docTopics(domain string, doc document) []Topic { - names := make([]string, 0, len(doc.Channels)) - for name := range doc.Channels { - names = append(names, name) - } - sort.Strings(names) - - topics := make([]Topic, 0, len(names)) - for _, name := range names { - ch := doc.Channels[name] - if ch.Address == "" { - continue - } - topics = append(topics, Topic{ - Domain: domain, - SpecTitle: doc.Info.Title, - SpecVersion: doc.Info.Version, - Channel: name, - Address: ch.Address, - TopicFilter: addressToFilter(ch.Address, nil), - Description: strings.TrimSpace(ch.Description), - RetainedLiveBehavior: retainedLiveBehavior(ch.Address), - Parameters: summarizeParameters(ch.Parameters, doc.Components.Parameters), - Messages: summarizeMessages(ch.Messages, doc.Components), - Operations: summarizeOperations(name, doc.Operations), - }) - } - return topics -} - -func summarizeParameters(params map[string]parameter, components map[string]parameter) []ParameterSummary { - names := make([]string, 0, len(params)) - for name := range params { - names = append(names, name) - } - sort.Strings(names) - - out := make([]ParameterSummary, 0, len(names)) - for _, name := range names { - p := params[name] - if p.Ref != "" { - if resolved, ok := components[refName(p.Ref)]; ok { - p = resolved - } - } - out = append(out, ParameterSummary{ - Name: name, - Description: strings.TrimSpace(p.Description), - Enum: append([]string{}, p.Enum...), - }) - } - return out -} - -func summarizeMessages(refs map[string]messageRef, components components) []MessageSummary { - names := make([]string, 0, len(refs)) - for name := range refs { - names = append(names, name) - } - sort.Strings(names) - - out := make([]MessageSummary, 0, len(names)) - for _, name := range names { - ref := refs[name] - msg := message{ - Name: ref.Name, - Title: ref.Title, - Summary: ref.Summary, - Description: ref.Description, - Payload: ref.Payload, - } - if ref.Ref != "" { - if resolved, ok := components.Messages[refName(ref.Ref)]; ok { - msg = resolved - } - } - out = append(out, MessageSummary{ - Name: firstNonEmpty(msg.Name, name), - Ref: ref.Ref, - Title: msg.Title, - Summary: msg.Summary, - Description: strings.TrimSpace(msg.Description), - Payload: summarizePayload(msg.Payload, components.Schemas), - }) - } - return out -} - -func summarizeOperations(channelName string, operations map[string]operation) []OperationSummary { - var out []OperationSummary - for name, op := range operations { - if refName(op.Channel.Ref) != channelName { - continue - } - out = append(out, OperationSummary{ - Name: name, - Action: op.Action, - Summary: op.Summary, - Description: strings.TrimSpace(op.Description), - }) - } - sort.Slice(out, func(i, j int) bool { - return out[i].Name < out[j].Name - }) - return out -} - -func summarizePayload(payload map[string]any, schemas map[string]map[string]any) PayloadShape { - if len(payload) == 0 { - return PayloadShape{} - } - if ref, _ := payload["$ref"].(string); ref != "" { - shape := summarizeSchema(schemas[refName(ref)]) - shape.Ref = ref - return shape - } - return summarizeSchema(payload) -} - -func summarizeSchema(schema map[string]any) PayloadShape { - if len(schema) == 0 { - return PayloadShape{} - } - shape := PayloadShape{ - Type: stringValue(schema["type"]), - Required: stringSlice(schema["required"]), - AllOf: refList(schema["allOf"]), - OneOf: refList(schema["oneOf"]), - } - props := mapValue(schema["properties"]) - names := make([]string, 0, len(props)) - for name := range props { - names = append(names, name) - } - sort.Strings(names) - for _, name := range names { - prop := mapValue(props[name]) - shape.Properties = append(shape.Properties, PropertySummary{ - Name: name, - Type: stringValue(prop["type"]), - Ref: stringValue(prop["$ref"]), - Description: strings.TrimSpace(stringValue(prop["description"])), - Enum: stringSlice(prop["enum"]), - }) - } - return shape -} - -func addressToFilter(address string, values map[string]string) string { - parts := strings.Split(address, "/") - for i, part := range parts { - name, ok := placeholderName(part) - if !ok { - continue - } - if v := strings.TrimSpace(values[name]); v != "" { - parts[i] = v - continue - } - if i == len(parts)-1 && strings.Contains(strings.ToLower(name), "path") { - parts[i] = "#" - } else { - parts[i] = "+" - } - } - return strings.Join(parts, "/") -} - -func matchesAddress(address, topic string) bool { - addressParts := strings.Split(strings.Trim(address, "/"), "/") - topicParts := strings.Split(strings.Trim(topic, "/"), "/") - for i, part := range addressParts { - if i >= len(topicParts) { - return false - } - if name, ok := placeholderName(part); ok { - if i == len(addressParts)-1 && strings.Contains(strings.ToLower(name), "path") { - return true - } - continue - } - if topicParts[i] == "+" || topicParts[i] == "#" { - continue - } - if part != topicParts[i] { - return false - } - } - return len(addressParts) == len(topicParts) || strings.HasSuffix(topic, "/#") -} - -func filtersOverlap(a, b string) bool { - ap := strings.Split(strings.Trim(a, "/"), "/") - bp := strings.Split(strings.Trim(b, "/"), "/") - for i := 0; i < len(ap) && i < len(bp); i++ { - if ap[i] == "#" || bp[i] == "#" { - return true - } - if ap[i] == "+" || bp[i] == "+" { - continue - } - if ap[i] != bp[i] { - return false - } - } - return len(ap) == len(bp) -} - -func matchesRole(topic Topic, role string) bool { - switch role { - case "metadata": - return strings.Contains(topic.Address, "/Metadata/") - case "value": - return strings.Contains(topic.Address, "/Value/") - case "event": - return !strings.Contains(topic.Address, "/Metadata/") && !strings.Contains(topic.Address, "/Value/") - default: - return strings.Contains(strings.ToLower(topic.RetainedLiveBehavior), role) || - strings.Contains(strings.ToLower(topic.Address), role) || - strings.Contains(strings.ToLower(topic.Channel), role) - } -} - -func matchesAddressValue(address, name, value string) bool { - value = strings.TrimSpace(value) - if value == "" { - return true - } - parts := strings.Split(strings.Trim(address, "/"), "/") - for i, part := range parts { - placeholder, ok := placeholderName(part) - if ok && placeholder == name { - return true - } - if !ok && strings.EqualFold(part, value) { - switch name { - case "objectType": - return i > 0 && (parts[i-1] == "Value" || parts[i-1] == "Metadata") - case "pointType": - return i > 1 && (parts[i-2] == "Value" || parts[i-2] == "Metadata") - default: - return true - } - } - } - return false -} - -func parameterAllows(params []ParameterSummary, name, value string) bool { - for _, param := range params { - if param.Name != name { - continue - } - if len(param.Enum) == 0 { - return true - } - for _, allowed := range param.Enum { - if strings.EqualFold(allowed, value) { - return true - } - } - return false - } - return true -} - -func matchesOperationAction(ops []OperationSummary, action string) bool { - for _, op := range ops { - if strings.EqualFold(op.Action, action) { - return true - } - } - return false -} - -func topicContains(topic Topic, query string) bool { - if strings.Contains(strings.ToLower(topic.Domain), query) || - strings.Contains(strings.ToLower(topic.SpecTitle), query) || - strings.Contains(strings.ToLower(topic.Channel), query) || - strings.Contains(strings.ToLower(topic.Address), query) || - strings.Contains(strings.ToLower(topic.Description), query) || - strings.Contains(strings.ToLower(topic.RetainedLiveBehavior), query) { - return true - } - for _, msg := range topic.Messages { - if strings.Contains(strings.ToLower(msg.Name), query) || - strings.Contains(strings.ToLower(msg.Title), query) || - strings.Contains(strings.ToLower(msg.Summary), query) || - strings.Contains(strings.ToLower(msg.Description), query) || - strings.Contains(strings.ToLower(msg.Payload.Ref), query) { - return true - } - } - for _, op := range topic.Operations { - if strings.Contains(strings.ToLower(op.Name), query) || - strings.Contains(strings.ToLower(op.Action), query) || - strings.Contains(strings.ToLower(op.Summary), query) || - strings.Contains(strings.ToLower(op.Description), query) { - return true - } - } - return false -} - -func valuesOrNil(values map[string]string) map[string]string { - clean := map[string]string{} - for k, v := range values { - if strings.TrimSpace(v) != "" { - clean[k] = v - } - } - if len(clean) == 0 { - return nil - } - return clean -} - -func inferParameters(address, topic string) map[string]string { - addressParts := strings.Split(strings.Trim(address, "/"), "/") - topicParts := strings.Split(strings.Trim(topic, "/"), "/") - out := map[string]string{} - for i, part := range addressParts { - name, ok := placeholderName(part) - if !ok || i >= len(topicParts) { - continue - } - if i == len(addressParts)-1 && strings.Contains(strings.ToLower(name), "path") { - out[name] = strings.Join(topicParts[i:], "/") - continue - } - out[name] = topicParts[i] - } - if len(out) == 0 { - return nil - } - return out -} - -func relatedTopics(address string, values map[string]string) []RelatedTopic { - switch { - case strings.Contains(address, "/Value/"): - return []RelatedTopic{{ - Role: "metadata", - TopicFilter: addressToFilter(strings.Replace(address, "/Value/", "/Metadata/", 1), values), - }} - case strings.Contains(address, "/Metadata/"): - return []RelatedTopic{{ - Role: "value", - TopicFilter: addressToFilter(strings.Replace(address, "/Metadata/", "/Value/", 1), values), - }} - default: - return nil - } -} - -func retainedLiveBehavior(address string) string { - switch { - case strings.Contains(address, "/Metadata/"): - return "metadata channel; expected to be useful with dsx_exchange_read_retained before sampling related live values" - case strings.Contains(address, "/Value/"): - return "live value channel; use dsx_exchange_subscribe and read related metadata first when available" - default: - return "schema-defined channel; use the channel description and broker ACLs to decide whether retained reads or live subscription are appropriate" - } -} - -func examples(topic Topic) []string { - out := []string{topic.TopicFilter} - if len(topic.MatchedParameters) > 0 { - filter := addressToFilter(topic.Address, topic.MatchedParameters) - if filter != topic.TopicFilter { - out = append(out, filter) - } - } - return out -} - -func placeholderName(part string) (string, bool) { - if strings.HasPrefix(part, "{") && strings.HasSuffix(part, "}") && len(part) > 2 { - return part[1 : len(part)-1], true - } - return "", false -} - -func refName(ref string) string { - idx := strings.LastIndex(ref, "/") - if idx < 0 || idx == len(ref)-1 { - return ref - } - return ref[idx+1:] -} - -func firstNonEmpty(values ...string) string { - for _, v := range values { - if strings.TrimSpace(v) != "" { - return v - } - } - return "" -} - -func sortTopics(topics []Topic) { - sort.Slice(topics, func(i, j int) bool { - if topics[i].Domain != topics[j].Domain { - return topics[i].Domain < topics[j].Domain - } - return topics[i].Channel < topics[j].Channel - }) -} - -func mapValue(v any) map[string]any { - switch typed := v.(type) { - case map[string]any: - return typed - case map[any]any: - out := map[string]any{} - for k, v := range typed { - if s, ok := k.(string); ok { - out[s] = v - } - } - return out - default: - return nil - } -} - -func stringValue(v any) string { - if s, ok := v.(string); ok { - return s - } - return "" -} - -func stringSlice(v any) []string { - switch typed := v.(type) { - case []string: - return append([]string{}, typed...) - case []any: - out := make([]string, 0, len(typed)) - for _, item := range typed { - if s, ok := item.(string); ok { - out = append(out, s) - } - } - return out - default: - return nil - } -} - -func refList(v any) []string { - items, ok := v.([]any) - if !ok { - return nil - } - out := make([]string, 0, len(items)) - for _, item := range items { - ref := stringValue(mapValue(item)["$ref"]) - if ref != "" { - out = append(out, ref) - } - } - return out -} diff --git a/mcp/dsx-exchange-mcp/internal/schemaindex/index_test.go b/mcp/dsx-exchange-mcp/internal/schemaindex/index_test.go index 0062ac6..3f8fac3 100644 --- a/mcp/dsx-exchange-mcp/internal/schemaindex/index_test.go +++ b/mcp/dsx-exchange-mcp/internal/schemaindex/index_test.go @@ -34,6 +34,29 @@ func TestDefaultDescribeBMSValueTopic(t *testing.T) { } } +func TestDefaultDescribeBMSMetadataTopic(t *testing.T) { + idx, err := Default() + if err != nil { + t.Fatalf("load default schema index: %v", err) + } + + matches := idx.Describe("BMS/v1/PUB/Metadata/Rack/RackLiquidIsolationStatus/#") + if len(matches) == 0 { + t.Fatal("Describe returned no matches") + } + + got := topicByChannel(t, matches, "rackMetadata") + if got.MatchedParameters["pointType"] != "RackLiquidIsolationStatus" { + t.Fatalf("pointType = %q, want RackLiquidIsolationStatus", got.MatchedParameters["pointType"]) + } + if len(got.RelatedTopics) != 1 || got.RelatedTopics[0].TopicFilter != "BMS/v1/PUB/Value/Rack/RackLiquidIsolationStatus/#" { + t.Fatalf("related topics = %#v, want value counterpart", got.RelatedTopics) + } + if got.RetainedLiveBehavior == "" { + t.Fatal("metadata topic should include retained/live guidance") + } +} + func TestDefaultDescribePowerManagementTopic(t *testing.T) { idx, err := Default() if err != nil { @@ -50,6 +73,9 @@ func TestDefaultDescribePowerManagementTopic(t *testing.T) { if got := matches[0].MatchedParameters["identifier"]; got != "+" { t.Fatalf("identifier parameter = %q, want +", got) } + if len(matches[0].RelatedTopics) != 0 { + t.Fatalf("related topics = %#v, want none for non-BMS topic", matches[0].RelatedTopics) + } } func TestDefaultSearchBMSSelectorBuildsTopicFilter(t *testing.T) { @@ -76,6 +102,54 @@ func TestDefaultSearchBMSSelectorBuildsTopicFilter(t *testing.T) { } } +func TestDefaultSearchBMSMetadataSelectorBuildsTopicFilter(t *testing.T) { + idx, err := Default() + if err != nil { + t.Fatalf("load default schema index: %v", err) + } + + matches := idx.Search(SearchOptions{ + Domain: "bms", + Role: "metadata", + ObjectType: "Rack", + PointType: "RackLiquidIsolationStatus", + Limit: 10, + }) + if len(matches) != 1 { + t.Fatalf("Search returned %d matches, want 1: %#v", len(matches), matches) + } + if got := matches[0].TopicFilter; got != "BMS/v1/PUB/Metadata/Rack/RackLiquidIsolationStatus/#" { + t.Fatalf("topic filter = %q, want BMS rack metadata filter", got) + } + if len(matches[0].RelatedTopics) != 1 || matches[0].RelatedTopics[0].TopicFilter != "BMS/v1/PUB/Value/Rack/RackLiquidIsolationStatus/#" { + t.Fatalf("related topics = %#v, want value counterpart", matches[0].RelatedTopics) + } +} + +func TestDefaultSearchCDUSetpointBuildsIntegrationTopicFilter(t *testing.T) { + idx, err := Default() + if err != nil { + t.Fatalf("load default schema index: %v", err) + } + + matches := idx.Search(SearchOptions{ + Domain: "bms", + Role: "value", + ObjectType: "CDU", + PointType: "LiquidTemperatureSpRequest", + Limit: 10, + }) + if len(matches) != 1 { + t.Fatalf("Search returned %d matches, want 1: %#v", len(matches), matches) + } + if got := matches[0].TopicFilter; got != "BMS/v1/+/Value/CDU/LiquidTemperatureSpRequest/#" { + t.Fatalf("topic filter = %q, want integration CDU setpoint value filter", got) + } + if matches[0].MatchedParameters["pointType"] != "LiquidTemperatureSpRequest" { + t.Fatalf("matched pointType = %q, want LiquidTemperatureSpRequest", matches[0].MatchedParameters["pointType"]) + } +} + func TestDefaultSearchQueryFindsNICO(t *testing.T) { idx, err := Default() if err != nil { @@ -84,7 +158,7 @@ func TestDefaultSearchQueryFindsNICO(t *testing.T) { matches := idx.Search(SearchOptions{ Domain: "nico", - Query: "state", + Query: "STATE", Limit: 5, }) if len(matches) == 0 { @@ -94,3 +168,46 @@ func TestDefaultSearchQueryFindsNICO(t *testing.T) { t.Fatalf("domain = %q, want nico", matches[0].Domain) } } + +func TestAddressToFilterUsesMultiLevelWildcardForFinalPath(t *testing.T) { + got := addressToFilter("BMS/v1/PUB/Value/Rack/{pointType}/{tagPath}", map[string]string{ + "pointType": "RackPower", + }) + if got != "BMS/v1/PUB/Value/Rack/RackPower/#" { + t.Fatalf("addressToFilter = %q, want final path wildcard", got) + } +} + +func TestDescribeConcreteBMSValueInfersParameters(t *testing.T) { + idx, err := Default() + if err != nil { + t.Fatalf("load default schema index: %v", err) + } + + matches := idx.Describe("BMS/v1/PUB/Value/Rack/RackPower/nvidia/titan/pod1C/rowF/rack06/info/power/power/value") + if len(matches) == 0 { + t.Fatal("Describe returned no matches") + } + + got := topicByChannel(t, matches, "rackBmsValue") + if got.MatchedParameters["pointType"] != "RackPower" { + t.Fatalf("pointType = %q, want RackPower", got.MatchedParameters["pointType"]) + } + if got.MatchedParameters["tagPath"] != "nvidia/titan/pod1C/rowF/rack06/info/power/power/value" { + t.Fatalf("tagPath = %q, want concrete suffix", got.MatchedParameters["tagPath"]) + } + if len(got.RelatedTopics) != 1 || got.RelatedTopics[0].TopicFilter != "BMS/v1/PUB/Metadata/Rack/RackPower/nvidia/titan/pod1C/rowF/rack06/info/power/power/value" { + t.Fatalf("related topics = %#v, want concrete metadata counterpart", got.RelatedTopics) + } +} + +func topicByChannel(t *testing.T, topics []Topic, channel string) Topic { + t.Helper() + for _, topic := range topics { + if topic.Channel == channel { + return topic + } + } + t.Fatalf("missing channel %q in matches: %#v", channel, topics) + return Topic{} +} diff --git a/mcp/dsx-exchange-mcp/internal/schemaindex/match.go b/mcp/dsx-exchange-mcp/internal/schemaindex/match.go new file mode 100644 index 0000000..3a91cac --- /dev/null +++ b/mcp/dsx-exchange-mcp/internal/schemaindex/match.go @@ -0,0 +1,213 @@ +// Copyright 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +package schemaindex + +import "strings" + +// addressToFilter turns an AsyncAPI channel address into an MQTT topic filter. +// Example: "BMS/v1/{publisher}/Value/{objectType}/{pointType}/{tagPath}" -> "BMS/v1/+/Value/+/+/#" +func addressToFilter(address string, values map[string]string) string { + parts := strings.Split(address, "/") + for i, part := range parts { + name, ok := placeholderName(part) + if !ok { + continue + } + if v := strings.TrimSpace(values[name]); v != "" { + parts[i] = v + continue + } + if i == len(parts)-1 && strings.Contains(strings.ToLower(name), "path") { + parts[i] = "#" + } else { + parts[i] = "+" + } + } + return strings.Join(parts, "/") +} + +// matchesAddress checks whether a concrete topic or topic filter fits an +// AsyncAPI address. It is schema-oriented, not a full MQTT broker matcher. +func matchesAddress(address, topic string) bool { + addressParts := strings.Split(strings.Trim(address, "/"), "/") + topicParts := strings.Split(strings.Trim(topic, "/"), "/") + for i, part := range addressParts { + if i >= len(topicParts) { + return false + } + if name, ok := placeholderName(part); ok { + if i == len(addressParts)-1 && strings.Contains(strings.ToLower(name), "path") { + return true + } + continue + } + if topicParts[i] == "+" || topicParts[i] == "#" { + continue + } + if part != topicParts[i] { + return false + } + } + return len(addressParts) == len(topicParts) || strings.HasSuffix(topic, "/#") +} + +// filtersOverlap handles broad MQTT filters such as BMS/v1/PUB/Value/# that do +// not directly fit one channel address but still overlap generated schema +// filters. +func filtersOverlap(a, b string) bool { + ap := strings.Split(strings.Trim(a, "/"), "/") + bp := strings.Split(strings.Trim(b, "/"), "/") + for i := 0; i < len(ap) && i < len(bp); i++ { + if ap[i] == "#" || bp[i] == "#" { + return true + } + if ap[i] == "+" || bp[i] == "+" { + continue + } + if ap[i] != bp[i] { + return false + } + } + return len(ap) == len(bp) +} + +func matchesRole(topic Topic, role string) bool { + switch role { + case "metadata": + // BMS exposes metadata as a first-class path segment. Other schemas only + // match this role if they choose the same convention. + return strings.Contains(topic.Address, "/Metadata/") + case "value": + // BMS exposes live values as a first-class path segment. Other schemas only + // match this role if they choose the same convention. + return strings.Contains(topic.Address, "/Value/") + case "event": + return !strings.Contains(topic.Address, "/Metadata/") && !strings.Contains(topic.Address, "/Value/") + default: + return strings.Contains(strings.ToLower(topic.RetainedLiveBehavior), role) || + strings.Contains(strings.ToLower(topic.Address), role) || + strings.Contains(strings.ToLower(topic.Channel), role) + } +} + +func matchesAddressValue(address, name, value string) bool { + value = strings.TrimSpace(value) + if value == "" { + return true + } + parts := strings.Split(strings.Trim(address, "/"), "/") + for i, part := range parts { + placeholder, ok := placeholderName(part) + if ok && placeholder == name { + return true + } + if !ok && strings.EqualFold(part, value) { + switch name { + case "objectType": + // BMS object types are the path level immediately after + // /Value/ or /Metadata/. + return i > 0 && (parts[i-1] == "Value" || parts[i-1] == "Metadata") + case "pointType": + // BMS point types are the path level immediately after the + // object type. + return i > 1 && (parts[i-2] == "Value" || parts[i-2] == "Metadata") + default: + return true + } + } + } + return false +} + +func parameterAllows(params []ParameterSummary, name, value string) bool { + for _, param := range params { + if param.Name != name { + continue + } + if len(param.Enum) == 0 { + return true + } + for _, allowed := range param.Enum { + if strings.EqualFold(allowed, value) { + return true + } + } + return false + } + return true +} + +func matchesOperationAction(ops []OperationSummary, action string) bool { + for _, op := range ops { + if strings.EqualFold(op.Action, action) { + return true + } + } + return false +} + +func topicContains(topic Topic, query string) bool { + if strings.Contains(strings.ToLower(topic.Domain), query) || + strings.Contains(strings.ToLower(topic.SpecTitle), query) || + strings.Contains(strings.ToLower(topic.Channel), query) || + strings.Contains(strings.ToLower(topic.Address), query) || + strings.Contains(strings.ToLower(topic.Description), query) || + strings.Contains(strings.ToLower(topic.RetainedLiveBehavior), query) { + return true + } + for _, msg := range topic.Messages { + if strings.Contains(strings.ToLower(msg.Name), query) || + strings.Contains(strings.ToLower(msg.Title), query) || + strings.Contains(strings.ToLower(msg.Summary), query) || + strings.Contains(strings.ToLower(msg.Description), query) || + strings.Contains(strings.ToLower(msg.Payload.Ref), query) { + return true + } + } + for _, op := range topic.Operations { + if strings.Contains(strings.ToLower(op.Name), query) || + strings.Contains(strings.ToLower(op.Action), query) || + strings.Contains(strings.ToLower(op.Summary), query) || + strings.Contains(strings.ToLower(op.Description), query) { + return true + } + } + return false +} + +func valuesOrNil(values map[string]string) map[string]string { + clean := map[string]string{} + for k, v := range values { + if strings.TrimSpace(v) != "" { + clean[k] = v + } + } + if len(clean) == 0 { + return nil + } + return clean +} + +// inferParameters extracts placeholder values from a topic that matched an +// AsyncAPI address, so callers see why a schema channel matched their filter. +func inferParameters(address, topic string) map[string]string { + addressParts := strings.Split(strings.Trim(address, "/"), "/") + topicParts := strings.Split(strings.Trim(topic, "/"), "/") + out := map[string]string{} + for i, part := range addressParts { + name, ok := placeholderName(part) + if !ok || i >= len(topicParts) { + continue + } + if i == len(addressParts)-1 && strings.Contains(strings.ToLower(name), "path") { + out[name] = strings.Join(topicParts[i:], "/") + continue + } + out[name] = topicParts[i] + } + if len(out) == 0 { + return nil + } + return out +} diff --git a/mcp/dsx-exchange-mcp/internal/schemaindex/parse.go b/mcp/dsx-exchange-mcp/internal/schemaindex/parse.go new file mode 100644 index 0000000..680cad0 --- /dev/null +++ b/mcp/dsx-exchange-mcp/internal/schemaindex/parse.go @@ -0,0 +1,236 @@ +// Copyright 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +package schemaindex + +import ( + "fmt" + "sort" + "strings" + + "gopkg.in/yaml.v3" +) + +type document struct { + AsyncAPI string `yaml:"asyncapi"` + Info info `yaml:"info"` + Channels map[string]channel `yaml:"channels"` + Operations map[string]operation `yaml:"operations"` + Components components `yaml:"components"` +} + +type info struct { + Title string `yaml:"title"` + Version string `yaml:"version"` + Description string `yaml:"description"` +} + +type channel struct { + Ref string `yaml:"$ref"` + Address string `yaml:"address"` + Description string `yaml:"description"` + Parameters map[string]parameter `yaml:"parameters"` + Messages map[string]messageRef `yaml:"messages"` +} + +type parameter struct { + Ref string `yaml:"$ref"` + Description string `yaml:"description"` + Enum []string `yaml:"enum"` +} + +type messageRef struct { + Ref string `yaml:"$ref"` + Name string `yaml:"name"` + Title string `yaml:"title"` + Summary string `yaml:"summary"` + Description string `yaml:"description"` + Payload map[string]any `yaml:"payload"` +} + +type message struct { + Name string `yaml:"name"` + Title string `yaml:"title"` + Summary string `yaml:"summary"` + Description string `yaml:"description"` + Payload map[string]any `yaml:"payload"` +} + +type operation struct { + Action string `yaml:"action"` + Summary string `yaml:"summary"` + Description string `yaml:"description"` + Channel reference `yaml:"channel"` + Messages []reference `yaml:"messages"` +} + +type reference struct { + Ref string `yaml:"$ref"` +} + +type components struct { + Messages map[string]message `yaml:"messages"` + Parameters map[string]parameter `yaml:"parameters"` + Schemas map[string]map[string]any `yaml:"schemas"` +} + +func parseDocument(name string, body []byte) (document, error) { + var doc document + if err := yaml.Unmarshal(body, &doc); err != nil { + return document{}, fmt.Errorf("parse %s: %w", name, err) + } + if strings.TrimSpace(doc.AsyncAPI) == "" { + return document{}, fmt.Errorf("parse %s: %w", name, errMissingAsyncAPI) + } + return doc, nil +} + +// docTopics flattens the AsyncAPI channel map into the compact Topic records +// returned by MCP schema tools. +func docTopics(domain string, doc document) []Topic { + names := make([]string, 0, len(doc.Channels)) + for name := range doc.Channels { + names = append(names, name) + } + sort.Strings(names) + + topics := make([]Topic, 0, len(names)) + for _, name := range names { + ch := doc.Channels[name] + if ch.Address == "" { + continue + } + topics = append(topics, Topic{ + Domain: domain, + SpecTitle: doc.Info.Title, + SpecVersion: doc.Info.Version, + Channel: name, + Address: ch.Address, + TopicFilter: addressToFilter(ch.Address, nil), + Description: strings.TrimSpace(ch.Description), + RetainedLiveBehavior: retainedLiveBehavior(ch.Address), + Parameters: summarizeParameters(ch.Parameters, doc.Components.Parameters), + Messages: summarizeMessages(ch.Messages, doc.Components), + Operations: summarizeOperations(name, doc.Operations), + }) + } + return topics +} + +func summarizeParameters(params map[string]parameter, components map[string]parameter) []ParameterSummary { + names := make([]string, 0, len(params)) + for name := range params { + names = append(names, name) + } + sort.Strings(names) + + out := make([]ParameterSummary, 0, len(names)) + for _, name := range names { + p := params[name] + if p.Ref != "" { + if resolved, ok := components[refName(p.Ref)]; ok { + p = resolved + } + } + out = append(out, ParameterSummary{ + Name: name, + Description: strings.TrimSpace(p.Description), + Enum: append([]string{}, p.Enum...), + }) + } + return out +} + +func summarizeMessages(refs map[string]messageRef, components components) []MessageSummary { + names := make([]string, 0, len(refs)) + for name := range refs { + names = append(names, name) + } + sort.Strings(names) + + out := make([]MessageSummary, 0, len(names)) + for _, name := range names { + ref := refs[name] + msg := message{ + Name: ref.Name, + Title: ref.Title, + Summary: ref.Summary, + Description: ref.Description, + Payload: ref.Payload, + } + if ref.Ref != "" { + if resolved, ok := components.Messages[refName(ref.Ref)]; ok { + msg = resolved + } + } + out = append(out, MessageSummary{ + Name: firstNonEmpty(msg.Name, name), + Ref: ref.Ref, + Title: msg.Title, + Summary: msg.Summary, + Description: strings.TrimSpace(msg.Description), + Payload: summarizePayload(msg.Payload, components.Schemas), + }) + } + return out +} + +func summarizeOperations(channelName string, operations map[string]operation) []OperationSummary { + var out []OperationSummary + for name, op := range operations { + if refName(op.Channel.Ref) != channelName { + continue + } + out = append(out, OperationSummary{ + Name: name, + Action: op.Action, + Summary: op.Summary, + Description: strings.TrimSpace(op.Description), + }) + } + sort.Slice(out, func(i, j int) bool { + return out[i].Name < out[j].Name + }) + return out +} + +func summarizePayload(payload map[string]any, schemas map[string]map[string]any) PayloadShape { + if len(payload) == 0 { + return PayloadShape{} + } + if ref, _ := payload["$ref"].(string); ref != "" { + shape := summarizeSchema(schemas[refName(ref)]) + shape.Ref = ref + return shape + } + return summarizeSchema(payload) +} + +func summarizeSchema(schema map[string]any) PayloadShape { + if len(schema) == 0 { + return PayloadShape{} + } + shape := PayloadShape{ + Type: stringValue(schema["type"]), + Required: stringSlice(schema["required"]), + AllOf: refList(schema["allOf"]), + OneOf: refList(schema["oneOf"]), + } + props := mapValue(schema["properties"]) + names := make([]string, 0, len(props)) + for name := range props { + names = append(names, name) + } + sort.Strings(names) + for _, name := range names { + prop := mapValue(props[name]) + shape.Properties = append(shape.Properties, PropertySummary{ + Name: name, + Type: stringValue(prop["type"]), + Ref: stringValue(prop["$ref"]), + Description: strings.TrimSpace(stringValue(prop["description"])), + Enum: stringSlice(prop["enum"]), + }) + } + return shape +} diff --git a/mcp/dsx-exchange-mcp/internal/schemaindex/topic_helpers.go b/mcp/dsx-exchange-mcp/internal/schemaindex/topic_helpers.go new file mode 100644 index 0000000..f2c8e89 --- /dev/null +++ b/mcp/dsx-exchange-mcp/internal/schemaindex/topic_helpers.go @@ -0,0 +1,144 @@ +// Copyright 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +package schemaindex + +import ( + "sort" + "strings" +) + +// relatedTopics identifies the corresponding BMS Metadata or Value channel for a given topic. +// This is only for BMS schemas that use the "/Value/" and "/Metadata/" convention to link live value +// and metadata channels. For all other schemas, no relationship is assumed unless they adopt this pattern. +func relatedTopics(address string, values map[string]string) []RelatedTopic { + switch { + case strings.Contains(address, "/Value/"): + return []RelatedTopic{{ + Role: "metadata", + TopicFilter: addressToFilter(strings.Replace(address, "/Value/", "/Metadata/", 1), values), + }} + case strings.Contains(address, "/Metadata/"): + return []RelatedTopic{{ + Role: "value", + TopicFilter: addressToFilter(strings.Replace(address, "/Metadata/", "/Value/", 1), values), + }} + default: + return nil + } +} + +// retainedLiveBehavior describes the BMS Metadata/Value convention when the +// address contains those path segments. For other schemas it falls back to a +// generic schema-channel note because retention/live semantics are domain +// specific and are not inferred from full AsyncAPI semantics here. +func retainedLiveBehavior(address string) string { + switch { + case strings.Contains(address, "/Metadata/"): + return "metadata channel; expected to be useful with dsx_exchange_read_retained before sampling related live values" + case strings.Contains(address, "/Value/"): + return "live value channel; use dsx_exchange_subscribe and read related metadata first when available" + default: + return "schema-defined channel; use the channel description and broker ACLs to decide whether retained reads or live subscription are appropriate" + } +} + +func examples(topic Topic) []string { + out := []string{topic.TopicFilter} + if len(topic.MatchedParameters) > 0 { + filter := addressToFilter(topic.Address, topic.MatchedParameters) + if filter != topic.TopicFilter { + out = append(out, filter) + } + } + return out +} + +func placeholderName(part string) (string, bool) { + if strings.HasPrefix(part, "{") && strings.HasSuffix(part, "}") && len(part) > 2 { + return part[1 : len(part)-1], true + } + return "", false +} + +func refName(ref string) string { + idx := strings.LastIndex(ref, "/") + if idx < 0 || idx == len(ref)-1 { + return ref + } + return ref[idx+1:] +} + +func firstNonEmpty(values ...string) string { + for _, v := range values { + if strings.TrimSpace(v) != "" { + return v + } + } + return "" +} + +func sortTopics(topics []Topic) { + sort.Slice(topics, func(i, j int) bool { + if topics[i].Domain != topics[j].Domain { + return topics[i].Domain < topics[j].Domain + } + return topics[i].Channel < topics[j].Channel + }) +} + +func mapValue(v any) map[string]any { + switch typed := v.(type) { + case map[string]any: + return typed + case map[any]any: + out := map[string]any{} + for k, v := range typed { + if s, ok := k.(string); ok { + out[s] = v + } + } + return out + default: + return nil + } +} + +func stringValue(v any) string { + if s, ok := v.(string); ok { + return s + } + return "" +} + +func stringSlice(v any) []string { + switch typed := v.(type) { + case []string: + return append([]string{}, typed...) + case []any: + out := make([]string, 0, len(typed)) + for _, item := range typed { + if s, ok := item.(string); ok { + out = append(out, s) + } + } + return out + default: + return nil + } +} + +func refList(v any) []string { + items, ok := v.([]any) + if !ok { + return nil + } + out := make([]string, 0, len(items)) + for _, item := range items { + ref := stringValue(mapValue(item)["$ref"]) + if ref != "" { + out = append(out, ref) + } + } + return out +} diff --git a/mcp/dsx-exchange-mcp/internal/server/tools_test.go b/mcp/dsx-exchange-mcp/internal/server/tools_test.go index 0d1f5a0..e054362 100644 --- a/mcp/dsx-exchange-mcp/internal/server/tools_test.go +++ b/mcp/dsx-exchange-mcp/internal/server/tools_test.go @@ -306,6 +306,46 @@ func TestMCPClientListsAndCallsDescribeTopic(t *testing.T) { } } +func TestMCPClientCallsFindTopics(t *testing.T) { + session, cleanup := newTestMCPClient(t) + defer cleanup() + + result, err := session.CallTool(context.Background(), &mcp.CallToolParams{ + Name: toolFindTopics, + Arguments: map[string]any{ + "domain": "bms", + "role": "metadata", + "object_type": "Rack", + "point_type": "RackLiquidIsolationStatus", + "limit": 10, + }, + }) + if err != nil { + t.Fatalf("CallTool(%s) returned client error: %v", toolFindTopics, err) + } + if result.IsError { + t.Fatalf("CallTool(%s) returned tool error: %s", toolFindTopics, textContentSummary(result)) + } + + var out findTopicsOutput + if err := json.Unmarshal([]byte(lastTextContent(t, result)), &out); err != nil { + t.Fatalf("decode CallTool(%s) JSON content: %v", toolFindTopics, err) + } + if out.Count != 1 { + t.Fatalf("MCP find_topics count = %d, want 1: %#v", out.Count, out.Matches) + } + got := out.Matches[0] + if got.Domain != "bms" || got.Channel != "rackMetadata" { + t.Fatalf("MCP find_topics match = %s/%s, want bms/rackMetadata", got.Domain, got.Channel) + } + if got.TopicFilter != "BMS/v1/PUB/Metadata/Rack/RackLiquidIsolationStatus/#" { + t.Fatalf("MCP find_topics topic_filter = %q, want BMS rack metadata filter", got.TopicFilter) + } + if len(got.RelatedTopics) != 1 || got.RelatedTopics[0].TopicFilter != "BMS/v1/PUB/Value/Rack/RackLiquidIsolationStatus/#" { + t.Fatalf("MCP find_topics related topics = %#v, want value counterpart", got.RelatedTopics) + } +} + func toolByName(t *testing.T, tools []*mcp.Tool, name string) *mcp.Tool { t.Helper() for _, tool := range tools { From 89134aac8ce5770623cdb7f8c700c30198c4eb8f Mon Sep 17 00:00:00 2001 From: Daniyal Rana Date: Tue, 23 Jun 2026 13:56:09 -0500 Subject: [PATCH 10/27] refactor(mcp): separate server transport setup Signed-off-by: Daniyal Rana --- .../cmd/dsx-exchange-mcp/main.go | 25 +--- mcp/dsx-exchange-mcp/internal/server/run.go | 45 +++++++ .../internal/server/tools_test.go | 8 +- .../internal/server/transport_test.go | 119 ++++++++++++++++++ 4 files changed, 166 insertions(+), 31 deletions(-) create mode 100644 mcp/dsx-exchange-mcp/internal/server/run.go create mode 100644 mcp/dsx-exchange-mcp/internal/server/transport_test.go diff --git a/mcp/dsx-exchange-mcp/cmd/dsx-exchange-mcp/main.go b/mcp/dsx-exchange-mcp/cmd/dsx-exchange-mcp/main.go index 7d70c91..a6c664f 100644 --- a/mcp/dsx-exchange-mcp/cmd/dsx-exchange-mcp/main.go +++ b/mcp/dsx-exchange-mcp/cmd/dsx-exchange-mcp/main.go @@ -5,14 +5,10 @@ package main import ( "log/slog" - "net/http" "os" "strconv" "time" - "github.com/modelcontextprotocol/go-sdk/mcp" - - "github.com/NVIDIA/dsx-exchange/mcp/dsx-exchange-mcp/internal/auth" "github.com/NVIDIA/dsx-exchange/mcp/dsx-exchange-mcp/internal/mqttbus" "github.com/NVIDIA/dsx-exchange/mcp/dsx-exchange-mcp/internal/server" ) @@ -52,21 +48,6 @@ func main() { os.Exit(2) } - srv := server.Build(cfg) - - handler := mcp.NewStreamableHTTPHandler( - func(*http.Request) *mcp.Server { return srv }, - &mcp.StreamableHTTPOptions{ - Stateless: true, - JSONResponse: true, - }, - ) - - mux := http.NewServeMux() - mux.Handle("/mcp", auth.Middleware(handler)) - mux.HandleFunc("/healthz/live", healthOK) - mux.HandleFunc("/healthz/ready", healthOK) - logger.Info("dsx-exchange-mcp listening", "addr", addr, "nats", natsURL, @@ -78,7 +59,7 @@ func main() { "max_duration_s", cfg.MaxDurationS, "mqtt_collect_max_concurrent_per_pod", cfg.MQTTCollectMaxConcurrent, ) - if err := http.ListenAndServe(addr, mux); err != nil { + if err := server.Run(addr, cfg); err != nil { logger.Error("server exited", "err", err) os.Exit(1) } @@ -112,7 +93,3 @@ func envBool(key string, fallback bool) bool { } return fallback } - -func healthOK(w http.ResponseWriter, _ *http.Request) { - w.WriteHeader(http.StatusNoContent) -} diff --git a/mcp/dsx-exchange-mcp/internal/server/run.go b/mcp/dsx-exchange-mcp/internal/server/run.go new file mode 100644 index 0000000..48e4f9a --- /dev/null +++ b/mcp/dsx-exchange-mcp/internal/server/run.go @@ -0,0 +1,45 @@ +// Copyright 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +package server + +import ( + "net/http" + + "github.com/modelcontextprotocol/go-sdk/mcp" + + "github.com/NVIDIA/dsx-exchange/mcp/dsx-exchange-mcp/internal/auth" +) + +// NewHandler wraps the MCP server in the streamable-HTTP transport used by the +// standalone binary and tests. Stateless mode keeps requests independent, and +// JSONResponse forces request/response clients to receive one JSON body instead +// of a server-sent-events stream. +func NewHandler(srv *mcp.Server) http.Handler { + return mcp.NewStreamableHTTPHandler( + func(*http.Request) *mcp.Server { return srv }, + &mcp.StreamableHTTPOptions{ + Stateless: true, + JSONResponse: true, + }, + ) +} + +// NewMux wires the public HTTP surface for the MCP backend. +func NewMux(cfg Config) http.Handler { + srv := Build(cfg) + mux := http.NewServeMux() + mux.Handle("/mcp", auth.Middleware(NewHandler(srv))) + mux.HandleFunc("/healthz/live", healthOK) + mux.HandleFunc("/healthz/ready", healthOK) + return mux +} + +// Run serves the configured MCP backend until http.Server exits. +func Run(addr string, cfg Config) error { + return http.ListenAndServe(addr, NewMux(cfg)) +} + +func healthOK(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusNoContent) +} diff --git a/mcp/dsx-exchange-mcp/internal/server/tools_test.go b/mcp/dsx-exchange-mcp/internal/server/tools_test.go index e054362..9d16bcb 100644 --- a/mcp/dsx-exchange-mcp/internal/server/tools_test.go +++ b/mcp/dsx-exchange-mcp/internal/server/tools_test.go @@ -433,15 +433,9 @@ func newTestMCPClient(t *testing.T) (*mcp.ClientSession, func()) { func newTestMCPClientWithConfig(t *testing.T, cfg Config) (*mcp.ClientSession, func()) { t.Helper() srv := Build(cfg) - handler := mcp.NewStreamableHTTPHandler(func(*http.Request) *mcp.Server { - return srv - }, &mcp.StreamableHTTPOptions{ - Stateless: true, - JSONResponse: true, - }) mux := http.NewServeMux() - mux.Handle("/mcp", auth.Middleware(handler)) + mux.Handle("/mcp", auth.Middleware(NewHandler(srv))) httpServer := httptest.NewServer(mux) client := mcp.NewClient(&mcp.Implementation{ diff --git a/mcp/dsx-exchange-mcp/internal/server/transport_test.go b/mcp/dsx-exchange-mcp/internal/server/transport_test.go new file mode 100644 index 0000000..b240591 --- /dev/null +++ b/mcp/dsx-exchange-mcp/internal/server/transport_test.go @@ -0,0 +1,119 @@ +// Copyright 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +package server + +import ( + "bytes" + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "strings" + "testing" +) + +func TestNewHandlerToolsListJSONResponse(t *testing.T) { + srv := Build(Config{}) + httpServer := httptest.NewServer(NewHandler(srv)) + defer httpServer.Close() + + resp := postJSONRPC(t, httpServer.URL, jsonRPCRequest(1, "tools/list", map[string]any{})) + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + t.Fatalf("tools/list status = %d, want 200", resp.StatusCode) + } + if got := resp.Header.Get("Content-Type"); !strings.HasPrefix(got, "application/json") { + t.Fatalf("tools/list Content-Type = %q, want application/json", got) + } + + body, err := io.ReadAll(resp.Body) + if err != nil { + t.Fatalf("read tools/list response: %v", err) + } + var out struct { + Result struct { + Tools []struct { + Name string `json:"name"` + } `json:"tools"` + } `json:"result"` + } + if err := json.Unmarshal(body, &out); err != nil { + t.Fatalf("decode tools/list response: %v\n%s", err, body) + } + names := map[string]bool{} + for _, tool := range out.Result.Tools { + names[tool.Name] = true + } + for _, name := range []string{toolDescribeTopic, toolFindTopics, toolReadRetained, toolSubscribe} { + if !names[name] { + t.Fatalf("tools/list missing %q; saw %#v", name, names) + } + } +} + +func TestNewHandlerRejectsLongPollGET(t *testing.T) { + srv := Build(Config{}) + httpServer := httptest.NewServer(NewHandler(srv)) + defer httpServer.Close() + + req, err := http.NewRequest(http.MethodGet, httpServer.URL, nil) + if err != nil { + t.Fatalf("build long-poll GET request: %v", err) + } + req.Header.Set("Accept", "text/event-stream") + resp, err := http.DefaultClient.Do(req) + if err != nil { + t.Fatalf("long-poll GET failed: %v", err) + } + defer resp.Body.Close() + + if resp.StatusCode < http.StatusBadRequest { + t.Fatalf("long-poll GET status = %d, want non-success", resp.StatusCode) + } +} + +func TestNewMuxHealthEndpoints(t *testing.T) { + httpServer := httptest.NewServer(NewMux(Config{})) + defer httpServer.Close() + + for _, path := range []string{"/healthz/live", "/healthz/ready"} { + resp, err := http.Get(httpServer.URL + path) + if err != nil { + t.Fatalf("GET %s failed: %v", path, err) + } + _ = resp.Body.Close() + if resp.StatusCode != http.StatusNoContent { + t.Fatalf("GET %s status = %d, want 204", path, resp.StatusCode) + } + } +} + +func postJSONRPC(t *testing.T, url string, body []byte) *http.Response { + t.Helper() + req, err := http.NewRequest(http.MethodPost, url, bytes.NewReader(body)) + if err != nil { + t.Fatalf("build JSON-RPC request: %v", err) + } + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Accept", "application/json, text/event-stream") + resp, err := http.DefaultClient.Do(req) + if err != nil { + t.Fatalf("POST JSON-RPC request failed: %v", err) + } + return resp +} + +func jsonRPCRequest(id int, method string, params map[string]any) []byte { + body, err := json.Marshal(map[string]any{ + "jsonrpc": "2.0", + "id": id, + "method": method, + "params": params, + }) + if err != nil { + panic(err) + } + return body +} From e77b79e94f080825396a3f76c7ef4cc950f3a82b Mon Sep 17 00:00:00 2001 From: Daniyal Rana Date: Tue, 23 Jun 2026 14:21:11 -0500 Subject: [PATCH 11/27] build(mcp): tighten docker build context Signed-off-by: Daniyal Rana --- mcp/dsx-exchange-mcp/.dockerignore | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) create mode 100644 mcp/dsx-exchange-mcp/.dockerignore diff --git a/mcp/dsx-exchange-mcp/.dockerignore b/mcp/dsx-exchange-mcp/.dockerignore new file mode 100644 index 0000000..151091c --- /dev/null +++ b/mcp/dsx-exchange-mcp/.dockerignore @@ -0,0 +1,22 @@ +# Copyright 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +# Local build and test artifacts. +bin/ +.gocache/ +coverage.out +*.test +*.prof + +# Local OS/editor state. +.DS_Store +.idea/ +.vscode/ + +# Local-only MCP validation helpers that are not part of the released server +# image. The server binary still embeds schemas from ./schemas at build time. +cmd/dsx-exchange-token-proxy/ +deploy/local-check/ +docs/gateway-auth-interactions.md +docs/local-deployment-check.md +docs/todo.md From 154dd91c3c1ed4971159a7b5c68c49b355836bf5 Mon Sep 17 00:00:00 2001 From: Daniyal Rana Date: Tue, 23 Jun 2026 14:28:51 -0500 Subject: [PATCH 12/27] docs(mcp): add client skill guidance Signed-off-by: Daniyal Rana --- .../skills/dsx-exchange-mcp/SKILL.md | 71 +++++++++++++++++++ 1 file changed, 71 insertions(+) create mode 100644 mcp/dsx-exchange-mcp/skills/dsx-exchange-mcp/SKILL.md diff --git a/mcp/dsx-exchange-mcp/skills/dsx-exchange-mcp/SKILL.md b/mcp/dsx-exchange-mcp/skills/dsx-exchange-mcp/SKILL.md new file mode 100644 index 0000000..907f8d4 --- /dev/null +++ b/mcp/dsx-exchange-mcp/skills/dsx-exchange-mcp/SKILL.md @@ -0,0 +1,71 @@ +--- +name: dsx-exchange-mcp +description: >- + Use DSX Exchange MCP tools for schema discovery, retained metadata reads, and + bounded live MQTT sampling. Use when an agent needs to find, describe, get, + fetch, read, sample, watch, listen, monitor, or subscribe to DSX Exchange + topics or values. Prefer the MCP client's native background agent, subagent, + task, or equivalent async mechanism for dsx_exchange_subscribe calls so the + active chat stays responsive. +--- + + + +# DSX Exchange MCP + +## Core Workflow + +- Use `dsx_exchange_find_topics` when the user describes a signal, asset, or + domain but does not provide an exact topic filter. +- Use `dsx_exchange_describe_topic` when the user provides a topic or topic + filter and needs schema context, payload shape, parameters, examples, or + related metadata/value topics. +- Use `dsx_exchange_read_retained` for retained metadata and last-known retained + values. For BMS value topics, read related `/Metadata/` topics first when + `describe_topic` returns them. +- Use `dsx_exchange_subscribe` only for bounded live sampling. Always provide a + finite `max_messages` and `max_duration_s`. + +## Background Subscribe + +- Run every `dsx_exchange_subscribe` call through the MCP client's native + background, subagent, task, or equivalent async mechanism when one is + available. +- Apply this by default whenever live sampling is needed, even if the user asks + to "get", "fetch", "read", "sample", "watch", "listen", or "monitor" values + without explicitly asking for background execution. +- Keep schema discovery, topic description, and retained reads inline; only live + subscription calls need background execution. +- If no background mechanism is available, use a short bounded subscribe window + and tell the user that the active chat may block until the tool call returns. + +## Subscribe Defaults + +Use these defaults unless the user asks for a different sampling window or the +topic rate requires a narrower cap: + +```json +{ + "max_messages": 100, + "max_duration_s": 30 +} +``` + +- Narrow broad topic filters before subscribing when possible. +- Prefer one focused subscription over several broad subscriptions. +- Summarize returned messages by topic, latest value, source timestamp, quality, + range, and stop reason. + +## Avoid + +- Do not use shell MQTT clients such as `mosquitto_sub` unless DSX Exchange MCP + is unavailable and the user approves the fallback. +- Do not use removed server-side watch lifecycle tools such as + `start_subscription`, `read_subscription`, `subscription_status`, or + `stop_subscription`. +- Do not ask the user for bearer tokens as tool arguments. The MCP client and + server transport are responsible for passing authentication headers. +- Do not mention local token proxy workflows in released client guidance. From 0bee4bd1c18bff3b7f405d5ca0e5b549b58a672e Mon Sep 17 00:00:00 2001 From: Daniyal Rana Date: Tue, 23 Jun 2026 16:57:36 -0500 Subject: [PATCH 13/27] build(mcp): publish core server build targets Signed-off-by: Daniyal Rana --- mcp/dsx-exchange-mcp/Makefile | 27 +++++++++++++++++---------- mcp/dsx-exchange-mcp/README.md | 3 --- 2 files changed, 17 insertions(+), 13 deletions(-) diff --git a/mcp/dsx-exchange-mcp/Makefile b/mcp/dsx-exchange-mcp/Makefile index 694b50d..c24a2a5 100644 --- a/mcp/dsx-exchange-mcp/Makefile +++ b/mcp/dsx-exchange-mcp/Makefile @@ -2,10 +2,11 @@ # SPDX-License-Identifier: Apache-2.0 BINARY := dsx-exchange-mcp -LOAD_BINARY := dsx-exchange-mcp-load PKG := github.com/NVIDIA/dsx-exchange/mcp/dsx-exchange-mcp SCHEMA_SRC ?= ../../schemas GOFLAGS ?= -mod=vendor +IMAGE_REPOSITORY ?= $(BINARY) +IMAGE_TAG ?= dev SKAFFOLD ?= skaffold KUBECTL ?= kubectl KIND_CONTEXT ?= kind-csc @@ -13,16 +14,25 @@ MCP_NAMESPACE ?= mcp-backends MCP_RELEASE ?= dsx-exchange-mcp MCP_LOCAL_PORT ?= 18080 -.PHONY: build build-load run skaffold-run-kind port-forward-kind test tidy vendor lint sync-specs verify-specs image load-image clean +.PHONY: build run skaffold-run-kind port-forward-kind test tidy vendor lint sync-specs verify-specs image clean help + +help: + @printf 'DSX Exchange MCP targets:\n' + @printf ' build build bin/%s\n' "$(BINARY)" + @printf ' run sync specs, build, and run the MCP server\n' + @printf ' test run Go tests with $(GOFLAGS)\n' + @printf ' lint run go vet\n' + @printf ' sync-specs copy schemas from $(SCHEMA_SRC) into ./schemas\n' + @printf ' verify-specs sync specs and fail if ./schemas changes\n' + @printf ' image build $(IMAGE_REPOSITORY):$(IMAGE_TAG)\n' + @printf ' skaffold-run-kind deploy the MCP backend to local Kind\n' + @printf ' port-forward-kind expose the Kind service on $(MCP_LOCAL_PORT)\n' + @printf ' clean remove local build outputs\n' build: mkdir -p bin go build $(GOFLAGS) -o bin/$(BINARY) ./cmd/$(BINARY) -build-load: - mkdir -p bin - go build $(GOFLAGS) -o bin/$(LOAD_BINARY) ./cmd/$(LOAD_BINARY) - run: sync-specs build ./bin/$(BINARY) @@ -54,10 +64,7 @@ verify-specs: sync-specs git diff --exit-code -- schemas image: - docker build -t $(BINARY):dev . - -load-image: - docker build -f Dockerfile.load -t $(LOAD_BINARY):dev . + docker build -t $(IMAGE_REPOSITORY):$(IMAGE_TAG) . clean: rm -rf bin diff --git a/mcp/dsx-exchange-mcp/README.md b/mcp/dsx-exchange-mcp/README.md index 2ff6875..5b2a92f 100644 --- a/mcp/dsx-exchange-mcp/README.md +++ b/mcp/dsx-exchange-mcp/README.md @@ -93,7 +93,6 @@ cd mcp/dsx-exchange-mcp make sync-specs # copies ../../schemas/ into ./schemas make test make build -make build-load make run # listens on :8080 ``` @@ -106,7 +105,6 @@ Images: ```sh make image # builds dsx-exchange-mcp:dev -make load-image # builds dsx-exchange-mcp-load:dev ``` Run `make sync-specs` before building the server binary or image when the @@ -282,7 +280,6 @@ schema discovery plus finite bounded MQTT reads. - Schema repo — `gitlab-master.nvidia.com/ncp/dsx/event-bus/schema` - Current v1 scope — `docs/current-v1-scope.md` -- Gateway auth interactions — `docs/gateway-auth-interactions.md` - Load validation findings — `docs/load-testing.md` - MCP spec — https://modelcontextprotocol.io/specification/2025-06-18/ - Go SDK — https://github.com/modelcontextprotocol/go-sdk From cde1aa835b72507d9d0721931c077208352b26a2 Mon Sep 17 00:00:00 2001 From: Daniyal Rana Date: Tue, 23 Jun 2026 22:38:12 -0500 Subject: [PATCH 14/27] docs(mcp): polish public release guidance Signed-off-by: Daniyal Rana --- mcp/dsx-exchange-mcp/README.md | 36 ++++++++++++------- .../values.deployed-bus.example.yaml | 11 +++--- .../skills/dsx-exchange-mcp/SKILL.md | 1 - 3 files changed, 29 insertions(+), 19 deletions(-) diff --git a/mcp/dsx-exchange-mcp/README.md b/mcp/dsx-exchange-mcp/README.md index 5b2a92f..c5e1fec 100644 --- a/mcp/dsx-exchange-mcp/README.md +++ b/mcp/dsx-exchange-mcp/README.md @@ -111,7 +111,20 @@ Run `make sync-specs` before building the server binary or image when the monorepo `schemas/` tree has changed. The image uses the already-synced `./schemas` tree and does not fetch schemas at runtime. -Environment: +## MCP client skill + +Client-facing agent guidance lives at `skills/dsx-exchange-mcp/SKILL.md`. +MCP clients or agent runtimes that support skill, rule, or instruction import +can use that file to teach agents the intended workflow: + +- use schema discovery tools inline +- use retained reads for metadata and last-known retained values +- run `dsx_exchange_subscribe` through a background agent, subagent, task, or + equivalent mechanism when available + +The skill is client-agnostic and does not include client-specific setup. + +## Environment | Var | Default | Notes | | --- | --- | --- | @@ -192,7 +205,7 @@ This path intentionally does not require an MCP gateway. The Kind values use ## Setup checklist -Before an MCP client or load test can call broker-backed tools, verify: +Before an MCP client or opt-in validation can call broker-backed tools, verify: | Item | What the operator provides | Where this MCP expects it | | --- | --- | --- | @@ -202,8 +215,8 @@ Before an MCP client or load test can call broker-backed tools, verify: | Broker username | OAuth profile username for MQTT CONNECT in `jwt_passthrough` mode | Helm `mqtt.username`, runtime `MQTT_USERNAME` | | Broker CA | Root/intermediate CA bundle for broker TLS | Secret referenced by `mqtt.tls.caCertSecret.name/key` | | TLS server name | Broker certificate server name, if needed | Helm `mqtt.tls.serverName`, runtime `MQTT_TLS_SERVER_NAME` | -| Caller JWT | Fresh user/service bearer from approved secret manager flow when using `jwt_passthrough` | MCP `Authorization: Bearer ...`; load secret key `bearer` | -| Allowed topics | Topics the caller JWT is authorized to read | E2E/load env topic inputs | +| Caller JWT | Fresh user/service bearer from the deployment's approved identity flow when using `jwt_passthrough` | MCP `Authorization: Bearer ...` | +| Allowed topics | Topics the caller JWT is authorized to read | E2E env topic inputs | If schema tools work but broker-backed tools return auth or subscribe errors, debug in this order: bearer freshness, broker CA trust, broker URL/server name, @@ -258,15 +271,14 @@ endpoint. If it is unset, the test starts an in-process MCP server. See `docs/local-llm-mcp-eval.md`. -## Quality and load validation +## Maintainer validation -`cmd/dsx-exchange-mcp-load` creates many MCP sessions and records JSON, text, -and CSV reports with per-operation latency and error attribution. Prompt quality -is covered by fixture-based Go tests and the opt-in local LLM eval. +Prompt quality is covered by fixture-based Go tests and the opt-in local LLM +eval. Load validation is maintainer-oriented and intentionally separate from +the public build/run path. -Use `docs/load-testing.md` for load-test scenarios, reproduction requirements, -and summarized findings from the current branch. Raw report bundles are local -evidence and should stay under ignored `reports/`. +Use `docs/load-testing.md` only when intentionally running load experiments. +Raw report bundles are local evidence and should stay under ignored `reports/`. ## Status @@ -278,8 +290,6 @@ schema discovery plus finite bounded MQTT reads. ## References -- Schema repo — `gitlab-master.nvidia.com/ncp/dsx/event-bus/schema` - Current v1 scope — `docs/current-v1-scope.md` -- Load validation findings — `docs/load-testing.md` - MCP spec — https://modelcontextprotocol.io/specification/2025-06-18/ - Go SDK — https://github.com/modelcontextprotocol/go-sdk diff --git a/mcp/dsx-exchange-mcp/deploy/helm/dsx-exchange-mcp/values.deployed-bus.example.yaml b/mcp/dsx-exchange-mcp/deploy/helm/dsx-exchange-mcp/values.deployed-bus.example.yaml index a1b415f..85660d9 100644 --- a/mcp/dsx-exchange-mcp/deploy/helm/dsx-exchange-mcp/values.deployed-bus.example.yaml +++ b/mcp/dsx-exchange-mcp/deploy/helm/dsx-exchange-mcp/values.deployed-bus.example.yaml @@ -1,11 +1,12 @@ # Copyright 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. # SPDX-License-Identifier: Apache-2.0 -# Example overlay for gated e2e / dev testing against the deployed DSX Exchange -# bus. Do not commit real bearer tokens or CA material; create the referenced -# Secret out of band in the target namespace. +# Example overlay for gated e2e / dev testing against a deployed DSX Exchange +# bus. Replace the placeholder broker host with the target environment. Do not +# commit real bearer tokens or CA material; create the referenced Secret out of +# band in the target namespace. -natsURL: tls://event-bus-ytl-dev2.dev.dsx.nvidia.com:1883 +natsURL: tls://:1883 runtimeClassName: "" @@ -16,5 +17,5 @@ mqtt: caCertSecret: name: dsx-exchange-broker-ca key: ca.crt - serverName: event-bus-ytl-dev2.dev.dsx.nvidia.com + serverName: insecureSkipVerify: false diff --git a/mcp/dsx-exchange-mcp/skills/dsx-exchange-mcp/SKILL.md b/mcp/dsx-exchange-mcp/skills/dsx-exchange-mcp/SKILL.md index 907f8d4..d695c31 100644 --- a/mcp/dsx-exchange-mcp/skills/dsx-exchange-mcp/SKILL.md +++ b/mcp/dsx-exchange-mcp/skills/dsx-exchange-mcp/SKILL.md @@ -68,4 +68,3 @@ topic rate requires a narrower cap: `stop_subscription`. - Do not ask the user for bearer tokens as tool arguments. The MCP client and server transport are responsible for passing authentication headers. -- Do not mention local token proxy workflows in released client guidance. From f66c888fdc3c7d587064160d384900ff53983941 Mon Sep 17 00:00:00 2001 From: Daniyal Rana Date: Wed, 24 Jun 2026 10:40:03 -0500 Subject: [PATCH 15/27] docs(mcp): clarify standalone local deployment Signed-off-by: Daniyal Rana --- .gitignore | 2 + local/README.md | 5 + mcp/dsx-exchange-mcp/.gitignore | 15 + mcp/dsx-exchange-mcp/Architecture.md | 27 +- mcp/dsx-exchange-mcp/README.md | 7 +- .../dsx-exchange-mcp/templates/service.yaml | 1 - mcp/dsx-exchange-mcp/docs/current-v1-scope.md | 14 +- .../docs/dsx-exchange-mcp-sdd.md | 982 ------------------ .../docs/local-llm-mcp-eval.md | 109 -- .../docs/long-running-subscriptions-ux.md | 132 --- .../docs/sdd-discussion-notes.md | 549 ---------- .../v1-background-watch-benchmark-plan.md | 208 ---- .../docs/watch-state-tradeoff-note.md | 180 ---- 13 files changed, 40 insertions(+), 2191 deletions(-) delete mode 100644 mcp/dsx-exchange-mcp/docs/dsx-exchange-mcp-sdd.md delete mode 100644 mcp/dsx-exchange-mcp/docs/local-llm-mcp-eval.md delete mode 100644 mcp/dsx-exchange-mcp/docs/long-running-subscriptions-ux.md delete mode 100644 mcp/dsx-exchange-mcp/docs/sdd-discussion-notes.md delete mode 100644 mcp/dsx-exchange-mcp/docs/v1-background-watch-benchmark-plan.md delete mode 100644 mcp/dsx-exchange-mcp/docs/watch-state-tradeoff-note.md diff --git a/.gitignore b/.gitignore index ec17304..616a388 100644 --- a/.gitignore +++ b/.gitignore @@ -8,3 +8,5 @@ /local/nats/secrets/ .vscode/ +.cursor/ +.gocache/ diff --git a/local/README.md b/local/README.md index d8118b7..c91be3f 100644 --- a/local/README.md +++ b/local/README.md @@ -173,6 +173,11 @@ is a direct backend deployment, not an MCP gateway deployment. It is intended for manual MCP client checks against the same local Event Bus services used by the e2e tests. +This path uses the normal local Event Bus clusters only: `kind-csc`, +`kind-cpc-1`, and `kind-cpc-2`. The MCP backend runs as a Helm release in +`kind-csc` under namespace `mcp-backends`; no separate MCP gateway cluster is +created or required. + After `make skaffold-run`, expose the MCP backend locally: ```bash diff --git a/mcp/dsx-exchange-mcp/.gitignore b/mcp/dsx-exchange-mcp/.gitignore index 31d0004..1f07cf7 100644 --- a/mcp/dsx-exchange-mcp/.gitignore +++ b/mcp/dsx-exchange-mcp/.gitignore @@ -10,3 +10,18 @@ context/ /schema/ /internal/specs/data/* !/internal/specs/data/.gitkeep + +# Local-only MCP validation helpers; not part of the released server. +/cmd/dsx-exchange-token-proxy/ +/deploy/local-check/ +/docs/gateway-auth-interactions.md +/docs/local-deployment-check.md +/docs/todo.md + +# Historical design notes kept locally, not in the public MCP docs tree. +/docs/dsx-exchange-mcp-sdd.md +/docs/local-llm-mcp-eval.md +/docs/long-running-subscriptions-ux.md +/docs/sdd-discussion-notes.md +/docs/v1-background-watch-benchmark-plan.md +/docs/watch-state-tradeoff-note.md diff --git a/mcp/dsx-exchange-mcp/Architecture.md b/mcp/dsx-exchange-mcp/Architecture.md index 7db0016..d09daaf 100644 --- a/mcp/dsx-exchange-mcp/Architecture.md +++ b/mcp/dsx-exchange-mcp/Architecture.md @@ -5,9 +5,9 @@ is intentionally code-centric: which files own which behavior, how a request flo through the service, and how configuration shapes runtime behavior. The server is designed to run **standalone**. Any HTTP MCP client that speaks -Streamable HTTP can call it directly at `/mcp`. AgentGateway (or any -other reverse proxy) is an optional front door for production aggregation and -coarse auth — not a requirement for the server to function. +Streamable HTTP can call it directly at `/mcp`. A reverse proxy or MCP gateway +may sit in front of the server in some deployments, but the server does not +depend on one. ## Big Picture @@ -603,10 +603,10 @@ make port-forward-kind ## Optional Gateway Integration -When deployed behind a Latinum MCP Gateway, this server is one upstream backend -among potentially many. The gateway validates caller JWTs, applies coarse MCP -authorization, and forwards the original HTTP headers unchanged. From the -server's perspective the request flow is identical to a direct client call. +When deployed behind a gateway, this server is one upstream backend among +potentially many. From the server's perspective the request flow is identical +to a direct client call: the upstream receives ordinary Streamable HTTP requests +on `/mcp`. ```text MCP client @@ -615,25 +615,12 @@ MCP client -> pod /mcp ``` -The Helm Service optionally advertises MCP to gateway discovery: - -```yaml -ports: - - name: mcp - port: 8080 - targetPort: mcp - appProtocol: agentgateway.dev/mcp -``` - A gateway upstream entry targets this service by name, namespace, labels, port, and pod selector. In multi-upstream gateway deployments, tool names may appear with an upstream prefix (for example `dsx-exchange-mcp-mcp_dsx_exchange_subscribe`). The exact external name depends on gateway upstream naming. -See `docs/gateway-auth-interactions.md` for the full gateway ↔ upstream ↔ broker -auth matrix. - ## What To Change For Common Tasks ### Add a new MCP tool diff --git a/mcp/dsx-exchange-mcp/README.md b/mcp/dsx-exchange-mcp/README.md index c5e1fec..2446c0c 100644 --- a/mcp/dsx-exchange-mcp/README.md +++ b/mcp/dsx-exchange-mcp/README.md @@ -185,6 +185,11 @@ backend: make -C local skaffold-run ``` +The local topology uses only the Event Bus Kind clusters created by +`local/infra/scripts/setup-clusters.sh`: `kind-csc`, `kind-cpc-1`, and +`kind-cpc-2`. The MCP server is a Helm release in `kind-csc`, namespace +`mcp-backends`; no separate MCP gateway cluster is part of this path. + To deploy or redeploy only the MCP backend after the local stack already exists: ```sh @@ -269,8 +274,6 @@ logs the tool trace, and compares the model's final tool plan with Set `DSX_EXCHANGE_MCP_URL` to a local process or port-forwarded Kind `/mcp` endpoint. If it is unset, the test starts an in-process MCP server. -See `docs/local-llm-mcp-eval.md`. - ## Maintainer validation Prompt quality is covered by fixture-based Go tests and the opt-in local LLM diff --git a/mcp/dsx-exchange-mcp/deploy/helm/dsx-exchange-mcp/templates/service.yaml b/mcp/dsx-exchange-mcp/deploy/helm/dsx-exchange-mcp/templates/service.yaml index ef7d176..cb1cfb8 100644 --- a/mcp/dsx-exchange-mcp/deploy/helm/dsx-exchange-mcp/templates/service.yaml +++ b/mcp/dsx-exchange-mcp/deploy/helm/dsx-exchange-mcp/templates/service.yaml @@ -15,4 +15,3 @@ spec: - name: mcp port: {{ .Values.service.port }} targetPort: mcp - appProtocol: agentgateway.dev/mcp diff --git a/mcp/dsx-exchange-mcp/docs/current-v1-scope.md b/mcp/dsx-exchange-mcp/docs/current-v1-scope.md index 170049e..03ffb61 100644 --- a/mcp/dsx-exchange-mcp/docs/current-v1-scope.md +++ b/mcp/dsx-exchange-mcp/docs/current-v1-scope.md @@ -6,25 +6,23 @@ SPDX-License-Identifier: Apache-2.0 # DSX Exchange MCP Current V1 Scope This note records the current implementation scope for `dsx-exchange-mcp`. -When older planning docs conflict with this note, use this note as the current -source of truth. +Use this note as the current source of truth when older design discussions or +issue notes conflict with the shipped v1 surface. ## Document Precedence Planning docs should be read newest-first. Later docs capture newer product and -engineering decisions, so they supersede older SDD language when scope or +engineering decisions, so they supersede older design language when scope or priority differs. For the current branch: 1. `current-v1-scope.md` 2. `mcp-tasks-vs-explicit-async-tools.md` -3. `long-running-subscriptions-ux.md` -4. `dsx-exchange-mcp-sdd.md` -5. Earlier tradeoff, benchmark, discussion, and eval notes -The SDD remains useful for architecture context, but it is broader than the -current implementation target. +The repository no longer keeps historical watch/stateful-session design notes in +the public MCP docs tree. `Architecture.md`, `README.md`, and this file describe +the current server shape. ## In Scope For Current V1 diff --git a/mcp/dsx-exchange-mcp/docs/dsx-exchange-mcp-sdd.md b/mcp/dsx-exchange-mcp/docs/dsx-exchange-mcp-sdd.md deleted file mode 100644 index a9abee3..0000000 --- a/mcp/dsx-exchange-mcp/docs/dsx-exchange-mcp-sdd.md +++ /dev/null @@ -1,982 +0,0 @@ -# DSX Exchange MCP - -## Aggregated Requirements - -This section is intentionally placed before the normal SDD template sections so -reviewers can see the DSX Exchange MCP requirements in one place before reading -the design. - -### MCP Requirements From DSX Exchange PRD - -The DSX Exchange PRD defines MCP as a first-class Exchange interface. The PRD -contains duplicate entries for `MCP-13` and `MCP-14`; this SDD treats each -requirement once. - -| ID | Priority | Aggregated Requirement | -| :--- | :---: | :--- | -| MCP-1 | P0 | Provide one MCP endpoint where agents discover every tool they are authorized to use across topology, power, health, events, and logs. | -| MCP-2 | P0 | Expose MCP-compliant tool schemas with clear input types and response structures. | -| MCP-3 | P0 | Scope tool discovery to caller permissions so unauthorized tools do not appear. | -| MCP-4 | P0 | Support infrastructure topology queries for nodes, switches, racks, and physical relationships. | -| MCP-5 | P0 | Include cooling-to-compute relationships in topology results. | -| MCP-6 | P0 | Support pre-flight queries for power headroom, cooling capacity, and available compute before provisioning. | -| MCP-7 | P0 | Use the same real-time power telemetry source as MaxLPS / DSX LPS. | -| MCP-8 | P1 | Support resource health, availability, and utilization queries across compute, cooling, and power. | -| MCP-9 | P1 | Surface correlated anomalies such as CDU flow drops co-located with GPU temperature rise. | -| MCP-10 | P1 | Let agents subscribe to curated Exchange event topics through MCP tools, pre-filtered by domain and relevance. | -| MCP-11 | P1 | Surface BMS leak events with operational context such as rack ID, affected compute, and recommended action. | -| MCP-12 | P2 | Retrieve operational logs and correlated event context through one MCP workflow. | -| MCP-13 | P1 | Support asynchronous tasks with status, TTL, and persistence for long-running operations. | -| MCP-14 | P1 | Show long-running agent tasks in audit logs with status and initiating identity. | -| MCP-15 | P0 | Require NVIDIA-approved authentication and token validation before any MCP tool invocation. | -| MCP-16 | P0 | Validate that tokens were issued for the intended protected resource. | -| MCP-17 | P0 | Retrieve credentials from a centralized secret store and avoid local client-secret storage. | -| MCP-18 | P0 | Audit every MCP tool invocation with caller identity, timestamp, tool name, and input parameters. | -| MCP-19 | P1 | Return structured, actionable MCP errors. | -| MCP-20 | P1 | Support pagination or cursoring for large result sets. | -| MCP-21 | P1 | Provide mock or stub mode for the MCP Gateway and all Exchange tools. | -| MCP-22 | P1 | Use the same factory data model across Exchange events, APIs, and MCP tools. | -| MCP-23 | P1 | Provide a documented pattern and reference implementation for partner-built agents. | -| MCP-24 | P0 | Treat MCP as a first-class Exchange interface with reliability, auth, observability, and documentation equivalent to the MQTT layer. | -| MCP-25 | P0 | Resolve and document the relationship between read-only MCP Gateway and action-taking CLI/TUI MCP surfaces. | - -### Long-Running Subscription UX Requirements - -The long-running subscription UX note adds the following requirements for -background Exchange watches. - -| ID | Priority | Aggregated Requirement | -| :--- | :---: | :--- | -| LSUB-1 | P0 | Start a long-running Exchange subscription and return a `subscription_id` immediately. | -| LSUB-2 | P0 | Allow users to ask an agent to watch a topic or domain without knowing raw MQTT hierarchy. | -| LSUB-3 | P0 | Read buffered messages by cursor with bounded `max_messages` and `max_bytes`. | -| LSUB-4 | P0 | Summarize what happened since a watch started. | -| LSUB-5 | P0 | Report subscription status such as running, reconnecting, expired, denied, or buffer overflow. | -| LSUB-6 | P1 | Emit optional MCP / SSE notifications when messages arrive or status changes. | -| LSUB-7 | P1 | Compute aggregations over a background stream such as counts, latest values, min/max/avg, or grouping by topic/object type. | -| LSUB-8 | P1 | Dump bounded raw message batches as JSON or JSONL for debugging. | -| LSUB-9 | P1 | Export a subscription to approved observability sinks such as Flight Recorder or logs. | -| LSUB-10 | P0 | Audit every subscription start, read, aggregation, export, and stop with caller identity and arguments. | -| LSUB-11 | P0 | Stop subscriptions explicitly and expire idle or over-TTL subscriptions automatically. | -| LSUB-12 | P1 | Return structured errors for ACL denial, authentication failure, reconnect exhaustion, buffer overflow, and expired subscriptions. | - -### DSX Exchange PRD Requirements Applied To MCP - -The PRD frames Exchange as a shared communication layer for compute, network, -power, cooling, IT, OT, and agents. For the MCP interface this means: - -| Area | Applied MCP Requirement | -| :--- | :--- | -| Exchange Foundation | MCP must expose Exchange as one coherent integration surface, not as separate one-off integrations per system. Events, MCP tools, and APIs must share the same auth model, data model, governance, versioning, and deprecation posture. | -| MQTT Machine Integration | MCP tools must let agents observe BMS, DPS / power-management, grid, NICO, SPIFFE, and other Exchange domains through schema-derived topics without requiring raw MQTT client code. | -| Multi-Site Needs | MCP must preserve Exchange layer isolation and controlled federation. Agents must not use MCP to bypass MDC/VDC/VMS boundaries or site/hall/shard policy. | -| Identity and Security | Every MCP read, watch, export, and future action must be authenticated, authorized by tenant/project/caller identity, scoped in discovery, rate limited, revocable, and audited. | -| Partner Integration | MCP must expose partner-consumable contracts based on AsyncAPI schemas, with staging/mock paths for agent and integration development. | -| Operational Needs | MCP must expose health, readiness, Prometheus metrics, structured JSON logs, and actionable degradation signals for critical Exchange paths. | -| Sovereign Needs | MCP must support air-gapped operation with no runtime dependency on external documentation or schema fetches. | - -### Requirements Inherited From Latinum MCP Server SRD - -| Area | Applied Requirement | -| :--- | :--- | -| Protocol | Implement MCP Streamable HTTP for discovery, invocation, response handling, resources, tools, and server-to-client notifications where supported. | -| Discovery Consistency | A caller must not see tools or resources they are not authorized to invoke or read. Discovery must not be a leak channel. | -| Backend Authorization | The gateway performs coarse authorization and forwards the caller credential unchanged. The upstream server remains responsible for fine-grained authorization. | -| Async Operations | Support asynchronous patterns for long-running operations. For Exchange MCP, this primarily means background watches with status, TTL, audit, and bounded reads. | -| Performance | Keep discovery fast, bound tool execution, support at least 100 concurrent agent connections, and fit within per-tenant gateway rate limits. | -| Reliability | Provide liveness/readiness probes, graceful degradation when Exchange is unavailable, and HA deployment with pod anti-affinity. | -| Security | Use NVIDIA-approved auth, TLS at deployment boundaries, input validation, egress control, centralized secret management for server credentials, no local credential storage, and audit logging. | -| Operability | Deploy as Kubernetes-native workload, emit JSON logs and Prometheus-compatible metrics, support configuration by environment / ConfigMap, and support zero-downtime rolling upgrades where active in-memory watches permit it. | -| Testability | Provide automated tests, performance validation, and mock/stub mode for agent development. | - -### Requirements Inherited From Latinum MCP Gateway SDD - -| Area | Applied Requirement | -| :--- | :--- | -| Gateway Topology | `dsx-exchange-mcp` is an upstream MCP server behind the Latinum MCP Gateway, not a separate external endpoint for agents in production. | -| Bearer Passthrough | The gateway validates the caller JWT and forwards `Authorization: Bearer ` unchanged to the upstream. | -| Coarse + Fine Auth | Gateway CEL policy filters MCP items coarsely. `dsx-exchange-mcp` and the Exchange broker enforce fine-grained resource and topic authorization. | -| Stateful Sessions | Agentgateway stateful MCP session routing uses `Mcp-Session-Id` to keep follow-up requests pinned to the resolved upstream pod. | -| Selector Targets | Production upstream registration must use selector-based targets so session routing can pin to individual backend pods. | -| Aggregation Prefixes | In multi-upstream gateway deployments, tools are exposed with gateway target prefixes. The SDD must describe bare upstream names and gateway-prefixed names where relevant. | -| Failure Semantics | MCP-layer denials return JSON-RPC errors in SSE frames; auth/rate-limit/malformed requests may return HTTP errors before reaching the upstream. | -| Audit Correlation | Gateway and upstream logs correlate through timestamp, caller identity, and MCP session ID. | - -### Requirements Inherited From Latinum Event Bus SRD / SDD - -| Area | Applied Requirement | -| :--- | :--- | -| Protocol | DSX Exchange uses MQTT 3.1.1 as the client-facing protocol on NATS. | -| Auth | OAuth2 / SSA JWTs may be supplied in the MQTT password field. Auth methods on one connection are mutually exclusive. | -| Authorization | Publish and subscribe permissions are enforced by predefined topic/subject patterns with dynamic wildcard matching. | -| Source Of Truth | Broker/auth-callout enforcement is authoritative for MQTT access. MCP must not duplicate policy in a divergent way. | -| Federation | MCP must respect Exchange federation, topic prefixing, and layer isolation. It observes the topic space visible to the caller's broker account. | -| Persistence | Retained messages and QoS state are broker/JetStream concerns. MCP does not become a durable event database. | -| Schemas | The bus is schema agnostic, but DSX Exchange participants publish formal AsyncAPI specs for every exposed subject and payload. | -| Observability | Exchange components must emit stdout logs, Prometheus metrics, and health endpoints. | -| Performance | MCP must protect the broker from unbounded agent reads through caps, cursors, probe limits, and per-pod concurrency limits. | - -# Software Architecture & Design Document (PLC-L1 SADD v2018-02-07/NGC) - -## Revision History - -| Version | Date | Modified By | Description | -| :---: | :---: | :--- | :--- | -| 0.1 | May 18, 2026 | Codex | Initial DSX Exchange MCP SDD draft from PRD, SRD/SDD inputs, handoff notes, and long-running subscription UX. | - -## Stakeholder Approvals - -| Stakeholder Name | Role | Date | Approver Comments | -| :--- | :---: | :---: | :--- | -| TBD | pic | | | -| TBD | prod | | | -| TBD | arch | | | -| TBD | eng | | | -| TBD | qa | | | -| TBD | sec | | | - -# 1 Introduction - -## 1.1 Overview - -`dsx-exchange-mcp` is the DSX Exchange upstream MCP server for agents. It sits -behind the Latinum MCP Gateway and exposes Exchange schemas, topic discovery, -bounded reads, retained metadata reads, and long-running background watches over -DSX Exchange MQTT topics. - -The production north-star is: - -```text -Agent / MCP client - -> Latinum MCP Gateway /mcp - -> dsx-exchange-mcp upstream MCP server - -> DSX Exchange MQTT broker - -> NATS auth-callout and broker ACL enforcement -``` - -The MCP interface does not replace DSX Exchange as the system-to-system event -bus. It gives agents a safe, discoverable, audited way to observe Exchange data -and reason over factory state. Read-oriented MCP functionality is in scope for -this SDD. Action-taking CLI/TUI MCP integration is identified by the PRD as a -required architectural decision, but direct write tools are not introduced here -until each action surface has explicit topic, setpoint, authorization, and audit -constraints. - -This SDD distinguishes three Exchange MCP data paths: - -1. **Schema and topic discovery.** Agents read AsyncAPI-derived resources and - call helper tools that map user intent to authorized Exchange topics. -2. **Bounded reads.** Agents read retained metadata or collect live messages - for bounded windows with message, duration, and byte caps. -3. **Background watches.** Agents start long-running MQTT subscriptions that - continue in the background, buffer bounded data under a `subscription_id`, - and are consumed through cursor reads, summaries, aggregations, optional - notifications, and explicit stop/expiry. - -## 1.2 Assumptions, Constraints, Dependencies - -### Assumptions - -* Production callers use the Latinum MCP Gateway. Direct access to - `dsx-exchange-mcp` is for development and diagnostics. -* The gateway validates caller JWTs, applies coarse MCP item authorization, and - forwards the original bearer to upstream MCP servers. -* DSX Exchange broker/auth-callout remains authoritative for MQTT - authentication and topic ACLs. -* AsyncAPI specs are the canonical machine-readable Exchange schema source for - MCP topic discovery and helper tools. -* v1 background watches use pod-local, in-memory state owned by the - session-pinned `dsx-exchange-mcp` pod and are lost on pod restart or session - loss. -* v1 intentionally optimizes for a simple, bounded background-watch UX before - adding external durable watch infrastructure. Strict stateless compliance for - watches requires externalizing watch metadata, cursors, and buffers. - -### Constraints - -* MCP discovery must not expose tools, resources, schema domains, or channels - the caller is not authorized to use. -* The MCP server must not hold broad service credentials for user data access. -* The MCP server must not become a durable event database or unrestricted data - export path. -* Raw MQTT topic filters supplied by agents must be validated and bounded. -* Long-running watches must have TTL, idle expiry, buffer limits, and explicit - overflow policy. -* Streamable HTTP / SSE notifications are optional acceleration signals; they - are not the only reliable data consumption mechanism. - -### Dependencies - -| Dependency | Purpose | -| :--- | :--- | -| Latinum MCP Gateway | External MCP endpoint, caller authentication, coarse discovery filtering, rate limiting, bearer passthrough, stateful session routing. | -| DSX Exchange MQTT broker | MQTT 3.1.1 topic subscription, retained messages, broker-level ACL enforcement. | -| NATS auth-callout | Validates MQTT credentials and mints effective NATS permissions used by the broker. | -| DSX Exchange schema repository | AsyncAPI specs for BMS, power-management, NICO, SPIFFE exchange, and future domains. | -| Flight Recorder / observability stack | Approved export and incident-evidence destination for selected background watches. | -| Central secret store | Source of broker CA material or service-side credentials that are not caller credentials. | -| External watch state store, future | Valkey, Redis, JetStream, or another approved backend for durable watch metadata, cursors, and buffers when v1 pod-local state is insufficient. | - -## 1.3 Definitions, Acronyms, Abbreviations - -| Term | Definition | -| :--- | :--- | -| MCP | Model Context Protocol. JSON-RPC 2.0 over Streamable HTTP for agent tools, resources, prompts, and notifications. | -| Exchange | DSX Exchange, the AI factory shared communication layer over the Latinum Event Bus. | -| MQTT | MQTT 3.1.1, the DSX Exchange client-facing pub/sub protocol. | -| AsyncAPI | Schema format used by DSX Exchange to define MQTT topics, operations, messages, and payloads. | -| BMS | Building Management System. Publishes physical plant telemetry and metadata. | -| NICO | Managed host / bare-metal state system publishing lifecycle events. | -| SPIFFE | Secure Production Identity Framework For Everyone. Used for workload identity and key distribution. | -| ACL | Access control list. In this design, broker subscribe/publish permissions derived by auth-callout. | -| Background watch | A long-running MQTT subscription created through MCP, identified by `subscription_id`, buffered server-side, and read through bounded calls. | -| Cursor | Monotonic subscription buffer position used to read bounded batches without re-sending all messages. | -| Logical subscription | One MCP-created background watch with its own `subscription_id`, topic filter, cursor, buffer, status, TTL, and audit state. | -| MQTT client | One broker connection authenticated with one effective caller authorization context. A pod may run many MQTT clients. | -| SSE | Server-Sent Events, used by MCP Streamable HTTP for server-to-client response frames and optional notifications. | - -## 1.4 Reference Documents - -| Document | Location | -| :--- | :--- | -| DSX Exchange PRD | `docs/input/[WIP] DSX Exchange PRD.docx` | -| Latinum Event Bus SRD | `../latinum-event-bus-poc/docs/Latinum Event Bus SRD.md` | -| Latinum Event Bus SDD | `../latinum-event-bus-poc/docs/Latinum Event Bus SDD.md` | -| Latinum MCP Server SRD | `../dsx-mcp/Latinum MCP Server - SRD.md` | -| Latinum MCP Gateway SDD | `../dsx-mcp/Latinum MCP Gateway - SDD.md` | -| DSX MCP / Exchange MCP Handoff | `../DSX_MCP_HANDOFF.md` | -| DSX Exchange MCP Discussion Notes | `docs/sdd-discussion-notes.md` | -| Long-Running Subscription UX | `docs/long-running-subscriptions-ux.md` | -| DSX Exchange Schema README | `../schema/README.md` | -| AsyncAPI specs | `../schema/specs/*.yaml` | - -# 2 Architecture Details - -## 2.1 System Context - -`dsx-exchange-mcp` is an upstream MCP server. It is not exposed directly to -agents in the production profile. Agents authenticate to the Latinum MCP Gateway -and receive an aggregated tool/resource catalogue filtered by gateway policy. -When an Exchange tool is invoked, the gateway forwards the caller bearer to -`dsx-exchange-mcp`. The upstream server uses that bearer when connecting to the -Exchange MQTT broker. - -```plantuml -@startuml -!theme plain -skinparam componentStyle rectangle - -actor "Agent / MCP client" as client -component "Latinum MCP Gateway\n(agentgateway)" as gateway -component "dsx-exchange-mcp\n(upstream MCP)" as mcp -component "DSX Exchange MQTT broker\n(NATS MQTT facade)" as broker -component "NATS auth-callout" as auth -database "AsyncAPI bundle\n(build-time embed)" as specs -component "Flight Recorder /\nObservability sinks" as flight - -client --> gateway : MCP Streamable HTTP\nAuthorization: Bearer JWT -gateway --> mcp : MCP upstream request\nAuthorization preserved -mcp --> broker : MQTT CONNECT\nusername=oauth profile\npassword=caller JWT -broker --> auth : auth request -auth --> broker : effective permissions -mcp --> specs : read schema index -mcp --> flight : approved export only -@enduml -``` - -## 2.2 Request Flow For Bounded Reads - -```plantuml -@startuml -!theme plain -autonumber -actor Client -participant "Latinum MCP Gateway" as GW -participant "dsx-exchange-mcp" as MCP -participant "MQTT Broker" as MQTT -participant "auth-callout" as AUTH - -Client -> GW : tools/call dsx_exchange_read_retained\nAuthorization: Bearer JWT -GW -> GW : validate JWT, rate limit,\ncoarse MCP authorization -GW -> MCP : tools/call\nAuthorization: Bearer JWT -MCP -> MCP : validate args and limits -MCP -> MQTT : CONNECT username=\npassword= -MQTT -> AUTH : validate credential and derive ACLs -AUTH --> MQTT : permissions -MCP -> MQTT : SUBSCRIBE topic_filter -MQTT --> MCP : SUBACK allowed or denied -MCP -> MCP : collect bounded retained/live messages -MCP --> GW : structured MCP result or error -GW --> Client : SSE-framed MCP response -@enduml -``` - -The upstream server does not decide whether a caller is allowed to subscribe to -a topic by trusting claims alone. It asks the broker through the actual MQTT -connect/subscribe path, or through a future entitlement API backed by the same -permission manager. - -## 2.3 JWT Passthrough To MQTT - -The caller bearer follows this path: - -```text -Client Authorization header - -> Latinum MCP Gateway validation - -> gateway upstream Authorization passthrough - -> dsx-exchange-mcp request context - -> MQTT CONNECT password - -> NATS auth-callout validation and permission derivation -``` - -The MQTT username is deployment configuration. The bearer is never accepted as -an MCP tool argument and is never logged. `dsx-exchange-mcp` logs only whether a -bearer was present plus normalized caller identity fields supplied by the -gateway or derived from the validated context. - -This design keeps one authority for MQTT topic access. The gateway can hide or -block MCP items coarsely, but broker SUBACK denial remains the final control for -actual topic subscription. - -## 2.4 AsyncAPI Schema Access And Tool Generation - -The server embeds AsyncAPI specs at build time. At startup it parses each -non-empty domain spec into a schema access index: - -| Index Field | Purpose | -| :--- | :--- | -| Domain | `bms`, `power-management`, `nico`, `spiffe-exchange`, and future domains. | -| Channel name | Stable AsyncAPI channel key. | -| MQTT address | Raw channel address, including parameters such as `{pointType}` or `{tagPath}`. | -| MQTT filter examples | Agent-safe filters derived from parameters, enums, and documented examples. | -| NATS subject pattern | MQTT-to-NATS conversion used for entitlement intersection. | -| Operation direction | Whether the channel is for publish, subscribe, or both from a consumer viewpoint. | -| Message schema | Payload schema reference and content type. | -| Domain hints | Operational labels such as metadata, value, leak, power, keyset, state transition. | - -The index supports four MCP behaviors: - -1. Resource reads for authorized schema domains. -2. Topic-finding tools that map user intent to schema-derived filters. -3. Curated domain tools for BMS metadata, topology graph extraction, power - events, NICO state transitions, and SPIFFE public keysets. -4. Authorization-aware hiding of domains/channels the caller cannot subscribe - to. - -The initial domain interpretation is: - -| Domain | MCP Treatment | -| :--- | :--- | -| BMS | Parse Value / Metadata channel pairs. Prefer retained metadata reads before live value subscriptions. Build relationships from metadata fields such as object IDs, process area, served load IDs, rack/CDU/power/cooling relationships, and integration ownership. | -| Power Management | Parse CloudEvents channels for grid load target, power state status, breach alert, and enforcement outcomes. Provide topic discovery and event summaries; write actions are out of scope until control constraints are approved. | -| NICO | Parse managed host state topics and expose state transition watches, counts, and machine-scoped filters. | -| SPIFFE Exchange | Parse public keyset topics and expose read-only discovery of tenant/kid key distribution where authorized. | - -## 2.5 Schema Visibility And ACLs - -Schema visibility must match effective subscribe authorization. A caller should -not see a domain or channel that is not relevant to any topic they can read. - -### Tactical V1: Canonical Broker Probes - -When no entitlement API is available, the server can perform bounded MQTT -authorization probes using the caller bearer: - -```text -MQTT CONNECT with caller bearer -MQTT SUBSCRIBE canonical schema filter -observe SUBACK success or denial -cache decision briefly, no longer than token expiry -``` - -Example canonical probes: - -| Domain | Probe Filter | -| :--- | :--- | -| BMS metadata | `BMS/v1/PUB/Metadata/#` | -| BMS values | `BMS/v1/PUB/Value/#` | -| Power management | schema-derived grid/power-management subscribe prefixes | -| NICO | `nico/v1/machine/+/state` or deployment-approved equivalent | -| SPIFFE Exchange | `spiffe-exchange/v1/pub-keysets/tenant/+/kid/+` or tenant-scoped equivalent | - -Probe limits are mandatory: short connect and subscribe timeouts, low probe -counts per discovery request, fail-closed on ambiguous broker/network errors, -and metrics for probe latency and failures. - -The limitation is false negatives for narrow ACLs. If a caller can read only a -specific rack path, a broad domain probe may fail even though a subset of the -schema applies. That limitation is why canonical probes are tactical, not the -preferred production design. - -### Preferred Production: Entitlement API - -The preferred design is a read-only entitlement API backed by the same -permission manager used by auth-callout. It exposes the effective subscribe -decision or effective permissions without becoming a second source of truth. -The broker still enforces actual MQTT access. - -The entitlement API can support: - -```http -POST /v1/authorize -{ - "token": "", - "action": "mqtt.subscribe", - "topic_filter": "BMS/v1/PUB/Metadata/#" -} -``` - -or: - -```http -POST /v1/effective-permissions -{ - "token": "", - "protocol": "mqtt" -} -``` - -`dsx-exchange-mcp` maps effective MQTT/NATS permissions to schema capabilities -by intersecting ACL patterns with the AsyncAPI access index. Deny rules take -precedence. If a partial allow/deny combination cannot be represented safely, -the channel is hidden or exposed only through a narrower recommended filter. - -## 2.6 Long-Running Background Watches - -Long-running Exchange subscriptions are modeled as background watches. A normal -MCP `tools/call` is not held open forever. - -```plantuml -@startuml -!theme plain -autonumber -actor User -participant "Agent" as Agent -participant "Latinum MCP Gateway" as GW -participant "Session-pinned\ndsx-exchange-mcp pod" as MCP -participant "MQTT Broker" as MQTT - -User -> Agent : Watch BMS leak events for row 3 -Agent -> GW : start_subscription(intent/topic) -GW -> MCP : route by MCP session -MCP -> MQTT : CONNECT/SUBSCRIBE with caller bearer -MCP -> MCP : create subscription_id,\nring buffer, status -MCP --> Agent : subscription_id, cursor, TTL -MQTT --> MCP : matching messages -MCP -> MCP : buffer, summarize,\noptional notification -Agent -> GW : read_subscription(cursor) -GW -> MCP : same Mcp-Session-Id\npinned to owning pod -MCP --> Agent : bounded batch + next_cursor -User -> Agent : Stop watching -Agent -> GW : stop_subscription(subscription_id) -GW -> MCP : owning pod -MCP -> MQTT : unsubscribe if no longer referenced -@enduml -``` - -The reliable contract is cursor-based reads and server-side summaries over -bounded buffers. Streamable HTTP / SSE notifications are optional signals for -clients that support them: - -```json -{ - "subscription_id": "sub_123", - "event": "messages_available", - "count": 17, - "severity": "warning", - "summary": "Rack leak event observed for rack R12" -} -``` - -Notifications must not carry unbounded raw payloads. Clients that do not expose -notifications still work by polling `subscription_status` and -`read_subscription`. - -## 2.7 Session Pinning And Pod Ownership - -Background watches require stateful MCP sessions. Agentgateway selector-based -targets and `sessionRouting: Stateful` allow the gateway to resolve an upstream -pod during `initialize` and encode that pod binding in `Mcp-Session-Id`. -Follow-up calls with the same session ID are routed to the same -`dsx-exchange-mcp` pod. - -The v1 recommendation is pod-local state. This is not a global "one pod state" -object; it is three related object types in the owning pod: - -| Object | Stored Attributes | -| :--- | :--- | -| MCP session | `Mcp-Session-Id`, caller identity, authorization fingerprint, creation time, last-seen time, active subscription IDs, and session limits. | -| Logical subscription | `subscription_id`, owner session ID, owner auth fingerprint, topic filter, schema domain/channel, status, cursor range, ring buffer, summary/aggregation state, TTL, idle expiry, message/byte counters, drop counters, and last error. | -| MQTT client | Client ID, broker URL, TLS config fingerprint, MQTT username, effective auth-context key, token expiry, creation time, last-used time, active logical-subscription refcount, broker-subscription refcounts, connection status, reconnect count, and last error. | - -The ring buffer is held on the logical subscription, not on the MQTT client. A -single MQTT client can feed several logical subscriptions for the same effective -authorization context, and each logical subscription keeps its own cursor and -buffer. - -For v1, pod restart, eviction, or session loss terminates active watches. The -client must start a new subscription. Durable cross-pod recovery is future work. -If product requirements require survivable watches across pod restart, the -watch state must move to an external backend such as Valkey for metadata, -cursors, and bounded buffers, or to JetStream / Flight Recorder when durable -event replay is the desired behavior. - -## 2.8 MQTT Connection Grouping - -MQTT connections are authenticated at CONNECT time and carry the effective ACLs -derived from that credential. Sharing a connection across callers can leak -permissions. - -The safe rule is: - -> Never share MQTT connections across distinct effective caller authorization -> contexts. - -There is no global MQTT client per pod. There also is not necessarily one MQTT -client per MCP client. The recommended v1 target is pod-local MQTT client reuse -within one MCP session and one effective authorization context. A bounded tool -call and a background watch may use the same MQTT client when the session, -broker configuration, and effective authorization context match. - -The right pooling unit is: - -```text -MCP session ID - + effective MQTT authorization context - + broker URL / TLS config / MQTT username -``` - -For bounded read tools, a short-lived MQTT client per tool call remains a simple -fallback if implementation risk must be reduced. Production should prefer -session/auth-context reuse to avoid repeated TLS handshakes, MQTT CONNECTs, and -auth-callout evaluations during normal agent workflows. - -The pool key is conservative: - -| Pool Key Component | Reason | -| :--- | :--- | -| Broker URL and TLS config | Keeps broker/security endpoints separate. | -| MQTT username | OAuth profile can affect auth-callout handling. | -| Issuer, subject, authorized party | Captures caller identity. | -| Audience and scopes | Captures token intent. | -| Tenant or persona | Prevents cross-tenant and cross-persona sharing. | -| Token expiry bucket | Prevents use past credential lifetime. | -| Policy version or permissions hash | Required for safe reuse when available. | - -The MQTT client lifecycle is separate from the broker subscription lifecycle: - -| Operation | MQTT Client Behavior | Broker Subscription Behavior | -| :--- | :--- | :--- | -| Bounded read | Get or create the pooled client for the session/auth context. | Create a temporary broker subscription, collect within message/duration/byte limits, then unsubscribe. | -| Background watch | Get or create the pooled client for the session/auth context. | Create a persistent broker subscription, keep it active until stop, TTL, idle expiry, token expiry, or session loss. | - -One pooled MQTT connection may carry multiple broker subscriptions for the same -effective auth context. The server must demultiplex incoming MQTT messages into -temporary bounded-call collectors or persistent logical-subscription buffers. -Broker subscriptions are reference-counted so stopping one logical subscription -or completing one bounded call does not remove a broker subscription still -needed by another active operation. - -The default policy is to share one pooled MQTT client for bounded calls and -background watches within the same session/auth context. A deployment may -promote a watch to a dedicated MQTT client when configured thresholds indicate -isolation is safer, such as high message rate, broad wildcard filters, buffer -pressure, reconnect churn, or measurable latency impact on bounded calls. - -MQTT clients are closed when their active operation refcount reaches zero and -their idle TTL expires. Active operations include temporary bounded calls and -persistent background watches. Clients are also closed on token expiry, max -client lifetime, MCP session close/expiry, policy version change, revocation -signal, reconnect exhaustion, or pod drain. The idle TTL should be short enough -to avoid broker connection accumulation and long enough to avoid reconnect churn -during normal agent workflows. - -# 3 Design Details - -## 3.1 MCP API - -### 3.1.1 Resources - -| Resource | Description | -| :--- | :--- | -| `dsx-exchange://specs/` | Authorized index of visible Exchange AsyncAPI domains. | -| `dsx-exchange://specs/{domain}` | Authorized AsyncAPI spec or filtered schema view for a single domain. | -| `dsx-exchange://subscriptions/{subscription_id}` | Optional status/read resource for a background watch owned by the current session. | - -Resources returned through the gateway may be prefixed by the gateway target -name in multi-upstream deployments. The upstream server continues to expose the -bare `dsx-exchange://` URIs. - -### 3.1.2 Existing Bounded Tools - -| Tool | Purpose | Key Guardrails | -| :--- | :--- | :--- | -| `dsx_exchange_read_retained` | Read retained messages, especially BMS metadata. | Topic validation, auth passthrough, message cap, byte cap, retained idle timeout. | -| `dsx_exchange_subscribe` | Collect live messages for a bounded window. | Topic validation, auth passthrough, message cap, duration cap, byte cap. | - -These tools remain useful for short investigations and tests. They are not the -long-running watch interface. - -### 3.1.3 Schema And Topic Helper Tools - -| Tool | Purpose | -| :--- | :--- | -| `dsx_exchange_find_topics` | Return authorized schema-derived topic filters for a domain and intent such as BMS metadata, BMS values, NICO state, power breach, or SPIFFE keysets. | -| `dsx_exchange_describe_topic` | Explain a topic or channel: domain, expected payload, value/metadata role, examples, and related topics. | -| `dsx_exchange_bms_metadata_snapshot` | Read retained BMS metadata and return normalized point metadata with cursoring. | -| `dsx_exchange_build_bms_graph` | Build a best-effort relationship graph from authorized BMS metadata, including rack, CDU, process area, served load, and point relationships when present. | - -These helpers reduce the need for agents to invent topic paths. They are built -from AsyncAPI, not hard-coded outside the schema index. - -### 3.1.4 Background Watch Tools - -| Tool | Purpose | -| :--- | :--- | -| `dsx_exchange_start_subscription` | Start a background MQTT watch and return `subscription_id`, status, cursor, TTL, and limits. | -| `dsx_exchange_read_subscription` | Read a bounded raw or normalized message batch by cursor. | -| `dsx_exchange_subscription_status` | Return status, counters, buffer state, TTL, idle expiry, and last error. | -| `dsx_exchange_summarize_subscription` | Summarize what changed over a bounded window. | -| `dsx_exchange_aggregate_subscription` | Return counts, latest values, min/max/avg, or grouping by topic/object type where payload shape supports it. | -| `dsx_exchange_export_subscription` | Export bounded watch data to an approved sink such as Flight Recorder or logs. | -| `dsx_exchange_stop_subscription` | Stop a background watch and release broker subscriptions when no longer referenced. | - -`start_subscription` input includes either an explicit `topic_filter` or a -schema-derived selector such as `domain`, `intent`, `object_type`, `point_type`, -and `scope`. If both are provided, the explicit filter must still pass schema -and ACL checks. - -`read_subscription` output includes: - -```json -{ - "subscription_id": "sub_123", - "status": "running", - "messages": [], - "count": 0, - "next_cursor": "42", - "truncated": false, - "dropped_count": 0, - "buffer_watermark": { - "oldest_cursor": "21", - "newest_cursor": "42" - } -} -``` - -### 3.1.5 Error Contract - -All tool failures return structured MCP tool results with `isError=true` and a -JSON body: - -```json -{ - "error": { - "code": "topic_acl_denied", - "message": "mqtt subscribe denied by broker ACL", - "retryable": false - } -} -``` - -Required error codes include: - -| Code | Meaning | -| :--- | :--- | -| `missing_bearer` | Gateway did not pass caller credentials. | -| `invalid_argument` | Tool input failed validation. | -| `invalid_topic_filter` | MQTT filter syntax is invalid or unsafe. | -| `schema_not_visible` | Requested schema/domain/channel is not authorized for this caller. | -| `topic_acl_denied` | Broker denied subscribe. | -| `mqtt_auth_failed` | Broker/auth-callout rejected credential. | -| `bus_unavailable` | Broker or network path unavailable. | -| `subscription_not_found` | Subscription does not exist in this session. | -| `subscription_expired` | Subscription TTL or idle timeout expired. | -| `subscription_not_owner` | Caller/session does not own the subscription. | -| `buffer_overflow` | Data was dropped or subscription failed due to overflow policy. | -| `reconnect_exhausted` | MQTT reconnect attempts exceeded configured limit. | -| `export_denied` | Export destination or operation is not authorized. | - -## 3.2 Security Design - -### 3.2.1 Authentication - -External authentication is performed by the Latinum MCP Gateway. The gateway -rejects missing, expired, invalid-signature, unknown-issuer, or wrong-audience -tokens before the request reaches `dsx-exchange-mcp`. - -`dsx-exchange-mcp` requires the bearer to be present for all data-bearing tools. -It passes the bearer to MQTT as the password. Broker/auth-callout validates the -credential again and derives MQTT/NATS permissions. - -For production, the SDD requires protected-resource validation to be resolved: -the gateway and upstream must agree which token audience is valid for Exchange -MCP and whether token exchange is required before the same bearer can be used -against MQTT. - -### 3.2.2 Authorization - -Authorization has three layers: - -1. **Gateway coarse MCP authorization.** Controls which MCP tools/resources are - visible and callable. -2. **Exchange MCP fine-grained validation.** Validates arguments, schema - visibility, subscription ownership, export destination, and session binding. -3. **Broker ACL enforcement.** Auth-callout and NATS enforce actual MQTT - subscribe/publish permissions. - -All three layers must allow the request. A broker denial is returned as a -structured MCP error, not hidden as an empty result. - -### 3.2.3 Credential Handling - -Caller bearers are held only in memory for the request or active MQTT connection -lifetime. They are never accepted as tool arguments, persisted, exported, or -logged. TLS trust bundles and other server-side configuration come from -deployment configuration or a central secret store. - -If a background watch depends on a caller bearer that expires, the watch must -end at or before token expiry unless a supported token-refresh or token-exchange -mechanism is added. - -### 3.2.4 Data Exfiltration Controls - -Raw reads are bounded by message count and byte limits. Background watch buffers -are bounded. Export is allowed only to configured sinks and must be separately -authorized and audited. Arbitrary URL/file export is not part of the API. - -## 3.3 Other Design Considerations - -### 3.3.1 High Availability - -The deployment runs multiple replicas with pod anti-affinity. Bounded tools are -stateless per call and can run on any pod. Background watches are stateful per -MCP session and are owned by the session-pinned pod. - -Rolling upgrades should drain where possible: - -* Stop accepting new watch starts on terminating pods. -* Continue serving bounded reads during graceful shutdown if time permits. -* Mark active watches as terminating and emit status notifications where - supported. -* Document that v1 clients must resubscribe after pod/session loss. - -Durable cross-pod recovery is future work. -That future work is required for strict compliance with the inherited -stateless-pod requirement for long-running watches. Until then, the SDD treats -background watches as bounded, ephemeral, session-scoped state. - -### 3.3.2 Scalability - -Scalability controls include: - -* Per-pod maximum active MQTT connections. -* Per-auth-context maximum active MQTT connections. -* Per-pod maximum active logical subscriptions. -* Per-caller/session subscription limits. -* Per-MQTT-client maximum broker subscriptions. -* Per-MQTT-client maximum temporary bounded-call subscriptions. -* Dedicated-client thresholds for high-volume or broad-wildcard watches. -* Per-pod total buffer bytes. -* Probe count and timeout limits. -* Message, duration, and byte caps for bounded tools. -* Ring-buffer message and byte caps for background watches. -* Notification rate limits per session. -* Export byte and duration limits. -* Explicit overflow policies: drop-oldest, drop-newest, aggregate-only, or fail. -* Per-tenant rate limiting at the gateway. - -Connection pooling is allowed only within one pod, one MCP session, and one -effective auth context by default. It must not cross tenants, personas, -subjects, scopes, token expiry buckets, or policy versions. Cross-session -pooling for identical service-account contexts is a future optimization and -requires an explicit policy-version or permissions-hash signal. - -The v1 pod-local approach is scalable for bounded numbers of watches because new -MCP sessions are distributed across pods and each pod owns only the watches -pinned to it. It is not sufficient for thousands of high-volume, hours-long -watches that must survive pod restarts. That scale requires additional -infrastructure: - -| Need | Additional Infrastructure | -| :--- | :--- | -| Cross-pod watch recovery | Valkey / Redis or another KV store for watch metadata, owner lease, status, and cursor state. | -| Shared bounded buffers | Valkey streams/lists, Redis streams, or another capped buffer store with TTL and memory quotas. | -| Durable replay | NATS JetStream durable consumers or Flight Recorder-backed replay instead of pod memory. | -| Cross-pod ownership transfer | Lease/heartbeat protocol in the external store plus idempotent MQTT resubscribe. | -| Large export retention | Flight Recorder or approved observability/log storage, not arbitrary MCP file/URL export. | - -The recommended first implementation remains pod-local because it avoids adding -a new state backend before the watch UX, auth rules, and buffer semantics are -validated. - -### 3.3.3 Logging, Metrics, And Debugging - -Structured audit logs include: - -* MCP session ID. -* Caller tenant, issuer, subject, and SPIFFE ID when available. -* Tool name. -* Schema domain/channel or topic filter. -* Subscription ID where applicable. -* Safe argument summary. -* Decision and error code. -* Message count, byte count, duration, cursor, and stop reason. - -Metrics include: - -* Tool calls by tool, result, and error code. -* Active MCP sessions. -* Active background watches. -* Active MQTT connections. -* Broker subscriptions. -* MQTT connect and subscribe latency. -* ACL probe latency and cache hit rate. -* Messages and bytes received. -* Buffered messages and bytes. -* Dropped messages and overflow count. -* Reconnect count and reconnect exhaustion. -* Export attempts and export denials. - -Debugging starts with the MCP session ID and caller identity, then correlates -gateway access logs, upstream audit logs, broker ACL denials, and auth-callout -logs. - -### 3.3.4 Mock / Stub Mode - -Mock mode is required for partner and agent development. It should provide: - -* Static AsyncAPI resources. -* Synthetic BMS metadata and value streams. -* Synthetic power-management CloudEvents. -* Synthetic NICO state transitions. -* Synthetic SPIFFE keyset messages. -* Configurable ACL profiles for allowed and denied domains. -* Background watch behavior including notifications, cursor reads, summaries, - overflow, expiry, and broker-denial simulation. - -Mock mode must not require live Exchange, Nautobot, BMS, LaunchLayer, or broker -dependencies. - -### 3.3.5 Future Work - -Future work includes: - -* Entitlement/effective-permissions API backed by auth-callout. -* Durable cross-pod watch recovery and replay. -* Rich BMS graph model validated against production metadata. -* Correlated anomaly tools across BMS, NICO, power, logs, and metrics. -* Action-taking CLI/TUI MCP relationship to read-only Gateway MCP. -* Partner reference agent implementation. -* Full protected-resource/token-exchange design for gateway-to-MQTT reuse. -* Certification tooling for AsyncAPI compatibility and MCP tool behavior. - -# 4 Alternatives Considered - -## 4.1 Infinite Tool Call Stream - -An infinite `tools/call` response could subscribe to MQTT and stream all -messages over one SSE response. This is rejected as the primary contract because -it ties up one request forever, makes backpressure client-specific, complicates -reconnect and audit behavior, and fails for clients that do not expose -notifications or streaming well. - -## 4.2 Bounded Reads Only - -Bounded reads are simple and safe, but they do not satisfy the UX requirement -to watch factory signals in the background while the user asks follow-up -questions. Bounded reads remain supported but are not sufficient. - -## 4.3 Background Watch With Cursor Reads - -This is the selected v1 design. The server owns the MQTT subscription in the -background, returns a `subscription_id`, stores bounded buffered data, and lets -clients read, summarize, aggregate, export, and stop the watch. Optional SSE -notifications improve latency without becoming the only data path. - -## 4.4 Shared Durable State In V1 - -Shared durable state would allow cross-pod recovery and replay, but it adds a -state store, ownership protocol, cursor persistence, and data retention policy -before the core UX is validated. It is future work unless production -requirements promote durable replay to v1. - -## 4.5 Broker Probes vs Entitlement API - -Broker probes are accurate for exact filters because they use the final -enforcement point, but they are expensive and weak for partial schema -visibility. A read-only entitlement API backed by auth-callout is preferred for -production schema filtering, while broker SUBACK remains final for data access. - -# 5 Operations - -## 5.1 Deployment - -`dsx-exchange-mcp` is deployed as a Kubernetes Deployment and Service behind -the Latinum MCP Gateway. Production deployments use: - -* Runtime isolation compatible with DSX security posture. -* Non-root user, read-only root filesystem, seccomp runtime default, and dropped - Linux capabilities. -* Liveness and readiness probes. -* Prometheus metrics endpoint. -* PodDisruptionBudget and pod anti-affinity. -* NetworkPolicy allowing only required gateway, broker, entitlement, and - observability egress paths. - -## 5.2 Runtime Configuration - -Runtime configuration includes: - -* MCP listen address. -* Broker URL. -* MQTT OAuth username. -* Broker TLS trust configuration. -* Tool message, duration, and byte caps. -* Background watch TTL, idle timeout, buffer caps, and overflow policy. -* MQTT client idle TTL, max lifetime, reconnect budget, and token-expiry safety - margin. -* MQTT pooling mode: short-lived bounded-call clients, session/auth-context - pooled clients, or dedicated clients for noisy watches. -* Per-session, per-auth-context, per-MQTT-client, and per-pod limits. -* Probe timeouts, probe cache TTL, and max probes. -* Optional entitlement API endpoint. -* Optional external watch state backend for durable or cross-pod operation. -* Optional approved export sinks. -* Metrics and log configuration. - -Caller credentials are not configuration. - -## 5.3 Failure Modes - -| Failure | Behavior | -| :--- | :--- | -| Missing bearer | Reject tool call with `missing_bearer`. | -| Invalid gateway token | Gateway rejects before upstream. | -| MQTT auth failure | Return `mqtt_auth_failed`; do not retry indefinitely. | -| Topic ACL denial | Return `topic_acl_denied`; do not treat as empty data. | -| Broker unavailable | Return `bus_unavailable` for bounded calls; background watches enter reconnecting then failed if attempts exhaust. | -| Entitlement unavailable | Fail closed for schema visibility or return degraded discovery without exposing unauthorized schema. | -| Buffer overflow | Apply configured overflow policy and expose status/metrics/audit. | -| Pod restart | v1 active watches are lost; clients resubscribe. | -| MQTT client idle | Close after refcount reaches zero and idle TTL expires. | -| Token expiry | End affected MQTT connections and watches unless supported refresh exists. | -| Policy version change or revocation | Close affected MQTT clients and expire dependent watches. | - -## 5.4 Acceptance Criteria - -The SDD design is complete when it can be reviewed against these outcomes: - -* A caller can discover only authorized Exchange resources/tools. -* A caller can read BMS metadata and subscribe to live values through bounded - tools using the original bearer as the MQTT password. -* Unauthorized topics produce broker-backed structured ACL errors. -* AsyncAPI specs drive topic discovery for BMS, power-management, NICO, and - SPIFFE Exchange. -* A caller can start a background watch, receive `subscription_id`, read by - cursor, ask for status, summarize/aggregate, optionally receive notifications, - export to an approved sink, and stop the watch. -* Background watches are pinned to an upstream pod by MCP session routing. -* MQTT clients are not shared across authorization boundaries. -* Audit logs and metrics cover bounded tools, schema visibility, background - watches, errors, and exports. diff --git a/mcp/dsx-exchange-mcp/docs/local-llm-mcp-eval.md b/mcp/dsx-exchange-mcp/docs/local-llm-mcp-eval.md deleted file mode 100644 index 25ccc46..0000000 --- a/mcp/dsx-exchange-mcp/docs/local-llm-mcp-eval.md +++ /dev/null @@ -1,109 +0,0 @@ -# Local LLM MCP Prompt Eval - -This eval checks whether a local LLM can read an operator prompt, use the -`dsx-exchange-mcp` MCP tools, and produce the expected tool plan from -`internal/server/testdata/tool_call_expectations.json`. - -It is intentionally opt-in because it depends on a local model runtime and can -be nondeterministic. - -## What It Proves - -- The MCP endpoint is reachable and completes the Streamable HTTP initialize - flow. -- `tools/list` exposes the DSX Exchange tools, either directly or with the - Latinum MCP Gateway prefix. -- The LLM actually emits MCP tool calls. -- The harness executes those tool calls and logs the tool trace. -- The LLM's final `planned_tool_calls` JSON contains the expected fixture calls. - -By default, only `dsx_exchange_describe_topic` is exposed to the LLM. The model -must still include the planned `dsx_exchange_read_retained` and -`dsx_exchange_subscribe` calls in its final JSON, but the harness does not hit -the live broker unless explicitly enabled. - -## LLM Endpoint - -The harness expects an OpenAI-compatible local chat completions endpoint: - -```sh -export DSX_EXCHANGE_LLM_BASE_URL=http://127.0.0.1:11434/v1 -export DSX_EXCHANGE_LLM_MODEL='' -``` - -Set `DSX_EXCHANGE_LLM_API_KEY` only if your local endpoint requires one. - -## Direct In-Process MCP Server - -If `DSX_EXCHANGE_MCP_URL` is not set, the test starts an in-process -`dsx-exchange-mcp` server with a test bearer and calls it directly. - -```sh -RUN_EXCHANGE_LLM_MCP_EVAL=1 \ -DSX_EXCHANGE_LLM_BASE_URL=http://127.0.0.1:11434/v1 \ -DSX_EXCHANGE_LLM_MODEL='' \ -go test ./internal/server -run TestLocalLLMMCPPromptEval -count=1 -v -``` - -This path is best for fast local iteration on schema discovery behavior. - -## Through Latinum MCP Gateway - -To evaluate the production-style path, run or port-forward the gateway from the -`dsx-mcp` repo, then point this harness at the gateway `/mcp` endpoint: - -```sh -export DSX_EXCHANGE_MCP_URL=http://localhost:18180/mcp -export DSX_EXCHANGE_E2E_BEARER="$TOKEN" - -RUN_EXCHANGE_LLM_MCP_EVAL=1 \ -DSX_EXCHANGE_LLM_BASE_URL=http://127.0.0.1:11434/v1 \ -DSX_EXCHANGE_LLM_MODEL='' \ -go test ./internal/server -run TestLocalLLMMCPPromptEval -count=1 -v -``` - -The gateway may expose prefixed tools such as -`dsx-exchange-mcp-mcp_dsx_exchange_describe_topic`. The harness passes those -actual names to the LLM, executes them as returned, and normalizes them back to -canonical names when comparing with the fixture. - -## Selecting Cases - -By default the harness runs only the first fixture to keep local iteration fast. -Run one or more named cases with: - -```sh -DSX_EXCHANGE_LLM_EVAL_CASES=bms-rack-temperature-latest,nico-machine-state -``` - -Run all cases by listing all fixture IDs from -`internal/server/testdata/tool_call_expectations.json`. - -## Live Broker Tool Execution - -Leave this off for normal prompt-planning evals: - -```sh -export DSX_EXCHANGE_LLM_EXECUTE_LIVE_TOOLS=1 -``` - -When enabled, the LLM can execute `dsx_exchange_read_retained` and -`dsx_exchange_subscribe` too. Use it only when the MCP endpoint has a valid -broker configuration, bearer token, topic permissions, and bounded test topics. - -## Reading The Output - -Run with `-v`. Each fixture logs: - -- the natural-language question -- the MCP tool trace the LLM actually emitted -- the model's final user-facing answer -- the final planned tool calls compared against the fixture - -Failures are useful evidence. They usually mean one of: - -- the model did not call tools -- the model chose overly broad or wrong topic filters -- the schema description did not provide enough signal -- the final JSON was not machine-parseable -- the gateway or local MCP endpoint was not reachable diff --git a/mcp/dsx-exchange-mcp/docs/long-running-subscriptions-ux.md b/mcp/dsx-exchange-mcp/docs/long-running-subscriptions-ux.md deleted file mode 100644 index 53b676a..0000000 --- a/mcp/dsx-exchange-mcp/docs/long-running-subscriptions-ux.md +++ /dev/null @@ -1,132 +0,0 @@ -# Long-Running Subscription UX User Stories - -This note captures the end-user experience expected from long-running DSX -Exchange MQTT subscriptions exposed through MCP. It should be considered as an -input to the DSX Exchange MCP SDD. - -## UX Model - -End users should not interact with raw MQTT clients or unbounded protocol -streams directly. They should ask an agent to watch a factory signal, and the -agent should use MCP tools to create, inspect, summarize, export, and stop a -managed background subscription. - -The baseline flow is: - -```text -User request - -> agent starts a subscription through MCP - -> dsx-exchange-mcp keeps the MQTT subscription active in the background - -> server buffers matching messages under a subscription_id - -> user asks for status, summaries, aggregations, raw batches, or export - -> agent stops the subscription, or TTL/idle expiry cleans it up -``` - -Streamable HTTP and SSE notifications may improve the live experience, but they -should not be the only way to consume data. Notifications should be lightweight -signals that new data or a state change is available. The reliable contract is -cursor-based reads and server-side summaries over bounded buffers. - -## User Stories - -| ID | Priority | User | I want... | So that... | -| --- | --- | --- | --- | --- | -| LSUB-1 | P0 | Agent Developer | start a long-running Exchange subscription and receive a `subscription_id` immediately | my agent can monitor live factory signals without holding one tool call open forever | -| LSUB-2 | P0 | AI Factory Operator | ask an agent to watch a topic or domain in natural language | I do not need to know raw MQTT topic hierarchy or keep a terminal open | -| LSUB-3 | P0 | Agent Developer | read buffered messages by cursor with bounded `max_messages` and `max_bytes` limits | my agent can safely process live events without memory spikes or timeouts | -| LSUB-4 | P0 | AI Factory Operator | ask "what happened since I started watching?" | I can get a concise operational summary instead of a raw event dump | -| LSUB-5 | P0 | Site Reliability Engineer | query subscription status such as running, reconnecting, expired, denied, or buffer_overflow | I can understand whether silence means no events or a broken watch | -| LSUB-6 | P1 | Agent Developer | receive optional MCP/SSE notifications when new messages arrive or status changes | clients that support live updates can react quickly without polling aggressively | -| LSUB-7 | P1 | AI Factory Operator | ask for aggregations over the background stream, such as counts, latest values, min/max/avg, or grouping by topic/object type | I can reason about trends and thresholds without pulling every raw message into the model | -| LSUB-8 | P1 | Site Reliability Engineer | dump a bounded raw batch of subscription messages in JSON/JSONL | I can inspect exact event payloads during debugging | -| LSUB-9 | P1 | AI Factory Operator | export a subscription to an approved observability sink such as Flight Recorder or logs | incident evidence can be retained without exposing arbitrary exfiltration paths | -| LSUB-10 | P0 | Security Reviewer | have every subscription start, read, aggregation, export, and stop audited with caller identity and arguments | I can reconstruct agent behavior during incidents | -| LSUB-11 | P0 | AI Factory Operator | stop a subscription explicitly or let it expire by TTL/idle timeout | background watches do not run forever by accident | -| LSUB-12 | P1 | Agent Developer | receive structured errors for ACL denial, authentication failure, reconnect exhaustion, buffer overflow, and expired subscriptions | my agent can recover or explain the failure to the operator | - -## Expected User Interactions - -Examples of natural-language requests: - -```text -Watch BMS leak events for row 3 and tell me if anything changes. -Summarize rack power changes from the last 10 minutes. -Show the latest value per CDU from this watch. -Count NICO state transitions by state since I started watching. -Dump the raw events for the last 5 minutes. -Export this watch to Flight Recorder for one hour. -Stop watching rack leak events. -``` - -The agent translates these requests into MCP tool calls such as: - -```text -dsx_exchange_start_subscription(...) -dsx_exchange_read_subscription(...) -dsx_exchange_subscription_status(...) -dsx_exchange_summarize_subscription(...) -dsx_exchange_aggregate_subscription(...) -dsx_exchange_export_subscription(...) -dsx_exchange_stop_subscription(...) -``` - -## Notification Behavior - -When supported by the MCP client, `dsx-exchange-mcp` can emit lightweight -server-to-client notifications over Streamable HTTP/SSE. Notifications should -announce availability or state, not carry unbounded payloads. - -Example notification payload: - -```json -{ - "subscription_id": "sub_123", - "event": "messages_available", - "count": 17, - "severity": "warning", - "summary": "Rack leak event observed for rack R12" -} -``` - -Clients that do not expose notifications should still work by polling -`status_subscription` and `read_subscription`. - -## Data Access Modes - -Long-running subscription UX should support three data access modes: - -| Mode | Purpose | Guardrails | -| --- | --- | --- | -| Notifications | Tell the client that new data or a status change exists | lightweight only; no large payloads | -| Cursor reads | Retrieve bounded raw or normalized message batches | required cursor, message cap, byte cap | -| Summaries and aggregations | Answer operational questions without dumping every message to the model | bounded windows, explicit groupings, sample examples | - -Export is a fourth mode, but only to approved sinks. It should be separately -authorized and audited because unrestricted raw export can become a data -exfiltration path. - -## SDD Implications - -The SDD should describe long-running subscriptions as a managed lifecycle, not -as an infinite `tools/call` response. - -Key design implications: - -- `start_subscription` returns quickly with a `subscription_id`, status, cursor, - and TTL. -- Active MQTT subscriptions are owned by the session-pinned `dsx-exchange-mcp` - pod. -- `Mcp-Session-Id` and agentgateway stateful routing keep follow-up reads and - status calls on the owning pod. -- The server stores messages in bounded per-subscription buffers with explicit - overflow policy. -- Optional SSE/MCP notifications are an acceleration path; cursor reads are the - reliable baseline. -- Server-side aggregation and summarization tools should be first-class for - high-volume streams. -- Raw dumps must be bounded. Long-running export should target approved sinks - only. -- v1 can treat pod restart or session loss as subscription loss requiring - resubscription. Durable cross-pod replay is a future requirement unless - explicitly added to v1 scope. - diff --git a/mcp/dsx-exchange-mcp/docs/sdd-discussion-notes.md b/mcp/dsx-exchange-mcp/docs/sdd-discussion-notes.md deleted file mode 100644 index 151fcc3..0000000 --- a/mcp/dsx-exchange-mcp/docs/sdd-discussion-notes.md +++ /dev/null @@ -1,549 +0,0 @@ -# DSX Exchange MCP SDD Discussion Notes - -This note captures future SDD topics for `dsx-exchange-mcp`. The near-term -assumption is that the MCP server supplements the existing DSX Event Bus and -does not change NATS, MQTT, broker auth, or auth-callout behavior. - -## Current Constraint - -The current MCP server can only learn effective MQTT access by attempting MQTT -operations with the caller bearer. It does not have a supported API to ask -auth-callout for the caller's effective NATS permissions before connecting. - -Broker enforcement remains authoritative: - -1. The MCP gateway validates the caller and forwards the bearer. -2. `dsx-exchange-mcp` presents that bearer as the MQTT password. -3. NATS/auth-callout validates the bearer and returns NATS user permissions. -4. The broker enforces subscribe and publish ACLs on the MQTT connection. - -## What Can Be Done With Only The MCP Server - -### ACL Probe By MQTT Connect - -For discovery or preflight, the MCP server can open a short-lived MQTT -connection with the caller bearer and attempt a bounded subscribe against one -or more candidate filters. - -Example uses: - -- Check whether `BMS/v1/PUB/Metadata/#` is readable before showing the BMS - schema resource. -- Check whether `NICO/v1/#` is denied and avoid exposing NICO-specific tools or - resources to that caller. -- Validate a user-supplied `topic_filter` before spending a longer collection - window on it. - -This is accurate because the broker is the final enforcement point. It is also -expensive and should be bounded. - -Recommended limits: - -- short connect timeout -- short subscribe timeout -- max probe count per request -- no payload return for pure authorization probes -- cache result for a short TTL no longer than token expiry -- fail closed when probe auth or connectivity is ambiguous - -### Schema Filtering By Probe - -Until an entitlement API exists, schema/resource filtering can be approximated -by probing canonical topic filters for each schema domain. - -Example mapping: - -| Schema | Probe Filter | -| --- | --- | -| `bms` | `BMS/v1/PUB/Metadata/#` | -| `nico` | `NICO/v1/#` | -| `power-management` | approved power-management metadata/event prefix | - -If the probe succeeds, expose that schema resource. If it fails with ACL denial, -hide the schema. If it fails due to broker/network availability, fail closed or -return a degraded discovery error rather than exposing everything. - -This is a tactical POC approach, not the preferred production design. - -## MQTT Client Reuse - -MQTT connections are authenticated at connect time. A connection carries the -effective broker identity and ACLs produced by auth-callout for that caller. -Reusing a connection across callers can leak broader permissions. - -Safe rule: - -> Do not share MQTT clients across distinct effective caller identities or ACL -> sets. - -For the current MCP server, the safest model is one short-lived MQTT client per -tool call. That is simple, stateless, and avoids cross-session privilege bleed. - -If connection reuse becomes necessary for scale, pool only by a key that -captures the effective broker authorization context: - -- issuer -- subject -- authorized party / service id -- tenant or persona -- broker account -- token scopes -- token expiry -- policy version or permissions hash, if available - -Without a policy version or entitlement hash, pooling should be conservative: -per caller token or per exact identity with short TTL, never cross-tenant and -never cross-persona. - -## Scalability Considerations - -One MQTT connection per tool call is acceptable for a bounded POC, but it has -costs: - -- connection setup latency on every tool call -- TLS handshake cost -- broker auth-callout load for repeated connects -- broker connection churn -- higher tail latency when agents call multiple filters in sequence - -Mitigations that do not require event-bus changes: - -- cache ACL probe results for a short TTL -- cap concurrent MQTT connections per pod -- cap concurrent probes per caller/session -- prefer broad-but-approved discovery probes over many narrow probes -- keep subscribe/read tools bounded by messages, duration, and result bytes -- expose metrics for active MQTT connections, connect failures, subscribe ACL - failures, probe cache hits, and per-caller throttling - -If sustained long-running subscriptions are required, they should be designed -separately. The default MCP tool model should remain bounded request/response. - -## Future SDD Topic: Entitlement API - -A production design should discuss adding a read-only entitlement or -authorization-check API to auth-callout, or to a sibling policy service that -uses the same permission manager. - -Possible API shapes: - -- `POST /v1/authorize` for exact checks such as `mqtt.subscribe` on a topic - filter. -- `GET /v1/mcp-entitlements` for schema/tool discovery filtering. - -The API must not become a second source of truth. It should expose the same -effective decision that auth-callout would apply during MQTT connection setup, -while the broker remains the final enforcement point. - -Open approval questions for the SDD: - -- Is auth-callout allowed to expose effective permissions to MCP backends? -- Should tenant callers receive raw topic allowlists or only coarse schema - capabilities? -- What identity should the MCP server use when calling the entitlement API? -- What are the cache TTL and invalidation rules when permissions hot-reload? -- Should entitlement failures fail closed for all personas? -- Does connection pooling require an explicit policy version or ACL hash? - -## Future SDD Topic: Long-Lived MQTT Subscriptions - -The SDD should distinguish bounded MCP collection tools from long-lived MQTT -subscriptions. A normal `tools/call` should not be treated as an unbounded raw -MQTT stream. Streamable HTTP can carry SSE-framed responses and notifications, -but long-running firehose-style tool calls create poor UX and scaling pressure. - -Recommended v1 posture: - -- Keep existing read/subscribe tools bounded by message count, duration, and - result bytes. -- Add a managed subscription control plane only if sustained monitoring is - required. -- Treat live MQTT delivery as a background activity owned by the MCP session's - backend pod. -- Prefer bounded reads, cursors, and summaries as the model-facing data path. -- Use server-to-client notifications as an optional acceleration path, not the - only way for the client to observe updates. - -Possible managed-subscription tools: - -- `dsx_exchange_start_subscription(topic_filter, ttl_s, buffer_max_messages, - buffer_max_bytes, drop_policy)` -- `dsx_exchange_read_subscription(subscription_id, cursor, max_messages)` -- `dsx_exchange_subscription_status(subscription_id)` -- `dsx_exchange_list_subscriptions()` -- `dsx_exchange_stop_subscription(subscription_id)` - -The start call should return quickly with a subscription acknowledgement: - -```json -{ - "subscription_id": "sub_123", - "status": "running", - "next_cursor": "0", - "resource_uri": "dsx-exchange://subscriptions/sub_123" -} -``` - -The MCP client can then poll/read bounded batches or ask for summaries. Clients -that support MCP resource subscriptions or Streamable HTTP server-to-client -notifications can additionally receive update notifications and decide when to -read the buffered data. - -## Future SDD Topic: Stateful Sessions And Pod Ownership - -Long-lived subscription state should be tied to stateful MCP sessions. With -Agentgateway selector-based targets and stateful session routing, the gateway -can pin subsequent requests carrying the same `Mcp-Session-Id` to the same -resolved upstream pod. - -Recommended v1 posture: - -- Use Streamable HTTP for remote MCP transport. -- Require stateful MCP sessions for managed subscriptions. -- Use selector-based Agentgateway upstream targets so gateway-managed - session pinning can resolve and pin individual backend pods. -- Keep active subscription state in the owning `dsx-exchange-mcp` pod memory. -- Accept that pod restart, eviction, or session loss terminates in-memory - subscriptions and requires client resubscription. -- Do not require Redis, Valkey, or other shared state in v1 unless recovery, - cross-pod visibility, or durable replay becomes a requirement. - -State kept in the owning pod should include: - -- MCP session ID -- caller identity / auth context fingerprint -- subscription ID -- topic filter -- MQTT connection key -- subscription status -- buffer cursor and ring-buffer state -- last broker error or disconnect reason -- TTL and idle-expiry timestamps - -This model scales by adding pods. New sessions are distributed across pods by -the gateway's initial routing decision; existing sessions continue to the -pinned backend pod. - -## Future SDD Topic: MQTT Connection Strategy For Subscriptions - -The SDD should explicitly separate logical MCP subscriptions from MQTT -connections. One MQTT connection can carry many topic subscriptions, but it is -authenticated at MQTT CONNECT time and therefore carries the permissions of the -bearer used as its password. - -Unsafe rule: - -> Do not use one shared MQTT connection per pod for all callers. - -Safer rule: - -> Pool MQTT connections per pod by broker configuration and effective caller -> authorization context. - -A conservative pool key should include: - -- broker URL and TLS config -- MQTT username -- issuer -- subject or authorized party / service ID -- audience -- scopes -- tenant or persona -- token expiry bucket -- policy version or permissions hash, if available - -For demo traffic that uses one SSA service account, sharing a connection across -sessions from that same effective auth context may be acceptable. For production -traffic with distinct users, tenants, personas, or bearer scopes, connections -must not be shared across auth boundaries. - -The pod should support: - -- many MCP sessions -- multiple logical subscriptions per MCP session -- multiple MQTT connections keyed by auth context -- many topic filters per eligible MQTT connection -- internal fan-out from MQTT messages to per-subscription buffers - -If multiple active subscriptions share one MQTT client, the implementation needs -a demux layer: - -1. MQTT message arrives. -2. Match the topic to active filters owned by the same auth context. -3. Write the message or an aggregate into each matching subscription buffer. -4. Emit optional status/update notifications. - -The SDD should also define unsubscribe reference counting so one logical -subscription cannot remove a broker subscription still needed by another -logical subscription. - -## Future SDD Topic: Lifecycle, Failure, And Backpressure - -Managed subscriptions need explicit lifecycle and safety behavior: - -- `start_subscription` and `stop_subscription` should be idempotent where - possible. -- Each subscription must have TTL and idle timeout controls. -- Token expiry must close or recreate affected MQTT connections. -- If the server cannot refresh a caller bearer, subscriptions tied to that - bearer must end before or at token expiry. -- Broker disconnects and reconnect attempts should update subscription status. -- Topic ACL denial should surface as authorization failure, not as an empty - stream. -- MQTT connect auth failures should surface as authentication failure. -- Buffer overflow policy must be explicit: drop oldest, drop newest, aggregate, - or fail the subscription. -- Rolling deploys should either drain subscriptions gracefully or document that - clients must resubscribe after session loss. - -Suggested lifecycle/status notifications: - -- subscribed -- unsubscribed -- reconnecting -- disconnected -- authorization_denied -- authentication_failed -- buffer_overflow -- expired - -The SDD should define per-pod limits and metrics: - -- active MCP sessions -- active logical subscriptions -- active MQTT connections -- broker subscriptions -- MQTT messages/sec in -- MCP/SSE notifications/sec out -- buffered messages and bytes -- dropped messages -- authn/authz failures -- reconnect count -- event loop / handler latency - -## Recommended SDD Position For V1 - -The SDD should likely state: - -> The DSX Exchange MCP server uses Streamable HTTP with stateful MCP sessions. -> Agentgateway selector-based session routing pins a client session to one -> `dsx-exchange-mcp` pod. For v1, active MQTT subscription state is held in that -> owning pod's memory and is lost on pod restart. MQTT connections are pooled -> per pod by broker config and caller authorization context, not globally. A -> pooled MQTT connection may carry multiple topic subscriptions for the same -> auth context. The server fans incoming MQTT messages into bounded -> per-subscription buffers. MCP clients consume those buffers through explicit -> read/status calls, with optional server-to-client notifications for clients -> that support live updates. - -## Future SDD Topic: Schema Discovery Based On MQTT ACLs - -MCP schema/resource visibility should correspond to the caller's effective MQTT -subscribe authorization. A caller should not see DSX Exchange schema domains or -channels that they cannot subscribe to through the broker. The MCP server must -not trust client-provided claims alone for schema visibility. - -Broker enforcement remains the final source of truth: - -1. Gateway validates the caller and forwards the bearer. -2. `dsx-exchange-mcp` uses the bearer for MQTT when reading data. -3. NATS/auth-callout mints NATS permissions for that bearer. -4. The broker enforces topic subscribe ACLs. - -The open design problem is how `dsx-exchange-mcp` can filter `/schema` or -`dsx-exchange://specs/*` discovery without brute-force probing every possible -topic. - -### Tactical POC Approach: Canonical ACL Probes - -If the MCP server only has a caller bearer and no entitlement API, the only -fully accurate authorization check is to ask the broker: - -```text -MQTT CONNECT with caller bearer -MQTT SUBSCRIBE candidate filter -observe SUBACK success or denial -``` - -This should not enumerate concrete topics. At most, probe a small number of -canonical schema filters: - -| Schema / Domain | Candidate Probe | -| --- | --- | -| BMS metadata | `BMS/v1/PUB/Metadata/#` | -| BMS values | `BMS/v1/PUB/Value/#` | -| NICO | `NICO/v1/#` | -| Power management | canonical power-management prefix | - -Limitations: - -- A broad probe can produce false negatives for narrow ACLs. For example, a - caller allowed only `BMS/v1/PUB/Metadata/Rack/#` may be denied on - `BMS/v1/PUB/Metadata/#` even though part of the BMS schema is relevant. -- Broker or network errors are ambiguous and should fail closed or return a - degraded discovery result. -- Probe results should be cached only for a short TTL no longer than token - expiry. -- Probe count and timeout must be tightly bounded. - -This is acceptable for a POC or demo, but it is not the preferred production -design. - -### Preferred Approach: Entitlement Or ACL Introspection API - -The cleaner design is an internal read-only authorization API backed by the -same permission manager used by auth-callout. Today auth-callout resolves: - -```text -JWT claims: sub / azp / scope - -> UserProfile - -> jwt.Permissions - -> pub/sub allow/deny ACLs - -> signed NATS user JWT -``` - -The missing capability is a supported internal API that exposes the effective -decision or effective permissions for schema filtering. Possible shapes: - -```http -POST /v1/authorize -{ - "token": "", - "action": "mqtt.subscribe", - "topic_filter": "BMS/v1/PUB/Metadata/#" -} -``` - -or: - -```http -POST /v1/effective-permissions -{ - "token": "", - "protocol": "mqtt" -} -``` - -Example response: - -```json -{ - "subject": "...", - "azp": "...", - "account": "...", - "subscribe": { - "allow": ["BMS.v1.PUB.Metadata.>", "BMS.v1.PUB.Value.Rack.>"], - "deny": ["NICO.v1.>"] - }, - "policy_version": "..." -} -``` - -The API must not become a second source of truth. It should expose the same -effective permissions that auth-callout would apply while minting the NATS user -JWT. The broker still enforces the final data access decision. - -### Best Product Contract: Schema Entitlements - -Rather than exposing raw topic ACLs directly to MCP clients, the authorization -service or MCP server can map effective permissions into schema capabilities: - -```json -{ - "resources": ["dsx-exchange://specs/bms"], - "channels": ["bms.rackMetadata", "bms.rackValue"], - "recommended_filters": [ - "BMS/v1/PUB/Metadata/Rack/#", - "BMS/v1/PUB/Value/Rack/#" - ] -} -``` - -This gives agents a cleaner discovery surface while keeping the raw ACLs -internal. Broker SUBACK remains final enforcement for actual MQTT reads. - -### Pattern Intersection Instead Of Topic Enumeration - -The MCP server should maintain a compiled schema access index derived from -AsyncAPI channels: - -```text -Schema channel: bms.rackMetadata -MQTT filter: BMS/v1/PUB/Metadata/Rack/# -NATS filter: BMS.v1.PUB.Metadata.Rack.> - -Schema channel: bms.rackValue -MQTT filter: BMS/v1/PUB/Value/Rack/# -NATS filter: BMS.v1.PUB.Value.Rack.> -``` - -NATS MQTT topic/subject conversion: - -| MQTT | NATS | -| --- | --- | -| `/` | `.` | -| `+` | `*` | -| `#` | `>` | - -At discovery time: - -```text -1. Get caller effective subscribe ACLs. -2. For each schema channel in the access index: - if channel filter intersects acl.sub.allow - and is not fully excluded by acl.sub.deny: - expose channel/resource -3. Return filtered schema index/resources. -``` - -Examples: - -```text -ACL allow: BMS.v1.PUB.Metadata.> -Schema: BMS.v1.PUB.Metadata.Rack.> -Result: visible - -ACL allow: BMS.v1.PUB.Metadata.Rack.> -Schema: BMS.v1.PUB.Metadata.CDU.> -Result: hidden - -ACL allow: BMS.v1.PUB.> -ACL deny: BMS.v1.PUB.Metadata.Secret.> -Schema: BMS.v1.PUB.Metadata.> -Result: partially visible; remove or annotate denied channel subset -``` - -The SDD should define deny precedence and how to represent partially visible -schemas. Conservative behavior is to hide a channel when deny/allow -intersection cannot be represented safely. - -### Recommended SDD Position - -The SDD should likely state: - -> Schema/resource discovery is filtered from the caller's effective MQTT -> subscribe authorization. The MCP server must not infer schema access from -> untrusted client claims alone. Broker enforcement remains the source of truth -> for final message access, but discovery filtering should use an internal -> entitlement API backed by the same permission manager that auth-callout uses -> to mint NATS user JWTs. - -And: - -> The MCP server maintains a compiled schema access index that maps AsyncAPI -> resources and channels to canonical MQTT topic filters and equivalent NATS -> subject filters. Given effective subscribe allow/deny filters, the server -> computes visible schema resources by wildcard-pattern intersection. It does -> not enumerate concrete topic instances. - -Open approval questions: - -- Can auth-callout expose effective permissions or authorization decisions to - MCP backends? -- Should MCP clients see raw topic ACLs, schema capabilities, or only filtered - resources? -- What identity and credential does `dsx-exchange-mcp` use to call the - entitlement API? -- How are policy version, hot reload, and cache invalidation represented? -- How should partially visible schema domains be presented? -- Should ambiguous entitlement failures fail closed for all callers? diff --git a/mcp/dsx-exchange-mcp/docs/v1-background-watch-benchmark-plan.md b/mcp/dsx-exchange-mcp/docs/v1-background-watch-benchmark-plan.md deleted file mode 100644 index 0035ac9..0000000 --- a/mcp/dsx-exchange-mcp/docs/v1-background-watch-benchmark-plan.md +++ /dev/null @@ -1,208 +0,0 @@ -# V1 Background Watch Benchmark Plan - -This note defines the benchmark and failure-test plan for the v1 -`dsx-exchange-mcp` background watch design. - -## Objective - -Validate whether the v1 session-pinned, pod-local watch model is sufficient -before adding external watch state such as Valkey, Redis, JetStream consumers, -or separate MQTT worker pods. - -The v1 design intentionally treats background watches as ephemeral, -session-scoped state: - -- `start_subscription` returns a `subscription_id` quickly. -- Follow-up status/read/stop calls are routed to the same upstream pod by - `Mcp-Session-Id` and Agentgateway stateful routing. -- Active watch state, MQTT connections, cursors, and ring buffers are held in - the owning `dsx-exchange-mcp` pod. -- Pod restart, pod eviction, or MCP session loss terminates active watches and - requires client resubscription. - -The benchmark should determine whether this tradeoff is acceptable for v1 -usage, and where the breakpoints are. - -See `docs/watch-state-tradeoff-note.md` for the current tradeoff decision: -active MQTT watches stay pod-local, while any Valkey use should be limited to -best-effort status and aggregate snapshots rather than transparent MQTT -failover. - -## Non-Goals - -- Prove durable cross-pod recovery. -- Hide pod failure from clients. -- Turn Valkey or JetStream into an MCP-owned message database. -- Benchmark unbounded raw MQTT streaming through one infinite MCP tool call. - -## Benchmark Questions - -The benchmark should answer: - -| Question | Decision It Informs | -| --- | --- | -| How many concurrent MCP sessions can one deployment support? | Replica sizing and per-pod limits. | -| How many active watches can each pod hold safely? | Watch admission limits. | -| How many MQTT connections and broker subscriptions are created? | Connection pooling strategy. | -| How do narrow and broad topic filters affect CPU, memory, and drops? | Topic guardrails and dedicated-client thresholds. | -| What is the p95/p99 latency for status and cursor reads? | User-facing UX limits. | -| How quickly do buffers fill under hot topics? | Buffer caps and overflow policy. | -| How expensive are broker reconnects and auth-callout evaluations? | Reconnect and pooling policy. | -| What happens during pod loss, rollout, and broker disruption? | Whether v1 failure semantics are acceptable. | - -## Load Shapes - -Run each load shape at multiple replica counts, starting with two replicas to -match the deployment default. - -| Scenario | Example Levels | Purpose | -| --- | --- | --- | -| Concurrent MCP sessions | 100, 500, 1000 | Validate session pinning, memory, and gateway behavior. | -| Watches per session | 1, 5, 10 | Model normal and power-user agent workflows. | -| Narrow filters | Specific rack/object paths | Baseline operational usage. | -| Domain-wide filters | BMS or NICO domain-level watches | High-volume but plausible usage. | -| Broad wildcard filters | `#` or similar unsafe patterns, if allowed in test | Worst-case/bad-agent pressure. | -| Sparse topics | Low event rate | Idle connection overhead. | -| Hot topics | High event rate | Buffer pressure, drops, and summarization cost. | -| Control-plane churn | Repeated start/status/read/stop | Lifecycle overhead and cleanup leaks. | -| Denied topics | Broker ACL denial | Structured error and audit behavior. | -| Broker unavailable | Connect/subscribe failures | Backoff, error, and retry pressure. | - -## Metrics To Capture - -### MCP Server - -- Active MCP sessions. -- Active background watches. -- Active tool calls. -- Tool calls by tool, status, and error code. -- `start_subscription`, `read_subscription`, `subscription_status`, and - `stop_subscription` latency p50/p95/p99. -- Active MQTT connections. -- Broker subscriptions. -- Messages and bytes received. -- Buffered messages and bytes. -- Dropped messages and overflow count. -- Per-pod buffer memory. -- Goroutine count. -- Pod CPU and memory. -- Pod restarts. - -### MQTT / Broker Path - -- MQTT connect latency. -- MQTT subscribe latency. -- MQTT connection failures. -- Subscribe ACL denials. -- Broker reconnect count. -- Reconnect exhaustion count. -- Auth-callout request rate and latency, if available. -- Broker connection count by client identity or auth context, if available. - -### Gateway Path - -- Gateway request rate. -- Gateway 4xx/5xx by reason. -- Session routing failures. -- Requests missing or changing `Mcp-Session-Id`. -- Upstream dispatch latency. - -### User-Visible Results - -- Time from `start_subscription` to `running`. -- Time to first message. -- `read_subscription` p95/p99 latency. -- `subscription_status` p95/p99 latency. -- Message loss as represented by buffer overflow counters. -- Number of watches requiring client resubscription during failure tests. - -## Suggested Pass / Review Gates - -Set exact thresholds per environment before running the benchmark. Initial -review gates can use this shape: - -| Gate | Initial Target | -| --- | --- | -| 1000 sessions with 1 watch each | No unbounded memory or goroutine growth. | -| `read_subscription` latency | p95 below 500 ms under normal load. | -| `subscription_status` latency | p95 below 250 ms under normal load. | -| Pod memory | Stays below 70 percent of configured limit. | -| Buffer overflow | Only occurs under configured overflow scenarios. | -| Stop cleanup | MQTT subscriptions, buffers, and goroutines are released. | -| Pod kill behavior | Watch loss is visible and client can resubscribe. | -| Broker ACL denial | Returns structured `topic_acl_denied`, not empty data. | -| Broker unavailable | Returns/updates `bus_unavailable` or `reconnecting` without hot looping. | - -## Failure Scenarios - -The v1 design does not promise transparent recovery. The tests should verify -clear status, cleanup, and resubscription behavior. - -| Failure | Expected V1 Behavior | -| --- | --- | -| Owning MCP pod killed mid-watch | Active watches on that pod are lost; client must resubscribe. | -| Rolling deployment | Terminating pod stops accepting new watch starts; active watches are drained or reported lost where possible. | -| Gateway pod restart | Follow-up requests with the same `Mcp-Session-Id` should still route to the owning upstream pod. | -| MQTT broker disconnect | Watch enters reconnecting or failed status; no silent empty stream. | -| Auth token expiry | Watch ends at or before token expiry unless token refresh/token exchange is added. | -| ACL revoked during watch | Watch stops or fails on reconnect or next broker enforcement point. | -| Buffer overflow | Configured overflow policy applies and status/metrics expose the drop. | -| Client stops polling | Idle timeout eventually cleans up the watch. | -| Client disappears without stop | TTL or idle expiry releases MQTT subscriptions and buffers. | -| Node drain or eviction | Same as pod termination; client resubscribes. | - -## When To Add External Watch State - -Promote Valkey, Redis, JetStream consumers, or another external state backend -only if benchmark or product data shows one or more of these are required: - -- Watches must survive `dsx-exchange-mcp` pod restart or node drain. -- Operators need to inspect all active watches globally across pods. -- `read_subscription` or `subscription_status` cannot reliably stay pinned to - the owning pod. -- Watch lifetimes are long enough that rollout-driven interruption is common - and unacceptable. -- Client resubscription creates too much UX friction or misses important - incident evidence. -- Background watch count or message rate requires separate worker scaling. -- Support needs ownership leases, heartbeats, and cross-pod takeover. - -If the goal is only better post-interruption UX, prefer a narrower -status-snapshot store before adding a full distributed ownership model. That -snapshot store can hold TTL-bound heartbeat, last-message, counter, and -aggregate data while active MQTT clients and raw buffers remain pod-local. - -If external state is added, keep the responsibility split explicit: - -| Data | Preferred Owner | -| --- | --- | -| Durable event replay | NATS JetStream or Flight Recorder. | -| Best-effort watch metadata/status/snapshot watermarks | Valkey, Redis, or approved KV store. | -| Bounded recent MCP buffers | Pod memory for v1; external capped buffers only when needed. | -| Live read cursors and raw ring buffers | Pod memory for v1. | -| Long-term incident evidence | Approved observability sinks, not MCP buffers. | - -## Recommended Milestones - -1. Implement v1 pod-local background watches with strong limits and metrics. -2. Run the benchmark at 100, 500, and 1000 concurrent sessions. -3. Run failure tests for pod kill, rollout, broker disruption, token expiry, - ACL denial, buffer overflow, and idle cleanup. -4. Review observed interruption rate and operator/client impact. -5. Decide whether v2 needs external watch state, worker pods, broker-backed - replay, or only tuning of v1 limits. - -## Decision Record Template - -After each benchmark run, record: - -| Field | Notes | -| --- | --- | -| Date and environment | Cluster, broker, gateway, image versions. | -| Replica count | Gateway and `dsx-exchange-mcp`. | -| Load shape | Sessions, watches/session, topics, message rate. | -| Limits | Buffer caps, TTL, idle timeout, message caps. | -| Results | Latency, CPU, memory, drops, errors, restarts. | -| Failure behavior | What failed, what recovered, what required resubscription. | -| Decision | Keep v1, tune v1, or promote external watch state. | -| Follow-up | Required code, chart, observability, or product changes. | diff --git a/mcp/dsx-exchange-mcp/docs/watch-state-tradeoff-note.md b/mcp/dsx-exchange-mcp/docs/watch-state-tradeoff-note.md deleted file mode 100644 index b8c0215..0000000 --- a/mcp/dsx-exchange-mcp/docs/watch-state-tradeoff-note.md +++ /dev/null @@ -1,180 +0,0 @@ -# Background Watch State Tradeoff Note - -This note captures the current design position for `dsx-exchange-mcp` -long-running MQTT watches after comparing pod-local state, Valkey-backed state, -and broker-backed recovery. - -## Decision - -Active background watches remain pod-local for the first implementation. The -MQTT client, subscription callbacks, raw ring buffer, and live cursor state are -owned by the `dsx-exchange-mcp` pod selected by Agentgateway stateful MCP -session routing. - -Valkey, if introduced, should be limited to best-effort watch status and -aggregate snapshots. It should not be used in v1 for transparent MQTT client -failover, lease-based ownership transfer, raw message replay, or token refresh. - -## Why Not Store The MQTT Client - -An MQTT client is a live TCP connection plus in-process callback state. It -cannot survive pod restart by being stored in a key/value database. When the -owning pod dies, the MQTT connection and in-memory callbacks are gone. - -The server can store only metadata about the watch: - -- `subscription_id` -- topic filter or schema-derived selector -- owner pod identity -- creation, expiry, and last heartbeat timestamps -- last message timestamp -- message counters and drop counters -- latest/oldest snapshot watermarks -- latest status and error code -- bounded aggregate snapshots - -This metadata can improve user-facing status after interruption, but it does -not recreate the MQTT subscription. - -## Credential Constraint - -The gateway validates each MCP tool call and forwards the caller bearer to the -upstream. `dsx-exchange-mcp` uses that bearer as the MQTT password when opening -the broker connection. Broker/auth-callout remains authoritative for topic ACLs. - -The MCP server should not persist raw JWTs or manage caller token refresh. This -means a replacement pod cannot autonomously reconnect a caller-scoped MQTT -subscription after owner pod failure unless a later authenticated tool call -provides a fresh bearer, or a separate approved token-exchange mechanism is -added. - -Because of that constraint, Valkey-backed lease takeover would create the -appearance of failover without solving the credential needed to resume the MQTT -stream. - -## Valkey Usage That Fits - -A narrow Valkey use can still be valuable if product UX needs post-interruption -status: - -- TTL-bound watch status records. -- Owner heartbeat and last-message timestamps. -- Message, byte, and drop counters. -- Last known status such as `running`, `expired`, `interrupted`, `failed`, or - `buffer_overflow`. -- Latest and oldest snapshot watermarks for explaining what the snapshot - covered. -- Periodic aggregate snapshots per topic, such as count, min, max, mean, last - value, and frequency. - -This state is best-effort. If Valkey is unavailable, the active pod-local watch -can continue. Snapshot writes may fail, and post-mortem status may be missing -if the pod later dies. - -## Valkey Usage To Avoid For V1 - -Avoid using Valkey for: - -- MQTT client persistence. -- Raw JWT persistence. -- Transparent MQTT reconnect after pod failure. -- Lease-based active owner promotion. -- Live read cursors or replay positions used to continue a pod-local stream - after pod failure. -- Raw ring buffers, exact message batches, or raw replay. -- A durable telemetry database. -- Replica reads for ownership, live state, or lease decisions. - -These uses require stronger consistency, failover, token lifecycle, and recovery -semantics than the current v1 goal needs. - -## Deployment Implication - -For best-effort watch status snapshots, Valkey can be treated like a transient -cache: - -- One writable primary endpoint is sufficient for v1. -- Replicas are optional and mainly useful for future HA promotion, not read - scaling. -- Reads and writes should go to the primary to avoid stale status and snapshot - confusion. -- Persistence is optional because records are TTL-bound and not source-of-truth - telemetry. -- If Valkey fails, the system should fail open to local-only watch behavior. - -This differs from gateway RLS Valkey. Gateway RLS stores short-lived shared rate -limit counters where loss only weakens throttling temporarily. Watch snapshots -are user-visible observability state, so they should be framed as best-effort -and not as a reliability boundary. - -## Valkey Snapshot TTL - -Snapshot records should expire soon after they stop helping the user understand -an interruption. The default TTL should be: - -```text -snapshot_ttl = min(watch_expires_at + 30 minutes, created_at + 2 hours) -``` - -With a 15-minute maximum watch TTL, this keeps most snapshot records for up to -45 minutes after creation. That gives the user or agent time to ask what -happened after an interruption without keeping stale monitoring state around as -if it were durable telemetry. - -Before watch expiry, the owning pod-local state is authoritative. After expiry -or interruption, a snapshot is useful only as last-known context. Longer-term -incident evidence belongs in audit logs, Flight Recorder, metrics, logs, or -another approved observability system rather than Valkey watch snapshots. - -## User-Facing Failure Semantics - -Pod-local state is authoritative. A Valkey snapshot can only explain the last -known state; it cannot prove that a watch is still active or resume raw reads. - -| Tool | Local State Exists | Local State Missing, No Snapshot | Local State Missing, Snapshot Exists | -| --- | --- | --- | --- | -| `dsx_exchange_start_subscription` | Create a new authenticated pod-local watch. | Create a new authenticated pod-local watch. | Create a new authenticated pod-local watch; old snapshot remains historical context until TTL. | -| `dsx_exchange_read_subscription` | Read the owning pod's local buffer and return bounded messages plus next cursor. | Return `subscription_not_found` or `session_lost`. | Return `interrupted` with snapshot metadata or aggregates and no raw messages. | -| `dsx_exchange_subscription_status` | Return live pod-local status. | Return `subscription_not_found` or `session_lost`. | Return `interrupted` with last heartbeat, last message time, counters, and aggregate snapshot. | -| `dsx_exchange_stop_subscription` | Stop the local MQTT subscription, release buffers, and return `stopped`. | Return `subscription_not_found`. | Mark the snapshot `stopped` or return idempotent `stopped` with `stopped_reason=session_lost`; no MQTT cleanup is possible. | - -If the owning pod dies and no Valkey snapshot is available, a later read/status -call should return `subscription_not_found` or `session_lost`. - -If a stale Valkey snapshot is available, a later read/status call may return: - -```json -{ - "subscription_id": "sub_123", - "status": "interrupted", - "interrupted_reason": "owner_heartbeat_stale", - "last_heartbeat_at": "2026-06-03T19:10:02Z", - "last_message_at": "2026-06-03T19:09:58Z", - "message_count": 1842, - "aggregates": [ - { - "topic": "BMS/v1/PUB/Value/Rack/Power/Rack02", - "count": 120, - "min": 28.4, - "max": 35.9, - "mean": 31.2, - "last_value": 32.1 - } - ] -} -``` - -The agent can then explain that the watch was interrupted and offer to restart -it through a fresh authenticated tool call. Messages after the interruption may -be missed. - -The `interrupted` status is informational only. It does not mean the watch is -still active, recoverable, or eligible for raw cursor reads. The recovery path is -a fresh authenticated `dsx_exchange_start_subscription` call. - -## Current Recommendation - -Implement pod-local watches first with short TTLs, clear interruption status, -and strong bounds. Add best-effort Valkey status and aggregate snapshots only if -the post-interruption UX needs more than `subscription_not_found` or -`session_lost`. From 26479987cc27185b7c43d6e21e75022a3db81fa9 Mon Sep 17 00:00:00 2001 From: Daniyal Rana Date: Wed, 24 Jun 2026 10:42:36 -0500 Subject: [PATCH 16/27] fix(mcp): return schema no-match tool errors Signed-off-by: Daniyal Rana --- mcp/dsx-exchange-mcp/internal/server/tools.go | 15 ++-- .../internal/server/tools_test.go | 70 +++++++++++++++++++ 2 files changed, 81 insertions(+), 4 deletions(-) diff --git a/mcp/dsx-exchange-mcp/internal/server/tools.go b/mcp/dsx-exchange-mcp/internal/server/tools.go index 95f3471..7a94f60 100644 --- a/mcp/dsx-exchange-mcp/internal/server/tools.go +++ b/mcp/dsx-exchange-mcp/internal/server/tools.go @@ -24,6 +24,8 @@ const ( toolReadRetained = "dsx_exchange_read_retained" toolDescribeTopic = "dsx_exchange_describe_topic" toolFindTopics = "dsx_exchange_find_topics" + + codeSchemaNoMatch = "schema_no_match" ) type subscribeInput struct { @@ -33,7 +35,7 @@ type subscribeInput struct { } type readRetainedInput struct { - TopicFilter string `json:"topic_filter" jsonschema:"MQTT topic filter to read retained messages from. For BMS, use Metadata paths such as BMS/v1/PUB/Metadata/#. Do not use this for live Value paths; use dsx_exchange_subscribe instead."` + TopicFilter string `json:"topic_filter" jsonschema:"MQTT topic filter to read retained messages from. For BMS, use Metadata paths such as BMS/v1/PUB/Metadata/# to discover which points and related Value topics matter. Do not use this for live Value paths; use dsx_exchange_subscribe instead."` MaxMessages int `json:"max_messages,omitempty" jsonschema:"safety cap on returned messages (default 1000)"` } @@ -119,8 +121,9 @@ func registerTools(s *mcp.Server, cfg Config) { Name: toolReadRetained, Annotations: readOnlyOpenWorldAnnotations("Read retained MQTT messages"), Description: "Read currently-retained messages on a DSX Exchange MQTT topic filter. " + - "Use this for retained BMS Metadata, for example BMS/v1/PUB/Metadata/#, before " + - "deriving specific value topics. Do not use this tool for BMS live Value channels; " + + "For BMS, use this for retained Metadata topics, for example BMS/v1/PUB/Metadata/#. " + + "Use the returned metadata to decide which related /Value/ topics to sample with dsx_exchange_subscribe. " + + "Do not use this tool for BMS live Value channels; " + "a zero-count retained_idle result means no retained messages matched that filter. " + "In jwt_passthrough mode, the caller bearer is passed to MQTT as the configured OAuth " + "username/password=; in noauth mode, no MQTT username/password is sent.", @@ -133,7 +136,8 @@ func registerTools(s *mcp.Server, cfg Config) { Annotations: readOnlyClosedWorldAnnotations("Describe Exchange schema topic"), Description: "Schema Exploration: describe the AsyncAPI channel matching a DSX Exchange topic filter. " + "Returns the schema channel, payload shape, retained/live behavior, examples, and related metadata/value topics. " + - "Use this before subscribing when the caller knows roughly which MQTT path they want but needs schema context.", + "Use this before subscribing when the caller knows roughly which MQTT path they want but needs schema context. " + + "If no embedded AsyncAPI channel matches the filter, this returns a schema_no_match tool error; retry discovery with dsx_exchange_find_topics before using broker-backed tools.", }, func(ctx context.Context, _ *mcp.CallToolRequest, in describeTopicInput) (*mcp.CallToolResult, describeTopicOutput, error) { return describeTopicTool(ctx, in) }) @@ -174,6 +178,9 @@ func describeTopicTool(ctx context.Context, in describeTopicInput) (*mcp.CallToo } matches := idx.Describe(topicFilter) + if len(matches) == 0 { + return describeTopicError(codeSchemaNoMatch, fmt.Sprintf("no embedded AsyncAPI channel matches topic_filter %q; try dsx_exchange_find_topics with domain, role, object_type, point_type, or query before using broker-backed tools", topicFilter)) + } out := describeTopicOutput{ TopicFilter: topicFilter, Count: len(matches), diff --git a/mcp/dsx-exchange-mcp/internal/server/tools_test.go b/mcp/dsx-exchange-mcp/internal/server/tools_test.go index 9d16bcb..cfb406d 100644 --- a/mcp/dsx-exchange-mcp/internal/server/tools_test.go +++ b/mcp/dsx-exchange-mcp/internal/server/tools_test.go @@ -107,6 +107,32 @@ func TestDescribeTopicToolRequiresFilter(t *testing.T) { } } +func TestDescribeTopicToolNoSchemaMatchReturnsToolError(t *testing.T) { + result, out, err := describeTopicTool(context.Background(), describeTopicInput{ + TopicFilter: "Unknown/v1/PUB/Value/Rack/RackPower/#", + }) + if err != nil { + t.Fatalf("describeTopicTool returned transport error: %v", err) + } + if result == nil || !result.IsError { + t.Fatalf("describeTopicTool no-match IsError = %v, want true", result != nil && result.IsError) + } + if out.TopicFilter != "" || out.Count != 0 || len(out.Matches) != 0 { + t.Fatalf("describeTopicTool no-match output = %#v, want empty output", out) + } + + var body structuredError + if err := json.Unmarshal([]byte(lastTextContent(t, result)), &body); err != nil { + t.Fatalf("decode no-match error body: %v", err) + } + if body.Error.Code != codeSchemaNoMatch { + t.Fatalf("no-match error code = %q, want %q", body.Error.Code, codeSchemaNoMatch) + } + if !strings.Contains(body.Error.Message, "no embedded AsyncAPI channel matches") { + t.Fatalf("no-match error message = %q, want embedded AsyncAPI match hint", body.Error.Message) + } +} + func TestFindTopicsToolMatchesSelector(t *testing.T) { cfg := Config{} normalizeConfig(&cfg) @@ -127,6 +153,24 @@ func TestFindTopicsToolMatchesSelector(t *testing.T) { } } +func TestFindTopicsToolNoMatchRemainsSuccess(t *testing.T) { + cfg := Config{} + normalizeConfig(&cfg) + result, out, err := findTopicsTool(context.Background(), cfg, findTopicsInput{ + Domain: "unknown-domain", + Query: "definitely-not-a-schema-topic", + }) + if err != nil { + t.Fatalf("findTopicsTool returned transport error: %v", err) + } + if result == nil || result.IsError { + t.Fatalf("findTopicsTool no-match IsError = %v, want false", result != nil && result.IsError) + } + if out.Count != 0 || len(out.Matches) != 0 { + t.Fatalf("findTopicsTool no-match output = %#v, want count=0", out) + } +} + type toolCallFixture struct { ID string `json:"id"` Domain string `json:"domain"` @@ -378,6 +422,32 @@ func TestMCPClientDescribeTopicInvalidFilterReturnsToolError(t *testing.T) { } } +func TestMCPClientDescribeTopicNoSchemaMatchReturnsToolError(t *testing.T) { + session, cleanup := newTestMCPClient(t) + defer cleanup() + + result, err := session.CallTool(context.Background(), &mcp.CallToolParams{ + Name: toolDescribeTopic, + Arguments: map[string]any{ + "topic_filter": "Unknown/v1/PUB/Value/Rack/RackPower/#", + }, + }) + if err != nil { + t.Fatalf("CallTool no-match filter returned client/protocol error: %v", err) + } + if !result.IsError { + t.Fatalf("CallTool no-match filter IsError=false; content=%s", textContentSummary(result)) + } + + var body structuredError + if err := json.Unmarshal([]byte(lastTextContent(t, result)), &body); err != nil { + t.Fatalf("decode no-match error body: %v", err) + } + if body.Error.Code != codeSchemaNoMatch { + t.Fatalf("no-match error code = %q, want %q", body.Error.Code, codeSchemaNoMatch) + } +} + func stringArg(t *testing.T, fixtureID string, callIndex int, args map[string]any, key string) string { t.Helper() value, ok := args[key] From 38da37d03aee963b09903972f853c3e9c8336f18 Mon Sep 17 00:00:00 2001 From: Daniyal Rana Date: Wed, 24 Jun 2026 11:00:08 -0500 Subject: [PATCH 17/27] docs(mcp): update client skill guidance Signed-off-by: Daniyal Rana --- .../skills/dsx-exchange-mcp/SKILL.md | 34 +++++++++++++++---- 1 file changed, 28 insertions(+), 6 deletions(-) diff --git a/mcp/dsx-exchange-mcp/skills/dsx-exchange-mcp/SKILL.md b/mcp/dsx-exchange-mcp/skills/dsx-exchange-mcp/SKILL.md index d695c31..702253e 100644 --- a/mcp/dsx-exchange-mcp/skills/dsx-exchange-mcp/SKILL.md +++ b/mcp/dsx-exchange-mcp/skills/dsx-exchange-mcp/SKILL.md @@ -23,12 +23,37 @@ SPDX-License-Identifier: Apache-2.0 - Use `dsx_exchange_describe_topic` when the user provides a topic or topic filter and needs schema context, payload shape, parameters, examples, or related metadata/value topics. -- Use `dsx_exchange_read_retained` for retained metadata and last-known retained - values. For BMS value topics, read related `/Metadata/` topics first when - `describe_topic` returns them. +- Treat `describe_topic` `schema_no_match` results as a bad schema-catalog + argument, not proof that the live broker has no data. Retry with + `dsx_exchange_find_topics` using inferred `domain`, `role`, `object_type`, + `point_type`, or `query` terms before calling broker-backed tools. +- Use `dsx_exchange_read_retained` for retained metadata discovery. For BMS + value requests, read related `/Metadata/` topics first when `describe_topic` + returns them, then use that metadata to decide which `/Value/` topics to + sample with `dsx_exchange_subscribe`. - Use `dsx_exchange_subscribe` only for bounded live sampling. Always provide a finite `max_messages` and `max_duration_s`. +## BMS Discovery + +- When the user asks for BMS values by signal name, asset, or point type, + prefer `dsx_exchange_find_topics` before `describe_topic`; only describe the + returned topic filters. +- For BMS value sampling, read retained related `/Metadata/` topics first when + available. Treat retained metadata as the point/topic inventory, not the live + value result. Use it to choose which concrete `/Value/` topics matter, then + subscribe to those value filters. +- Do not call `dsx_exchange_read_retained` on guessed `/Value/` topics. + +## Non-BMS Discovery + +- For non-BMS schemas such as `power-management`, `nico`, and + `spiffe-exchange`, do not infer BMS-style `/Metadata/` and `/Value/` + companion topics unless `describe_topic` returns them. +- Use `dsx_exchange_find_topics` or `dsx_exchange_describe_topic` to get the + event topic filter, then use `dsx_exchange_subscribe` for bounded live + sampling when the user asks to listen, watch, fetch, or get events. + ## Background Subscribe - Run every `dsx_exchange_subscribe` call through the MCP client's native @@ -63,8 +88,5 @@ topic rate requires a narrower cap: - Do not use shell MQTT clients such as `mosquitto_sub` unless DSX Exchange MCP is unavailable and the user approves the fallback. -- Do not use removed server-side watch lifecycle tools such as - `start_subscription`, `read_subscription`, `subscription_status`, or - `stop_subscription`. - Do not ask the user for bearer tokens as tool arguments. The MCP client and server transport are responsible for passing authentication headers. From 6ef2082670a6d0962def17865050835f6859ebe5 Mon Sep 17 00:00:00 2001 From: Daniyal Rana Date: Wed, 24 Jun 2026 11:10:39 -0500 Subject: [PATCH 18/27] docs(mcp): keep docs local-only Signed-off-by: Daniyal Rana --- mcp/dsx-exchange-mcp/.dockerignore | 4 +- mcp/dsx-exchange-mcp/.gitignore | 14 +- mcp/dsx-exchange-mcp/Architecture.md | 4 - mcp/dsx-exchange-mcp/README.md | 2 - mcp/dsx-exchange-mcp/docs/current-v1-scope.md | 95 ------- mcp/dsx-exchange-mcp/docs/load-testing.md | 240 ------------------ .../docs/mcp-schema-prompt-eval-results.csv | 8 - .../docs/mcp-tasks-vs-explicit-async-tools.md | 147 ----------- .../docs/schema-tool-question-bank.md | 74 ------ 9 files changed, 3 insertions(+), 585 deletions(-) delete mode 100644 mcp/dsx-exchange-mcp/docs/current-v1-scope.md delete mode 100644 mcp/dsx-exchange-mcp/docs/load-testing.md delete mode 100644 mcp/dsx-exchange-mcp/docs/mcp-schema-prompt-eval-results.csv delete mode 100644 mcp/dsx-exchange-mcp/docs/mcp-tasks-vs-explicit-async-tools.md delete mode 100644 mcp/dsx-exchange-mcp/docs/schema-tool-question-bank.md diff --git a/mcp/dsx-exchange-mcp/.dockerignore b/mcp/dsx-exchange-mcp/.dockerignore index 151091c..ab9dc56 100644 --- a/mcp/dsx-exchange-mcp/.dockerignore +++ b/mcp/dsx-exchange-mcp/.dockerignore @@ -17,6 +17,4 @@ coverage.out # image. The server binary still embeds schemas from ./schemas at build time. cmd/dsx-exchange-token-proxy/ deploy/local-check/ -docs/gateway-auth-interactions.md -docs/local-deployment-check.md -docs/todo.md +docs/ diff --git a/mcp/dsx-exchange-mcp/.gitignore b/mcp/dsx-exchange-mcp/.gitignore index 1f07cf7..bf44bec 100644 --- a/mcp/dsx-exchange-mcp/.gitignore +++ b/mcp/dsx-exchange-mcp/.gitignore @@ -11,17 +11,7 @@ context/ /internal/specs/data/* !/internal/specs/data/.gitkeep -# Local-only MCP validation helpers; not part of the released server. +# Local-only MCP validation helpers and notes; not part of the released server. /cmd/dsx-exchange-token-proxy/ /deploy/local-check/ -/docs/gateway-auth-interactions.md -/docs/local-deployment-check.md -/docs/todo.md - -# Historical design notes kept locally, not in the public MCP docs tree. -/docs/dsx-exchange-mcp-sdd.md -/docs/local-llm-mcp-eval.md -/docs/long-running-subscriptions-ux.md -/docs/sdd-discussion-notes.md -/docs/v1-background-watch-benchmark-plan.md -/docs/watch-state-tradeoff-note.md +/docs/ diff --git a/mcp/dsx-exchange-mcp/Architecture.md b/mcp/dsx-exchange-mcp/Architecture.md index d09daaf..fb41b17 100644 --- a/mcp/dsx-exchange-mcp/Architecture.md +++ b/mcp/dsx-exchange-mcp/Architecture.md @@ -285,10 +285,6 @@ Responsibility split: | `dsx-exchange-mcp` | Extract credentials, translate MCP tools to embedded specs and bounded MQTT reads | | NATS/MQTT broker + auth-callout | Authenticate the delegated token (or noauth profile) and enforce topic ACLs | - -For gateway-specific auth interactions when a gateway is deployed, see -`docs/gateway-auth-interactions.md`. - ## MCP Tools Tool registration lives in `internal/server/tools.go`. diff --git a/mcp/dsx-exchange-mcp/README.md b/mcp/dsx-exchange-mcp/README.md index 2446c0c..0b8e532 100644 --- a/mcp/dsx-exchange-mcp/README.md +++ b/mcp/dsx-exchange-mcp/README.md @@ -280,7 +280,6 @@ Prompt quality is covered by fixture-based Go tests and the opt-in local LLM eval. Load validation is maintainer-oriented and intentionally separate from the public build/run path. -Use `docs/load-testing.md` only when intentionally running load experiments. Raw report bundles are local evidence and should stay under ignored `reports/`. ## Status @@ -293,6 +292,5 @@ schema discovery plus finite bounded MQTT reads. ## References -- Current v1 scope — `docs/current-v1-scope.md` - MCP spec — https://modelcontextprotocol.io/specification/2025-06-18/ - Go SDK — https://github.com/modelcontextprotocol/go-sdk diff --git a/mcp/dsx-exchange-mcp/docs/current-v1-scope.md b/mcp/dsx-exchange-mcp/docs/current-v1-scope.md deleted file mode 100644 index 03ffb61..0000000 --- a/mcp/dsx-exchange-mcp/docs/current-v1-scope.md +++ /dev/null @@ -1,95 +0,0 @@ - - -# DSX Exchange MCP Current V1 Scope - -This note records the current implementation scope for `dsx-exchange-mcp`. -Use this note as the current source of truth when older design discussions or -issue notes conflict with the shipped v1 surface. - -## Document Precedence - -Planning docs should be read newest-first. Later docs capture newer product and -engineering decisions, so they supersede older design language when scope or -priority differs. - -For the current branch: - -1. `current-v1-scope.md` -2. `mcp-tasks-vs-explicit-async-tools.md` - -The repository no longer keeps historical watch/stateful-session design notes in -the public MCP docs tree. `Architecture.md`, `README.md`, and this file describe -the current server shape. - -## In Scope For Current V1 - -Current v1 is a focused MCP interface for schema-aware, read-only access to DSX -Exchange topics. - -In scope: - -- Expose embedded AsyncAPI specs as MCP resources. -- Provide schema/topic discovery with `dsx_exchange_describe_topic` and - `dsx_exchange_find_topics`. -- Provide bounded MQTT reads with `dsx_exchange_read_retained` and - `dsx_exchange_subscribe`. -- Pass the caller bearer through to MQTT as the broker credential. -- Let the broker and auth-callout remain authoritative for topic ACL decisions. -- Return structured tool errors for missing bearer, invalid topics, broker - unavailability, auth failure, and ACL denial. -- For live-value get/fetch/read/sample/watch/listen/monitor UX, use repeated - bounded `dsx_exchange_subscribe` calls (client-side background - agent/subagent/task execution when the MCP host supports it), not server-side - subscription lifecycle tools. - -## Explicitly Out Of Scope For Current V1 - -Do not treat these as current v1 gaps: - -- Filtering MCP resource or schema-tool discovery by caller permissions. -- Hiding schema domains or schema helper tools before the caller attempts a - broker-backed MQTT read. -- Adding a separate entitlement API solely for current v1 discovery filtering. -- Implementing `dsx_exchange_bms_metadata_snapshot`. -- Implementing `dsx_exchange_build_bms_graph`. -- Implementing `dsx_exchange_summarize_subscription`. -- Implementing `dsx_exchange_aggregate_subscription`. -- Implementing `dsx_exchange_export_subscription`. -- Server-side watch/listen/monitor lifecycle tools - (`start_subscription`, `read_subscription`, `subscription_status`, - `stop_subscription`). -- Implementing MCP notifications for watch events. -- Making watches durable across pod restart or cross-pod failover. -- Storing raw JWTs, refreshing caller tokens, or resuming MQTT clients without a - fresh authenticated request. -- Adding Valkey, Redis, JetStream consumers, or worker pods for v1 watch state. - -These may be revisited later, but they are not required to call the current -branch useful or complete for its intended scope. - -## Possible Later Work - -Aggregation is the most plausible next feature after this scope because it can -reduce high-volume streams into smaller operator-facing results. - -Durable watch state, pod-local background subscription lifecycle tools, external -workers, cross-pod recovery, entitlement-driven discovery filtering, graph -construction, and export sinks should wait for clear product demand or benchmark -evidence. - -## Completion Bar - -For this scope, the branch is complete enough when: - -- Default MCP unit tests pass. -- Helm rendering/linting for the MCP chart passes. -- The MCP server can be deployed behind the gateway. -- A caller can discover schema topics, read retained metadata, and collect bounded - live messages with `dsx_exchange_subscribe`. -- Unauthorized MQTT topics fail through broker-backed structured errors instead - of being treated as empty data. -- Docs and examples describe the smaller v1 scope instead of implying the full - SDD backlog is required now. diff --git a/mcp/dsx-exchange-mcp/docs/load-testing.md b/mcp/dsx-exchange-mcp/docs/load-testing.md deleted file mode 100644 index 23eaeff..0000000 --- a/mcp/dsx-exchange-mcp/docs/load-testing.md +++ /dev/null @@ -1,240 +0,0 @@ - - -# DSX Exchange MCP Load Testing - -This note explains how to reproduce the current load-test methodology and keeps -the stable findings from local experiments. Raw report bundles are intentionally -not committed; they belong under ignored `reports/`. - -## What The Harness Tests - -`cmd/dsx-exchange-mcp-load` creates many independent MCP clients. Each client -performs its own MCP initialize flow, lists tools, and then runs one workload -scenario. Current `dsx-exchange-mcp` uses stateless JSON Streamable HTTP, so a -server may not return `Mcp-Session-Id`; the harness handles both stateless and -stateful endpoints. This is not an LLM test; it is a protocol and -backend-capacity test. - -Scenarios: - -| Scenario | Purpose | -| --- | --- | -| `discovery` | Exercise schema tools only: `find_topics` and `describe_topic`. | -| `schema-resources` | Exercise MCP resources: `resources/list` and `resources/read`. | -| `bounded-read` | Exercise broker-facing bounded reads: retained reads and short live subscribes. | -| `mixed` | Mix schema tools and bounded MQTT tools. | -| `mixed-stateless` | Alias-style scenario with the same public-surface intent as `mixed`. | - -Legacy watch scenarios (`watch`, `watch-hold`, `watch-status-hold`, and -`sticky-check`) remain in the harness for historical report comparison, but -current public v1 does not expose watch/listen/monitor lifecycle tools. - -`deploy/loadtest/run-kind-load-experiment.sh` wraps the load binary for local -Kind/gateway experiments. It records the manifest, image IDs, token TTL -metadata, cluster state before/after, JSON/TXT/CSV reports, per-operation -latency, and per-operation error attribution. - -## Reproduction Requirements - -The load harness depends on systems outside this MCP repo. Before running -gateway-backed deployed-bus tests, the operator needs: - -- a reachable MCP `/mcp` endpoint, either direct backend or gateway -- backend configuration for the target Event Bus MQTT endpoint through Helm - `natsURL` -- MQTT auth mode configured through Helm `mqtt.authMode` -- broker username configured through Helm `mqtt.username` when using - `jwt_passthrough` -- broker TLS trust configured through Helm `mqtt.tls.caCertSecret.name/key` -- broker TLS server name configured through Helm `mqtt.tls.serverName` when the - certificate requires it -- a fresh caller JWT from the approved secret manager or Vault flow when using - `jwt_passthrough` -- the JWT available to the load job as a Kubernetes secret named - `dsx-exchange-mcp-load-token` with key `bearer` when using - `jwt_passthrough` -- the MCP backend image and load-generator image available to the cluster - -Do not commit tokens, CA material, cluster snapshots, local endpoint names, or -raw generated report bundles. - -## Build And Run Path - -Start from the MCP module root: - -```sh -make sync-specs -make image -make load-image -``` - -`make image` builds the backend image as `dsx-exchange-mcp:dev`. -`make load-image` builds the load-generator image as -`dsx-exchange-mcp-load:dev`. Make those images available to the local cluster or -registry using the repo's existing deployment flow. - -After the gateway, backend, broker, CA trust, and load token secret are in -place, run the reusable wrapper: - -```sh -deploy/loadtest/run-kind-load-experiment.sh -``` - -The wrapper creates a Kubernetes Job for the load generator, applies the -selected gateway rate-limit helper when requested, records backend and load -image IDs, captures cluster state, and writes the report bundle under ignored -`reports/`. - -## Setup Checklist - -Use this checklist before treating load-test failures as MCP bugs: - -| Check | Expected state | -| --- | --- | -| MCP endpoint | MCP clients can reach the direct backend or gateway `/mcp` endpoint. | -| MQTT auth mode | Backend uses `jwt_passthrough` for deployed auth or `noauth` for local anonymous fallback. | -| Bearer passthrough | In `jwt_passthrough`, the caller bearer reaches `dsx-exchange-mcp` as `Authorization`. | -| Broker endpoint | Backend `NATS_URL` points at the intended MQTT endpoint. | -| Broker username | In `jwt_passthrough`, backend `MQTT_USERNAME` matches the broker OAuth profile. | -| Broker CA | Backend has a mounted CA file and `MQTT_TLS_CA_FILE` points to it. | -| TLS server name | Backend server-name override matches the broker certificate when required. | -| Load JWT secret | In `jwt_passthrough`, load namespace has `dsx-exchange-mcp-load-token` with data key `bearer`. | -| JWT freshness | In `jwt_passthrough`, token TTL is long enough for the full experiment. | -| Topic ACLs | The load topics are authorized for the caller identity. | - -If `discovery` passes but `bounded-read` or `mixed` fails, the next checks are -auth mode, bearer freshness, broker CA/server-name settings, topic ACLs, broker -availability, and MQTT admission limits. - -## Important Knobs - -Record these for every run so the result is reproducible: - -| Knob | Meaning | -| --- | --- | -| `SCENARIO` | Workload shape, such as `discovery`, `schema-resources`, `bounded-read`, or `mixed`. | -| `SESSION_SWEEP` / `SESSIONS` | Concurrent MCP client/session counts. | -| `STARTUP_RAMP` | Time window used to spread client startup. `0s` means an instant startup burst. | -| `DURATION` | Total wall-clock runtime after the load job starts. | -| `GATEWAY_RPS` | Gateway tenant rate-limit setting used during the run. | -| `CLIENT_RPS` | Load-generator request rate limit. | -| `BACKEND_REPLICAS` | Number of `dsx-exchange-mcp` backend pods. | -| `BACKEND_CONNECT_TIMEOUT_S` | Backend MQTT connect timeout. | -| `BACKEND_SUBSCRIBE_TIMEOUT_S` | Backend MQTT subscribe timeout. | -| `BACKEND_COLLECT_MAX_CONCURRENT` | Per-pod admission limit for bounded MQTT collectors. | -| `TOPIC` / `RETAINED_TOPIC` | Allowed live and retained topic filters used by broker-facing scenarios. | -| `RESET_BACKEND` | Whether the backend is restarted before the run. | - -Ramp and reset are important. A zero-ramp run measures thundering-herd startup -behavior. A ramped run is closer to organic production growth and makes it -easier to separate steady-state capacity from burst admission failures. Resetting -the backend before a run makes startup cost visible and avoids carrying state -from an earlier experiment. - -## Findings From Current Experiments - -These findings came from local Kind/gateway experiments using 100, 500, and -1000 MCP clients, 1-3 backend replicas, 30s/60s startup ramps, raised gateway -RPS variants, and MQTT timeout/admission experiments. Some older experiments -included watch lifecycle tools; those results are kept only as historical -capacity evidence. - -### Schema Discovery Is Mostly Healthy - -The `discovery` scenario, which only calls `find_topics` and `describe_topic`, -was healthy through the gateway: - -| Sessions | Backend replicas | Startup ramp | Success | -| ---: | ---: | ---: | ---: | -| 100 | 1 | 30s | 99.70% | -| 500 | 1 | 30s | 98.62% | -| 1000 | 1 | 30s | 97.39% | - -Failures were mostly client context deadlines near the wall-clock end of the -run, plus a small number of HTTP request failures. This indicates the schema -tools themselves are not the bottleneck. - -### Gateway Resource Proxy Needs Follow-Up - -The `schema-resources` scenario was unhealthy through the gateway: - -| Sessions | Backend replicas | Startup ramp | Success | -| ---: | ---: | ---: | ---: | -| 100 | 1 | 30s | 0.75% | -| 500 | 1 | 30s | 3.10% | -| 1000 | 1 | 30s | 6.41% | - -Direct backend validation of the same resource methods passed at 100%. That -points to gateway resource proxy/config/protocol behavior rather than backend -resource registration. - -### Mixed Load Bottlenecks On MQTT-Backed Tools - -The `mixed` scenario combines cheap schema calls with expensive broker-facing -calls. At high session counts, failures were dominated by: - -- bounded `subscribe` -- `read_retained` -- MQTT admission limiting -- broker unavailable or MQTT subscribe/connect deadline errors - -Schema tools in the same mixed run had only small deadline/HTTP failures. The -reason is shared-path pressure: even a cheap schema call still goes through the -client, gateway, session lookup/routing, backend HTTP handler, JSON-RPC decode, -tool dispatch, JSON encode, and response path. When many MQTT calls are waiting -on connect/subscribe or admission, they consume shared gateway/backend capacity -and cheap calls can miss client deadlines. - -### Historical Watch Status Result - -`watch-status-hold` performed much better than mixed load once watches were -started and clients mostly polled `subscription_status`: - -| Scenario | Replicas | Sessions | Success | -| --- | ---: | ---: | ---: | -| `watch-status-hold` | 1 | 500 | 99.996% | -| `watch-status-hold` | 2 | 500 | 100.000% | -| `watch-status-hold` | 3 | 500 | 100.000% | -| `watch-status-hold` | 2 | 1000 | 98.743% | -| `watch-status-hold` | 3 | 1000 | 98.864% | - -This was useful evidence that lightweight status polling is cheaper than -repeated broker startup. The public v1 direction has since moved away from -server-side watch state, so new UX validation should focus on finite -`dsx_exchange_subscribe` calls that clients can run in the background when they -support that primitive. - -### Replicas Help Steady State, Not Broker Startup Storms - -Adding backend replicas did not automatically fix mixed bounded MQTT load. The -dominant cost was concurrent MQTT connect/subscribe against the external broker, -not pure CPU work inside one MCP pod. More pods can increase the number of -simultaneous broker connection attempts, so scaling replicas without admission -control can move the bottleneck to the broker/auth/network path faster. - -### Timeout Alone Is Not A Fix - -Raising MQTT connect/subscribe timeouts from 10s to 20s or 30s did not resolve -high-concurrency mixed failures. Longer timeouts can reduce premature failures -when the system is merely slow, but they can also hide overload by making -requests sit in limbo longer. Admission limits and startup ramping gave clearer -signals about whether a run was burst-limited or steady-state-limited. - -## Current Interpretation - -For current v1, the MCP server is useful and bounded when: - -- schema discovery is used freely -- retained/live bounded reads stay small -- long listen/watch/monitor prompts are implemented as finite - `dsx_exchange_subscribe` calls -- MCP clients run long bounded calls in the background when they support that - UX primitive - -The next scale work should focus on gateway resource handling, MQTT startup -backpressure, and pod-failure behavior. Durable external watch state should stay -out of scope unless product/load evidence shows bounded background tool calls -are not enough. diff --git a/mcp/dsx-exchange-mcp/docs/mcp-schema-prompt-eval-results.csv b/mcp/dsx-exchange-mcp/docs/mcp-schema-prompt-eval-results.csv deleted file mode 100644 index e4da151..0000000 --- a/mcp/dsx-exchange-mcp/docs/mcp-schema-prompt-eval-results.csv +++ /dev/null @@ -1,8 +0,0 @@ -"eval_date","eval_scope","prompt_id","prompt","expected_describe_calls","expected_runtime_plan","expected_schema","actual_mcp_describe_trace","actual_schema_result","result","prompt_level_analysis","llm_prompt_eval_status" -"2026-06-01","gateway_mcp_schema_eval","bms-rack-temperature-latest","Grab me all of the most recent rack temperature data.","dsx_exchange_describe_topic(BMS/v1/PUB/Metadata/Rack/RackLiquidSupplyTemperature/#); dsx_exchange_describe_topic(BMS/v1/PUB/Metadata/Rack/RackLiquidReturnTemperature/#)","read_retained metadata for supply and return temperature; subscribe to BMS/v1/PUB/Value/Rack/RackLiquidSupplyTemperature/# and BMS/v1/PUB/Value/Rack/RackLiquidReturnTemperature/#","bms/rackMetadata; related value topics BMS/v1/PUB/Value/Rack/RackLiquidSupplyTemperature/# and BMS/v1/PUB/Value/Rack/RackLiquidReturnTemperature/#","describe supply metadata -> count=1 first=bms/rackMetadata; describe return metadata -> count=1 first=bms/rackMetadata","expected channels found through http://localhost:18180/mcp using dsx_exchange_describe_topic","PASS","Good schema signal for the prompt. The expected plan is metadata-first because the user asked for most recent rack temperature data, so retained metadata should be read before sampling live value topics. Current fixture only treats RackLiquidSupplyTemperature and RackLiquidReturnTemperature as rack temperature data; if air-side rack temperature points are later added, this prompt may need broader topic discovery.","NOT_RUN_NO_LOCAL_LLM_ENDPOINT" -"2026-06-01","gateway_mcp_schema_eval","bms-rack-liquid-isolation-status","Show me rack liquid isolation status updates.","dsx_exchange_describe_topic(BMS/v1/PUB/Value/Rack/RackLiquidIsolationStatus/#)","read_retained BMS/v1/PUB/Metadata/Rack/RackLiquidIsolationStatus/#; subscribe to BMS/v1/PUB/Value/Rack/RackLiquidIsolationStatus/#","bms/rackBmsValue; related metadata topic BMS/v1/PUB/Metadata/Rack/RackLiquidIsolationStatus/#","describe value topic -> count=2 first=bms/rackBmsValue","expected channel found; extra overlapping BMS integration-value channel is also matched","PASS_WITH_EXTRA_MATCH","The prompt maps to the correct live BMS value topic and related metadata topic. The extra match is caused by the generic integration channel BMS/v1/{integration}/Value/Rack/{pointType}/{tagPath} overlapping PUB; the schema matcher does not currently enforce parameter enum constraints, so an LLM may need ranking or filtering to prefer rackBmsValue for PUB-owned telemetry.","NOT_RUN_NO_LOCAL_LLM_ENDPOINT" -"2026-06-01","gateway_mcp_schema_eval","bms-rack-power","What topic should I use for rack power telemetry?","dsx_exchange_describe_topic(BMS/v1/PUB/Value/Rack/RackPower/#)","read_retained BMS/v1/PUB/Metadata/Rack/RackPower/#; subscribe to BMS/v1/PUB/Value/Rack/RackPower/#","bms/rackBmsValue; related metadata topic BMS/v1/PUB/Metadata/Rack/RackPower/#","describe value topic -> count=2 first=bms/rackBmsValue","expected channel found; extra overlapping BMS integration-value channel is also matched","PASS_WITH_EXTRA_MATCH","The schema tool gives enough signal to answer with the correct rack power value topic and metadata companion. Same caveat as liquid isolation status: the overlapping integration-value channel should be deprioritized or eliminated by enum-aware matching so the model does not over-explain irrelevant integration topics.","NOT_RUN_NO_LOCAL_LLM_ENDPOINT" -"2026-06-01","gateway_mcp_schema_eval","power-breach-alerts","Listen for power breach alerts from power agents.","dsx_exchange_describe_topic(grid/v1/poweragent/+/powerbreach)","subscribe to grid/v1/poweragent/+/powerbreach","power-management/powerBreachAlertChannel","describe topic -> count=1 first=power-management/powerBreachAlertChannel","expected channel found through http://localhost:18180/mcp using dsx_exchange_describe_topic","PASS","Clean mapping. The prompt is naturally a live subscription request, and there is no retained metadata companion in the current AsyncAPI schema. Expected behavior is describe first, then bounded subscribe.","NOT_RUN_NO_LOCAL_LLM_ENDPOINT" -"2026-06-01","gateway_mcp_schema_eval","power-state-status","Find current power state status events.","dsx_exchange_describe_topic(grid/v1/poweragent/+/powerstate/status)","subscribe to grid/v1/poweragent/+/powerstate/status","power-management/powerStateStatusChannel","describe topic -> count=1 first=power-management/powerStateStatusChannel","expected channel found through http://localhost:18180/mcp using dsx_exchange_describe_topic","PASS","Clean mapping for power-agent status events. The word current could tempt a retained read, but the current schema fixture expects this as an event subscription because no retained metadata/value split is modeled for power-management.","NOT_RUN_NO_LOCAL_LLM_ENDPOINT" -"2026-06-01","gateway_mcp_schema_eval","power-enforcement-outcomes","Which topic has infrastructure enforcement outcomes for power breaches?","dsx_exchange_describe_topic(grid/v1/infra/+/powerbreach/enforcement)","subscribe to grid/v1/infra/+/powerbreach/enforcement","power-management/powerBreachEnforcementChannel","describe topic -> count=1 first=power-management/powerBreachEnforcementChannel","expected channel found through http://localhost:18180/mcp using dsx_exchange_describe_topic","PASS","Clean mapping. This is more of a topic discovery question than a data retrieval request, but the expected plan still includes a bounded subscribe if the client wants live enforcement outcomes.","NOT_RUN_NO_LOCAL_LLM_ENDPOINT" -"2026-06-01","gateway_mcp_schema_eval","nico-machine-state","Subscribe to NICO machine state changes.","dsx_exchange_describe_topic(NICO/v1/machine/+/state)","subscribe to NICO/v1/machine/+/state","nico/managedHostState","describe topic -> count=1 first=nico/managedHostState","expected schema channel found; separate runtime subscribe should stop after broker ACL denial","PASS_RUNTIME_ACL_GATING_REQUIRED","Schema lookup quality is good and schema discovery does not need to be ACL-blocked. The required intelligence is runtime adaptive planning: if read_retained or subscribe returns an ACL/authorization denial for NICO, the MCP client or server should mark that namespace/domain as unavailable for this caller/session and avoid repeatedly pursuing NICO subscriptions unless the user explicitly asks to retry or changes credentials.","NOT_RUN_NO_LOCAL_LLM_ENDPOINT" diff --git a/mcp/dsx-exchange-mcp/docs/mcp-tasks-vs-explicit-async-tools.md b/mcp/dsx-exchange-mcp/docs/mcp-tasks-vs-explicit-async-tools.md deleted file mode 100644 index 3bade22..0000000 --- a/mcp/dsx-exchange-mcp/docs/mcp-tasks-vs-explicit-async-tools.md +++ /dev/null @@ -1,147 +0,0 @@ -# MCP Tasks vs Explicit Async Tools - -This note compares two ways for `dsx-exchange-mcp` to expose long-running MQTT -subscriptions through MCP. - -## Summary - -`dsx-exchange-mcp` can provide asynchronous subscription behavior even without -native MCP Tasks by exposing ordinary tools that create, inspect, fetch, and -cancel background work. Native MCP Tasks move that same lifecycle into the MCP -protocol so the client or host can understand and manage it generically. - -The backend distributed-systems requirements are mostly the same in both cases: -Valkey-backed task state, worker lease/heartbeat, bounded result buffers, -cancellation, expiry, no raw JWT persistence, and clear failover semantics. - -## Explicit Async Tools - -In this model, async behavior is represented as normal MCP tools. - -Example tool surface: - -| Tool | Purpose | -| --- | --- | -| `dsx_exchange_start_subscription` | Starts a background MQTT watch and returns immediately with a task/subscription ID. | -| `dsx_exchange_task_status` | Reads task state, heartbeat, counters, expiry, and error information. | -| `dsx_exchange_task_result` | Reads buffered or final subscription results. | -| `dsx_exchange_cancel_task` | Requests cooperative cancellation. | -| `dsx_exchange_list_tasks` | Lists recent tasks visible to the caller. | - -Typical flow: - -```text -MCP client - -> tools/call dsx_exchange_start_subscription(...) - <- { "task_id": "watch_123", "status": "working", "poll_interval_s": 5 } - -MCP client - -> tools/call dsx_exchange_task_status({ "task_id": "watch_123" }) - <- { "status": "working", "message_count": 42 } - -MCP client - -> tools/call dsx_exchange_task_result({ "task_id": "watch_123" }) - <- { "status": "completed", "messages": [...] } -``` - -This is still asynchronous because the initial tool call does not hold the HTTP -request open for the full MQTT subscription lifetime. It returns a handle, while -the server continues work in the background. - -The downside is that the async contract lives in tool descriptions and agent -behavior. The agent must remember the task ID, decide when to poll, choose when -to fetch results, and call the correct cancellation tool. - -## Native MCP Tasks - -Native MCP Tasks represent async work at the protocol level. - -Expected flow: - -```text -MCP client - -> tools/call dsx_exchange_watch(...) with task support - <- CreateTaskResult / task_id - -MCP host or client - -> tasks/get(task_id) - <- task status/progress - -MCP host or client - -> tasks/result(task_id) - <- final CallToolResult - -MCP host or client - -> tasks/cancel(task_id) - <- cancellation acknowledgement -``` - -Native Tasks let the server advertise task capability and allow the MCP host to -own the async loop instead of relying on the model to learn a custom tool -workflow. - -## Added Benefit of Native MCP Tasks - -| Benefit | Why it matters | -| --- | --- | -| Host-owned polling | The MCP client/host can poll `tasks/get` without relying on the LLM to remember to call a status tool. | -| Protocol-owned task ID | The task ID is part of MCP state, not just text in a tool result. | -| Standard status UX | Clients can show running, completed, failed, cancelled, progress, and result availability consistently. | -| Deferred result retrieval | `tasks/result` provides a standard path to fetch the final tool result later. | -| Capability negotiation | The server can advertise which tools support task mode. | -| Tool execution semantics | A tool can declare task behavior such as required, optional, or forbidden. | -| Standard cancellation | `tasks/cancel` avoids a custom cancel tool per server. | -| Better reconnect behavior | A client can reconnect and continue polling a known task ID using protocol semantics. | -| Less prompt engineering | Tool descriptions do not need to teach every client the start/status/result/cancel loop. | -| Future interoperability | Gateways, dashboards, traces, and agent runtimes can understand task lifecycle generically. | - -## What Native Tasks Do Not Solve - -Native MCP Tasks improve the protocol and client UX. They do not eliminate the -backend work needed for long-running DSX Exchange subscriptions. - -Both approaches still require: - -- Valkey or equivalent durable task metadata and bounded result storage. -- Worker lease and heartbeat records. -- MQTT client lifecycle management. -- Cancellation checks and broker disconnect. -- Task TTL and result retention. -- Caller access checks for task status and result reads. -- A policy for token expiry while a watch is running. -- No raw JWT persistence in task state. -- Explicit failover semantics; pod failure may still create a subscription gap. - -## SDK Implications - -As of this note, the Go MCP SDK does not expose native Tasks APIs. The Python SDK -has experimental Tasks support, but using it would not remove the state, -failover, and JWT lifecycle work above. - -For a conservative Go v1, explicit async tools are the lower-churn path. The -internal task model should still align with MCP Tasks terminology (`working`, -`completed`, `failed`, `cancelled`, expiry, result retrieval, cancellation) so -the server can migrate to native MCP Tasks when Go SDK support and client -support are ready. - -## Recommendation - -For v1: - -1. Keep bounded synchronous tools for simple reads and short live samples. -2. Add explicit async tools for long-running MQTT watches. -3. Store task state/results in Valkey for cross-pod visibility and recovery - metadata. -4. Do not store raw JWTs; use the current caller bearer only when starting or - resuming an MQTT client. -5. Document best-effort failover: a later authenticated request can resume a - watch, but events may be missed during pod outage. -6. Track native MCP Tasks as a future API-layer migration once the Go SDK and - target MCP clients support it. - -References: - -- MCP Tasks specification: https://modelcontextprotocol.io/specification/2025-11-25/basic/utilities/tasks -- MCP Tasks overview: https://modelcontextprotocol.io/extensions/tasks/overview -- Go SDK Tasks issue: https://github.com/modelcontextprotocol/go-sdk/issues/626 -- Python SDK Tasks issue: https://github.com/modelcontextprotocol/python-sdk/issues/1546 diff --git a/mcp/dsx-exchange-mcp/docs/schema-tool-question-bank.md b/mcp/dsx-exchange-mcp/docs/schema-tool-question-bank.md deleted file mode 100644 index c00f98d..0000000 --- a/mcp/dsx-exchange-mcp/docs/schema-tool-question-bank.md +++ /dev/null @@ -1,74 +0,0 @@ -# Schema Tool Question Bank - -This question bank documents expected tool-call plans for natural-language -requests that should be answered from the embedded AsyncAPI schema catalogue. -The executable copy lives in -`internal/server/testdata/tool_call_expectations.json`. - -These examples validate schema-driven planning only. Runtime tests execute -`dsx_exchange_describe_topic` and validate MQTT filter syntax, but they do not -connect to the live broker for `read_retained` or `subscribe`. - -## BMS - -### Grab me all of the most recent rack temperature data. - -Expected flow: - -1. `dsx_exchange_describe_topic({"topic_filter":"BMS/v1/PUB/Metadata/Rack/RackLiquidSupplyTemperature/#"})` -2. `dsx_exchange_describe_topic({"topic_filter":"BMS/v1/PUB/Metadata/Rack/RackLiquidReturnTemperature/#"})` -3. `dsx_exchange_read_retained({"topic_filter":"BMS/v1/PUB/Metadata/Rack/RackLiquidSupplyTemperature/#","max_messages":1000})` -4. `dsx_exchange_read_retained({"topic_filter":"BMS/v1/PUB/Metadata/Rack/RackLiquidReturnTemperature/#","max_messages":1000})` -5. `dsx_exchange_subscribe({"topic_filter":"BMS/v1/PUB/Value/Rack/RackLiquidSupplyTemperature/#","max_messages":100,"max_duration_s":30})` -6. `dsx_exchange_subscribe({"topic_filter":"BMS/v1/PUB/Value/Rack/RackLiquidReturnTemperature/#","max_messages":100,"max_duration_s":30})` - -Rationale: metadata is retained and gives point identity/units/relationships; -value topics are live and separate supply/return point types. - -### Show me rack liquid isolation status updates. - -Expected flow: - -1. `dsx_exchange_describe_topic({"topic_filter":"BMS/v1/PUB/Value/Rack/RackLiquidIsolationStatus/#"})` -2. `dsx_exchange_read_retained({"topic_filter":"BMS/v1/PUB/Metadata/Rack/RackLiquidIsolationStatus/#","max_messages":1000})` -3. `dsx_exchange_subscribe({"topic_filter":"BMS/v1/PUB/Value/Rack/RackLiquidIsolationStatus/#","max_messages":100,"max_duration_s":30})` - -### What topic should I use for rack power telemetry? - -Expected flow: - -1. `dsx_exchange_describe_topic({"topic_filter":"BMS/v1/PUB/Value/Rack/RackPower/#"})` -2. `dsx_exchange_read_retained({"topic_filter":"BMS/v1/PUB/Metadata/Rack/RackPower/#","max_messages":1000})` -3. `dsx_exchange_subscribe({"topic_filter":"BMS/v1/PUB/Value/Rack/RackPower/#","max_messages":100,"max_duration_s":30})` - -## Power Management - -### Listen for power breach alerts from power agents. - -Expected flow: - -1. `dsx_exchange_describe_topic({"topic_filter":"grid/v1/poweragent/+/powerbreach"})` -2. `dsx_exchange_subscribe({"topic_filter":"grid/v1/poweragent/+/powerbreach","max_messages":100,"max_duration_s":30})` - -### Find current power state status events. - -Expected flow: - -1. `dsx_exchange_describe_topic({"topic_filter":"grid/v1/poweragent/+/powerstate/status"})` -2. `dsx_exchange_subscribe({"topic_filter":"grid/v1/poweragent/+/powerstate/status","max_messages":100,"max_duration_s":30})` - -### Which topic has infrastructure enforcement outcomes for power breaches? - -Expected flow: - -1. `dsx_exchange_describe_topic({"topic_filter":"grid/v1/infra/+/powerbreach/enforcement"})` -2. `dsx_exchange_subscribe({"topic_filter":"grid/v1/infra/+/powerbreach/enforcement","max_messages":100,"max_duration_s":30})` - -## NICO - -### Subscribe to NICO machine state changes. - -Expected flow: - -1. `dsx_exchange_describe_topic({"topic_filter":"NICO/v1/machine/+/state"})` -2. `dsx_exchange_subscribe({"topic_filter":"NICO/v1/machine/+/state","max_messages":100,"max_duration_s":30})` From a80d9451bec1e903dc8aabd0e7c8c0daa5e044a4 Mon Sep 17 00:00:00 2001 From: Daniyal Rana Date: Wed, 24 Jun 2026 12:11:11 -0500 Subject: [PATCH 19/27] docs(mcp): simplify MCP README Signed-off-by: Daniyal Rana --- mcp/dsx-exchange-mcp/README.md | 377 ++++++++++++--------------------- 1 file changed, 137 insertions(+), 240 deletions(-) diff --git a/mcp/dsx-exchange-mcp/README.md b/mcp/dsx-exchange-mcp/README.md index 0b8e532..8ab6989 100644 --- a/mcp/dsx-exchange-mcp/README.md +++ b/mcp/dsx-exchange-mcp/README.md @@ -1,296 +1,193 @@ # dsx-exchange-mcp -MCP server that exposes the DSX Exchange AsyncAPI specs as Resources and a -read-only NATS-MQTT bridge as Tools. One server for all DSX Exchange domains. - -Runs standalone over Streamable HTTP. - -## What it exposes - -**Resources** — AsyncAPI 3.1.0 specs, embedded at build time: -- `dsx-exchange://specs/` — index of available domains -- `dsx-exchange://specs/{domain}` — raw YAML for one domain - (e.g. `bms`, `nico`, `power-management`, `spiffe-exchange`) - -**Tools** — schema discovery plus read-only MQTT against the DSX Event Bus: - -Schema discovery tools do not connect to MQTT. They inspect the embedded -AsyncAPI bundle so the client can choose a valid topic before touching the -broker: - -- `dsx_exchange_find_topics(query, domain, limit)` — search the embedded - AsyncAPI index for relevant Exchange topics before choosing a concrete - broker read. -- `dsx_exchange_describe_topic(topic_filter)` — parse embedded AsyncAPI specs - and describe the matching schema channel, payload shape, retained/live - behavior, examples, and related metadata/value topics. - -Bounded MQTT tools create a short-lived broker connection for one request and -return within configured message, duration, and byte limits: - -- `dsx_exchange_subscribe(topic_filter, max_messages, max_duration_s)` — - subscribe and collect messages over a bounded window. Use this for live - values. For live-value get/fetch/read/sample/watch/listen/monitor requests, - MCP clients that support background agent, subagent, task, or equivalent - execution should run this tool through that mechanism so the main chat can - keep working. If background execution is unavailable, use short sampling - windows and repeat the call. -- `dsx_exchange_read_retained(topic_filter, max_messages)` — drain retained - messages currently held by the broker. Use this for metadata; BMS values are - not retained (republished on change every ~100 s). - -Topic filters use standard MQTT wildcards: `+` (single level), `#` (multi-level, -end of filter only). - -Why this split exists: MCP tool calls are fundamentally request/response. A -long MQTT subscription inside one foreground tool call can tie up the MCP client -while it waits for stream data, which is a poor fit for sparse or ongoing -telemetry. The preferred stateless pattern is to use `dsx_exchange_subscribe` -with bounded limits and have agent runtimes run long sampling calls through a -background agent, subagent, task, or equivalent mechanism when they support that -primitive. MCP Tasks or response streaming may eventually provide a cleaner -protocol-level answer, but those paths are still experimental for this use case. -The public v1 surface intentionally avoids -server-side watch/listen/monitor state: one MQTT tool call creates a temporary -client, subscribes for a finite window, returns bounded results, and disconnects. - -## Auth - -The server supports two MQTT auth modes. It does not accept JWTs as tool -arguments. - -- `jwt_passthrough` (default): each MCP request may include - `Authorization: Bearer `. Broker-backed tools present that bearer to - MQTT as `username=`, `password=`. The DSX Exchange - auth-callout validates the JWT and enforces topic ACLs. -- `noauth`: broker-backed tools send no MQTT username or password. Use this - only with local/dev Event Bus deployments configured with the noauth - anonymous fallback. - -Schema discovery tools do not connect to MQTT and therefore do not require a -bearer. Broker-backed tools in `jwt_passthrough` mode return a structured -`missing_bearer` tool error when the MCP request has no bearer. +MCP server for DSX Exchange schemas, topic discovery, and read-only MQTT access +to the DSX Event Bus. It runs standalone over Streamable HTTP and serves one +MCP endpoint for all synced DSX Exchange domains. + +## What It Exposes + +| Surface | Name | Purpose | +| --- | --- | --- | +| Resource | `dsx-exchange://specs/` | Index of embedded AsyncAPI domains. | +| Resource | `dsx-exchange://specs/{domain}` | Raw AsyncAPI YAML for one domain, such as `bms`, `nico`, `power-management`, or `spiffe-exchange`. | +| Tool | `dsx_exchange_find_topics(query, domain, limit)` | Search the embedded AsyncAPI topic catalogue before choosing a broker read. | +| Tool | `dsx_exchange_describe_topic(topic_filter)` | Describe the matching schema channel, payload shape, retained/live behavior, examples, and related metadata/value topics. | +| Tool | `dsx_exchange_read_retained(topic_filter, max_messages)` | Read retained metadata and retained state. For BMS, use retained `/Metadata/` topics before subscribing to live `/Value/` topics. | +| Tool | `dsx_exchange_subscribe(topic_filter, max_messages, max_duration_s)` | Collect live messages over a bounded window, then disconnect. Use this for live values. | + +Schema discovery tools use only the embedded AsyncAPI bundle and do not connect +to MQTT. Broker-backed tools create a short-lived MQTT connection for one +request and return within configured message, duration, and byte limits. + +Topic filters use standard MQTT wildcards: `+` for one level and `#` for the +final multi-level suffix. For long or sparse live sampling, MCP clients that +support background agents, subagents, tasks, or equivalent execution should run +`dsx_exchange_subscribe` there so the main chat can keep working. See +[skills/dsx-exchange-mcp/SKILL.md](skills/dsx-exchange-mcp/SKILL.md) for +client and agent workflow guidance. Tools are currently scoped to stateless, +finite samples: each subscribe call collects a finite sample and exits. + +## Authentication + +The server supports two MQTT authentication modes. JWTs are never accepted as +tool arguments. + +| Mode | Use case | Behavior | +| --- | --- | --- | +| `jwt_passthrough` | Default mode for deployed DSX Exchange brokers. | Broker-backed tools read `Authorization: Bearer ` from the MCP request and present it to MQTT as `username=`, `password=`. | +| `noauth` | Local/dev Event Bus deployments configured with anonymous fallback. | Broker-backed tools send no MQTT username or password. | +| Schema-only | Resource reads plus `find_topics` and `describe_topic`. | No broker connection is made, so no bearer is required. | + +In `jwt_passthrough` mode, broker-backed tools return a structured +`missing_bearer` tool error when the MCP request has no bearer. Broker-side +auth-callout remains the source of truth for JWT validation and topic ACLs. ## Layout +```text +. +|-- cmd/dsx-exchange-mcp/ main, environment wiring, HTTP listener +|-- internal/ +| |-- auth/ bearer extraction and request identity +| |-- server/ MCP server, resources, and tools +| |-- specs/ embedded raw AsyncAPI resources +| |-- schemaindex/ parsed AsyncAPI topic catalogue +| `-- mqttbus/ MQTT client wrapper +|-- deploy/helm/ Helm chart +`-- schemas/ embedded copy of the repository root schemas ``` -cmd/dsx-exchange-mcp main, env wiring, HTTP listener -internal/auth bearer extraction + request identity context -internal/server MCP server, resource & tool registration -internal/specs raw AsyncAPI resources from embedded schemas -internal/schemaindex parsed AsyncAPI topic catalogue for schema tools -internal/mqttbus paho v3 client wrapper (jwt_passthrough/noauth + TLS) -deploy/helm chart (kata runtime, readonly rootfs, drop ALL caps) -schemas/ generated embedded copy of monorepo root schemas/ -``` -## Build & run +For the full server design, schema indexing behavior, authentication flow, and +deployment shape, see [Architecture.md](Architecture.md). + +## Usage Fast local process path: ```sh cd mcp/dsx-exchange-mcp -make sync-specs # copies ../../schemas/ into ./schemas +make sync-specs make test make build -make run # listens on :8080 +make run ``` Configure an MCP client with `http://127.0.0.1:8080/mcp`. Schema resources and -schema discovery tools work without a broker connection. MQTT-backed tools need -`NATS_URL` to point at a reachable broker and need the MCP client to provide -`Authorization: Bearer `. +schema discovery tools work without a broker. MQTT-backed tools also need +`NATS_URL` to point at a reachable broker and, in `jwt_passthrough` mode, a +bearer on the MCP request. -Images: +Build the local development image: ```sh -make image # builds dsx-exchange-mcp:dev +make image ``` -Run `make sync-specs` before building the server binary or image when the -monorepo `schemas/` tree has changed. The image uses the already-synced -`./schemas` tree and does not fetch schemas at runtime. - -## MCP client skill - -Client-facing agent guidance lives at `skills/dsx-exchange-mcp/SKILL.md`. -MCP clients or agent runtimes that support skill, rule, or instruction import -can use that file to teach agents the intended workflow: - -- use schema discovery tools inline -- use retained reads for metadata and last-known retained values -- run `dsx_exchange_subscribe` through a background agent, subagent, task, or - equivalent mechanism when available - -The skill is client-agnostic and does not include client-specific setup. - -## Environment - -| Var | Default | Notes | -| --- | --- | --- | -| `MCP_ADDR` | `:8080` | listener for `/mcp` (Streamable HTTP) | -| `NATS_URL` | `tcp://nats:1883` | MQTT 3.1.1 facade on the NATS broker | -| `MCP_MQTT_AUTH_MODE` | `jwt_passthrough` | `jwt_passthrough` or `noauth` | -| `MQTT_USERNAME` | `oauthtoken` | MQTT username used only in `jwt_passthrough` mode | -| `MQTT_CONNECT_TIMEOUT_S` | `5` | timeout for MQTT CONNECT | -| `MQTT_SUBSCRIBE_TIMEOUT_S` | `5` | timeout for MQTT SUBSCRIBE | -| `MQTT_TLS_CA_FILE` | (unset) | optional root CA bundle for private broker CA | -| `MQTT_TLS_SERVER_NAME` | (unset) | optional TLS server name override | -| `MQTT_TLS_INSECURE_SKIP_VERIFY` | `false` | local-dev only; rejected by Helm unless acknowledged | -| `MCP_DEFAULT_MAX_MESSAGES` | `100` | default message cap per tool call | -| `MCP_MAX_MESSAGES` | `1000` | hard message cap per tool call | -| `MCP_DEFAULT_MAX_DURATION_S` | `30` | default subscribe window | -| `MCP_MAX_DURATION_S` | `30` | hard subscribe window cap | -| `MQTT_MAX_RESULT_BYTES` | `1048576` | max returned topic+payload bytes | -| `MCP_MQTT_COLLECT_MAX_CONCURRENT_PER_POD` | `100` | per-pod admission limit for bounded MQTT collectors | -| `MCP_FIND_TOPICS_DEFAULT_LIMIT` | `20` | default schema search result cap | -| `MCP_FIND_TOPICS_MAX_LIMIT` | `100` | hard schema search result cap | -| `LOG_FORMAT` | `json` | structured logs | - -Health endpoints are served on the same listener: - -- `/healthz/live` -- `/healthz/ready` - -TLS trust is deployment configuration, not MCP tool input. For deployed-bus -tests or production, mount the broker root CA and set `MQTT_TLS_CA_FILE`. -Agents provide bearer credentials through MCP request headers only, never tool -arguments. In `noauth` local mode, do not provide a dummy token; the MQTT client -intentionally sends no username/password so the Event Bus noauth fallback can -match. - -The public schema tree is copied from the monorepo root `schemas/` directory. Override the location with `SCHEMA_SRC=/path/to/schemas make sync-specs`. - -## Specs are pinned at build time - -`make sync-specs` copies the monorepo schema tree from `../../schemas` into `schemas/`, and `schemas/embed.go` -bakes it into the binary. The image is hermetic — no runtime fetch from GitLab. -Empty domain stubs are filtered out at startup so they don't surface as MCP -resources or schema tool matches. - -To update specs, re-run `sync-specs` against a refreshed schema checkout and -cut a new image. - -## Deploy to local Kind - -The Helm chart lives at `deploy/helm/dsx-exchange-mcp/`. Production-oriented -defaults keep the container non-root with a read-only root filesystem and -`drop: ["ALL"]`; local Kind overrides live in -`deploy/helm/dsx-exchange-mcp/values.kind.yaml`. - -The repo root Skaffold flow deploys the local Event Bus stack and this MCP -backend: +Deploy the local Event Bus stack and MCP backend with the repository Skaffold +flow: ```sh make -C local skaffold-run ``` -The local topology uses only the Event Bus Kind clusters created by -`local/infra/scripts/setup-clusters.sh`: `kind-csc`, `kind-cpc-1`, and -`kind-cpc-2`. The MCP server is a Helm release in `kind-csc`, namespace -`mcp-backends`; no separate MCP gateway cluster is part of this path. - -To deploy or redeploy only the MCP backend after the local stack already exists: +To redeploy only the MCP backend after the local Kind stack already exists: ```sh cd mcp/dsx-exchange-mcp make skaffold-run-kind ``` -Expose the backend for a desktop MCP client: +Expose the Kind backend for a desktop MCP client: ```sh make port-forward-kind ``` -Configure the MCP client with `http://127.0.0.1:18080/mcp`. In Kind, the MCP pod -uses `tcp://nats.event-bus.svc.cluster.local:1883` from `values.kind.yaml`. -This path intentionally does not require an MCP gateway. The Kind values use -`MCP_MQTT_AUTH_MODE=noauth`, matching the local Event Bus noauth setup. +Configure the MCP client with `http://127.0.0.1:18080/mcp`. In Kind, the MCP +pod is installed in `kind-csc`, namespace `mcp-backends`, and uses +`MCP_MQTT_AUTH_MODE=noauth` with the local Event Bus MQTT endpoint from +`values.kind.yaml`. -## Setup checklist +Run `make sync-specs` before building the server binary or image when the +repository root `schemas/` tree changes. Override the source with +`SCHEMA_SRC=/path/to/schemas make sync-specs`. -Before an MCP client or opt-in validation can call broker-backed tools, verify: +## Environment -| Item | What the operator provides | Where this MCP expects it | +| Variable | Default | Notes | | --- | --- | --- | -| MCP endpoint | A reachable direct server `/mcp` endpoint | `DSX_EXCHANGE_MCP_URL` for tests/tools | -| MQTT auth mode | `jwt_passthrough` for deployed broker auth, `noauth` for local anonymous fallback | Helm `mqtt.authMode`, runtime `MCP_MQTT_AUTH_MODE` | -| Broker endpoint | MQTT endpoint for the DSX Event Bus | Helm `natsURL`, runtime `NATS_URL` | -| Broker username | OAuth profile username for MQTT CONNECT in `jwt_passthrough` mode | Helm `mqtt.username`, runtime `MQTT_USERNAME` | -| Broker CA | Root/intermediate CA bundle for broker TLS | Secret referenced by `mqtt.tls.caCertSecret.name/key` | -| TLS server name | Broker certificate server name, if needed | Helm `mqtt.tls.serverName`, runtime `MQTT_TLS_SERVER_NAME` | -| Caller JWT | Fresh user/service bearer from the deployment's approved identity flow when using `jwt_passthrough` | MCP `Authorization: Bearer ...` | -| Allowed topics | Topics the caller JWT is authorized to read | E2E env topic inputs | - -If schema tools work but broker-backed tools return auth or subscribe errors, -debug in this order: bearer freshness, broker CA trust, broker URL/server name, -and topic ACLs. - -Do not commit bearer tokens, CA files, cluster snapshots, or environment-specific -broker endpoints. - -## E2E against deployed bus - -Deployed-bus tests are opt-in because they require external broker, JWT, topic, -and CA setup. Stage 1 tests the MQTT bridge directly. Stage 2 tests the MCP -protocol path through this server's direct `/mcp` endpoint. Set -`DSX_EXCHANGE_MCP_URL` to the local process or port-forwarded Kind endpoint. - -Never commit bearer tokens, CA material, or topic names that are environment -specific or sensitive. - -Validation ladder: +| `MCP_ADDR` | `:8080` | Listener for `/mcp` and health endpoints. | +| `NATS_URL` | `tcp://nats:1883` | MQTT 3.1.1 facade on the NATS broker. | +| `MCP_MQTT_AUTH_MODE` | `jwt_passthrough` | `jwt_passthrough` or `noauth`. | +| `MQTT_USERNAME` | `oauthtoken` | MQTT username used only in `jwt_passthrough` mode. | +| `MQTT_CONNECT_TIMEOUT_S` | `5` | MQTT CONNECT timeout. | +| `MQTT_SUBSCRIBE_TIMEOUT_S` | `5` | MQTT SUBSCRIBE timeout. | +| `MQTT_TLS_CA_FILE` | unset | Optional root CA bundle for a private broker CA. | +| `MQTT_TLS_SERVER_NAME` | unset | Optional TLS server name override. | +| `MQTT_TLS_INSECURE_SKIP_VERIFY` | `false` | Local-dev only; rejected by Helm unless acknowledged. | +| `MCP_DEFAULT_MAX_MESSAGES` | `100` | Default message cap per tool call. | +| `MCP_MAX_MESSAGES` | `1000` | Hard message cap per tool call. | +| `MCP_DEFAULT_MAX_DURATION_S` | `30` | Default subscribe window. | +| `MCP_MAX_DURATION_S` | `30` | Hard subscribe window cap. | +| `MQTT_MAX_RESULT_BYTES` | `1048576` | Maximum returned topic and payload bytes. | +| `MCP_MQTT_COLLECT_MAX_CONCURRENT_PER_POD` | `100` | Per-pod admission limit for bounded MQTT collectors. | +| `MCP_FIND_TOPICS_DEFAULT_LIMIT` | `20` | Default schema search result cap. | +| `MCP_FIND_TOPICS_MAX_LIMIT` | `100` | Hard schema search result cap. | +| `LOG_FORMAT` | `json` | Structured log format. | -```sh -# Direct MQTT bridge to the deployed broker. -RUN_EXCHANGE_E2E_DEPLOYED_BUS=1 go test -mod=vendor ./internal/mqttbus -run TestDeployedBusE2E - -# MCP schema/tool path through a direct backend /mcp endpoint. -RUN_EXCHANGE_MCP_SCHEMA_E2E=1 go test -mod=vendor ./internal/server -run TestStagedMCPSchemaDescribeThroughEndpoint +Health endpoints are served on the same listener: -# MCP bounded broker-backed tool path. -RUN_EXCHANGE_MCP_E2E=1 go test -mod=vendor ./internal/server -run TestStagedMCPE2EDeployedBus +- `/healthz/live` +- `/healthz/ready` -# Curated prompt-to-tool fixture replay through the endpoint. -RUN_EXCHANGE_MCP_QUALITY_E2E=1 go test -mod=vendor ./internal/server -run TestStagedMCPQualityFixturesThroughEndpoint -``` +TLS trust is deployment configuration, not MCP tool input. For production or +deployed broker usage, mount the broker root CA and set `MQTT_TLS_CA_FILE`. +Agents provide bearer credentials through MCP request headers only. In `noauth` +local mode, do not provide a dummy token; the MQTT client intentionally sends no +username or password. -Required environment for the staged MCP tests is the setup checklist above plus -`DSX_EXCHANGE_MCP_URL`, `DSX_EXCHANGE_E2E_BEARER`, and the allowed topic inputs -used by the selected test. For direct MQTT tests, provide the broker URL, -`DSX_EXCHANGE_MQTT_AUTH_MODE`, username if non-default, CA/server-name -settings, bearer when using `jwt_passthrough`, and allowed/denied topic inputs -through the `DSX_EXCHANGE_MQTT_*` and `DSX_EXCHANGE_E2E_*` environment -variables. +## Specs -## Local LLM prompt eval +Specs are pinned at build time. `make sync-specs` copies the repository root +schema tree into `schemas/`, and `schemas/embed.go` bakes it into the binary. +The image uses the already-synced `./schemas` tree and does not fetch schemas +at runtime. -`TestLocalLLMMCPPromptEval` is an opt-in local harness that runs fixture prompts -through an OpenAI-compatible local LLM endpoint, executes emitted MCP tool calls, -logs the tool trace, and compares the model's final tool plan with -`internal/server/testdata/tool_call_expectations.json`. +Empty domain stubs are filtered out at startup so they do not surface as MCP +resources or schema tool matches. To update specs, re-run `make sync-specs` +against a refreshed schema checkout and cut a new image. -Set `DSX_EXCHANGE_MCP_URL` to a local process or port-forwarded Kind `/mcp` -endpoint. If it is unset, the test starts an in-process MCP server. +## Setup Checklist -## Maintainer validation +Before an MCP client can call broker-backed tools, verify: -Prompt quality is covered by fixture-based Go tests and the opt-in local LLM -eval. Load validation is maintainer-oriented and intentionally separate from -the public build/run path. +| Item | What the operator provides | Where this MCP expects it | +| --- | --- | --- | +| MCP endpoint | A reachable direct server `/mcp` endpoint. | `DSX_EXCHANGE_MCP_URL` for tests and tools. | +| MQTT authentication mode | `jwt_passthrough` for deployed broker auth, `noauth` for local anonymous fallback. | Helm `mqtt.authMode`, runtime `MCP_MQTT_AUTH_MODE`. | +| Broker endpoint | MQTT endpoint for the DSX Event Bus. | Helm `natsURL`, runtime `NATS_URL`. | +| Broker username | OAuth profile username for MQTT CONNECT in `jwt_passthrough` mode. | Helm `mqtt.username`, runtime `MQTT_USERNAME`. | +| Broker CA | Root/intermediate CA bundle for broker TLS. | Secret referenced by `mqtt.tls.caCertSecret.name/key`. | +| TLS server name | Broker certificate server name, if needed. | Helm `mqtt.tls.serverName`, runtime `MQTT_TLS_SERVER_NAME`. | +| Caller JWT | Fresh user/service bearer from the deployment's approved identity flow when using `jwt_passthrough`. | MCP `Authorization: Bearer ...`. | +| Allowed topics | Topics the caller JWT is authorized to read. | Broker-side authorization policy. | -Raw report bundles are local evidence and should stay under ignored `reports/`. +If schema tools work but broker-backed tools return auth or subscribe errors, +debug bearer freshness, broker CA trust, broker URL/server name, and topic ACLs. +Do not commit bearer tokens, CA files, cluster snapshots, or +environment-specific broker endpoints. -## Status +## Validation -Alpha. Populated specs load and surface as resources when synced into the -embedded bundle. The MQTT tools use paho v3 and support `jwt_passthrough` and -`noauth` broker modes. Broker-side auth-callout remains the source of truth for -JWT validation, anonymous fallback, and topic ACLs. Public v1 is stateless: -schema discovery plus finite bounded MQTT reads. +Use these repo-local checks for README-level validation: -## References +```sh +make test +make lint +make build +make image +``` -- MCP spec — https://modelcontextprotocol.io/specification/2025-06-18/ -- Go SDK — https://github.com/modelcontextprotocol/go-sdk +Deployed-broker and local LLM prompt-eval tests remain opt-in checks in the Go +test suite. They require external services or secrets and are not the baseline +README validation path. From d1cd1dd6f602d2dcd9aef505c0388c7d5109b472 Mon Sep 17 00:00:00 2001 From: Daniyal Rana Date: Wed, 24 Jun 2026 12:37:44 -0500 Subject: [PATCH 20/27] build(mcp): refresh embedded BMS schema Signed-off-by: Daniyal Rana --- .../schemas/asyncapi/bms/bms.yaml | 621 +++++++++++++++++- 1 file changed, 614 insertions(+), 7 deletions(-) diff --git a/mcp/dsx-exchange-mcp/schemas/asyncapi/bms/bms.yaml b/mcp/dsx-exchange-mcp/schemas/asyncapi/bms/bms.yaml index 199dceb..73168d3 100644 --- a/mcp/dsx-exchange-mcp/schemas/asyncapi/bms/bms.yaml +++ b/mcp/dsx-exchange-mcp/schemas/asyncapi/bms/bms.yaml @@ -116,6 +116,8 @@ info: - **System**: A System can be the BMS or an Integration to the BMS. Heartbeat points and system-to-system communication point types are typically defined inside of a System object type. System Heartbeat point types are expected to operate as follows. An integration may choose to use the Echo points or not. + Naming convention: the `BMS`/`Integration` suffix indicates the publisher of the point. + All four heartbeat pointTypes require `objectName` and `objectId` in metadata to identify which System the heartbeat belongs to. By convention, an integration's `objectId` matches the same string used as its `integration` metadata field on its other points (so MEPAI1's System object has `objectId: "MEPAI1"`). - **HeartbeatTimestampBms** — Publisher: BMS. The BMS publishes its own timestamp every 10 seconds. One instance globally. `objectId` identifies the BMS (e.g., `"BMS"`). `integration` field: not used. @@ -126,8 +128,6 @@ info: - **HeartbeatEchoIntegration** — Publisher: Integration. Each integration reads the BMS's `HeartbeatTimestampBms` value and re-publishes it on this point type, allowing the BMS to confirm round-trip with that integration. One per connected integration. `objectId` identifies the BMS being echoed (e.g., `"BMS"`). `integration` field: required (drives topic). - Naming convention: the `Bms`/`Integration` suffix indicates the publisher of the point. The party whose timestamp is being echoed is encoded in `objectId`, not in the point name. - - **Rack**: A Rack is a special object type and has specific point types that can only be used with a Rack object type. Many integrations and MQTT Clients will only ingest data from the Rack object type. Other integrations will consider Rack data as the most important or interesting data in the AI Factory. Mechanical and Electrical design (and therefore BMS Data) at the rack is also more standardized than mechanical and electrical systems as you move out of the white space and to the gray space of the AI Factory. For these reasons, the point types associated with a Rack object type are generally more specific than any other object type, making ingesting and understanding rack data more straight-forward. - **PowerMeter**: A PowerMeter is a special object type and has specific point types that can only be used with a PowerMeter object type. PowerMeter point types contain the metadata needed to understand electrical power path. This data is used by integrations / MQTT Clients for power management strategies. @@ -280,10 +280,12 @@ channels: address: 'BMS/v1/{integration}/Value/Rack/{pointType}/{tagPath}' description: | Values published by integrations for Rack control points. + Subscribe to `BMS/v1/PUB/Metadata/Rack/#` and publish only when that metadata payload includes an `integration` field whose value matches the publishing integration identifier. + That matching metadata payload indicates the integration can publish values to this topic. **MQTT wildcard examples** - - All integration rack values: `BMS/v1/+/Value/Rack/#` + - All integration Rack values: `BMS/v1/+/Value/Rack/#` parameters: integration: description: Integration identifier. @@ -373,6 +375,29 @@ channels: phaseCurrent: $ref: '#/components/messages/PowerMeterPhaseCurrentMsg' + powerMeterIntegrationValue: + address: 'BMS/v1/{integration}/Value/PowerMeter/{pointType}/{tagPath}' + description: | + Values published by integrations for PowerMeter control points. + Subscribe to `BMS/v1/PUB/Metadata/PowerMeter/#` and publish only when that metadata payload includes an `integration` field whose value matches the publishing integration identifier. + That matching metadata payload indicates the integration can publish values to this topic. + + **MQTT wildcard examples** + + - All integration PowerMeter values: `BMS/v1/+/Value/PowerMeter/#` + parameters: + integration: + description: Integration identifier. + pointType: + enum: + - GenericPoint + description: Integration-published PowerMeter point type. + tagPath: + description: Must match the tagPath from the corresponding BMS metadata exactly. + messages: + valueMessage: + $ref: '#/components/messages/ValueMessage' + # --------------------------------------------------------------------------- # BESS # --------------------------------------------------------------------------- @@ -424,6 +449,29 @@ channels: available: $ref: '#/components/messages/BESSAvailableMsg' + bessIntegrationValue: + address: 'BMS/v1/{integration}/Value/BESS/{pointType}/{tagPath}' + description: | + Values published by integrations for BESS control points. + Subscribe to `BMS/v1/PUB/Metadata/BESS/#` and publish only when that metadata payload includes an `integration` field whose value matches the publishing integration identifier. + That matching metadata payload indicates the integration can publish values to this topic. + + **MQTT wildcard examples** + + - All integration BESS values: `BMS/v1/+/Value/BESS/#` + parameters: + integration: + description: Integration identifier. + pointType: + enum: + - GenericPoint + description: Integration-published BESS point type. + tagPath: + description: Must match the tagPath from the corresponding BMS metadata exactly. + messages: + valueMessage: + $ref: '#/components/messages/ValueMessage' + # --------------------------------------------------------------------------- # UPS # --------------------------------------------------------------------------- @@ -475,6 +523,29 @@ channels: available: $ref: '#/components/messages/UPSAvailableMsg' + upsIntegrationValue: + address: 'BMS/v1/{integration}/Value/UPS/{pointType}/{tagPath}' + description: | + Values published by integrations for UPS control points. + Subscribe to `BMS/v1/PUB/Metadata/UPS/#` and publish only when that metadata payload includes an `integration` field whose value matches the publishing integration identifier. + That matching metadata payload indicates the integration can publish values to this topic. + + **MQTT wildcard examples** + + - All integration UPS values: `BMS/v1/+/Value/UPS/#` + parameters: + integration: + description: Integration identifier. + pointType: + enum: + - GenericPoint + description: Integration-published UPS point type. + tagPath: + description: Must match the tagPath from the corresponding BMS metadata exactly. + messages: + valueMessage: + $ref: '#/components/messages/ValueMessage' + # --------------------------------------------------------------------------- # ATS # --------------------------------------------------------------------------- @@ -526,6 +597,29 @@ channels: available: $ref: '#/components/messages/ATSAvailableMsg' + atsIntegrationValue: + address: 'BMS/v1/{integration}/Value/ATS/{pointType}/{tagPath}' + description: | + Values published by integrations for ATS control points. + Subscribe to `BMS/v1/PUB/Metadata/ATS/#` and publish only when that metadata payload includes an `integration` field whose value matches the publishing integration identifier. + That matching metadata payload indicates the integration can publish values to this topic. + + **MQTT wildcard examples** + + - All integration ATS values: `BMS/v1/+/Value/ATS/#` + parameters: + integration: + description: Integration identifier. + pointType: + enum: + - GenericPoint + description: Integration-published ATS point type. + tagPath: + description: Must match the tagPath from the corresponding BMS metadata exactly. + messages: + valueMessage: + $ref: '#/components/messages/ValueMessage' + # --------------------------------------------------------------------------- # Generator # --------------------------------------------------------------------------- @@ -577,6 +671,29 @@ channels: available: $ref: '#/components/messages/GeneratorAvailableMsg' + generatorIntegrationValue: + address: 'BMS/v1/{integration}/Value/Generator/{pointType}/{tagPath}' + description: | + Values published by integrations for Generator control points. + Subscribe to `BMS/v1/PUB/Metadata/Generator/#` and publish only when that metadata payload includes an `integration` field whose value matches the publishing integration identifier. + That matching metadata payload indicates the integration can publish values to this topic. + + **MQTT wildcard examples** + + - All integration Generator values: `BMS/v1/+/Value/Generator/#` + parameters: + integration: + description: Integration identifier. + pointType: + enum: + - GenericPoint + description: Integration-published Generator point type. + tagPath: + description: Must match the tagPath from the corresponding BMS metadata exactly. + messages: + valueMessage: + $ref: '#/components/messages/ValueMessage' + # --------------------------------------------------------------------------- # Shunt # --------------------------------------------------------------------------- @@ -627,6 +744,29 @@ channels: available: $ref: '#/components/messages/ShuntAvailableMsg' + shuntIntegrationValue: + address: 'BMS/v1/{integration}/Value/Shunt/{pointType}/{tagPath}' + description: | + Values published by integrations for Shunt control points. + Subscribe to `BMS/v1/PUB/Metadata/Shunt/#` and publish only when that metadata payload includes an `integration` field whose value matches the publishing integration identifier. + That matching metadata payload indicates the integration can publish values to this topic. + + **MQTT wildcard examples** + + - All integration Shunt values: `BMS/v1/+/Value/Shunt/#` + parameters: + integration: + description: Integration identifier. + pointType: + enum: + - GenericPoint + description: Integration-published Shunt point type. + tagPath: + description: Must match the tagPath from the corresponding BMS metadata exactly. + messages: + valueMessage: + $ref: '#/components/messages/ValueMessage' + # --------------------------------------------------------------------------- # Breaker # --------------------------------------------------------------------------- @@ -677,6 +817,29 @@ channels: available: $ref: '#/components/messages/BreakerAvailableMsg' + breakerIntegrationValue: + address: 'BMS/v1/{integration}/Value/Breaker/{pointType}/{tagPath}' + description: | + Values published by integrations for Breaker control points. + Subscribe to `BMS/v1/PUB/Metadata/Breaker/#` and publish only when that metadata payload includes an `integration` field whose value matches the publishing integration identifier. + That matching metadata payload indicates the integration can publish values to this topic. + + **MQTT wildcard examples** + + - All integration Breaker values: `BMS/v1/+/Value/Breaker/#` + parameters: + integration: + description: Integration identifier. + pointType: + enum: + - GenericPoint + description: Integration-published Breaker point type. + tagPath: + description: Must match the tagPath from the corresponding BMS metadata exactly. + messages: + valueMessage: + $ref: '#/components/messages/ValueMessage' + # --------------------------------------------------------------------------- # CDU # --------------------------------------------------------------------------- @@ -791,7 +954,8 @@ channels: address: 'BMS/v1/{integration}/Value/CDU/{pointType}/{tagPath}' description: | Values published by integrations for CDU control points. - Subscribe to `cduMetadata` first and read `integration` for the exact topic. + Subscribe to `BMS/v1/PUB/Metadata/CDU/#` and publish only when that metadata payload includes an `integration` field whose value matches the publishing integration identifier. + That matching metadata payload indicates the integration can publish values to this topic. **MQTT wildcard examples** @@ -802,6 +966,7 @@ channels: pointType: enum: - LiquidTemperatureSpRequest + - GenericPoint description: Integration-published CDU point type. tagPath: description: Must match the tagPath from the corresponding BMS metadata exactly. @@ -915,6 +1080,29 @@ channels: genericPoint: $ref: '#/components/messages/GenericEquipmentPointMsg' + coolingTowerIntegrationValue: + address: 'BMS/v1/{integration}/Value/CoolingTower/{pointType}/{tagPath}' + description: | + Values published by integrations for CoolingTower control points. + Subscribe to `BMS/v1/PUB/Metadata/CoolingTower/#` and publish only when that metadata payload includes an `integration` field whose value matches the publishing integration identifier. + That matching metadata payload indicates the integration can publish values to this topic. + + **MQTT wildcard examples** + + - All integration CoolingTower values: `BMS/v1/+/Value/CoolingTower/#` + parameters: + integration: + description: Integration identifier. + pointType: + enum: + - GenericPoint + description: Integration-published CoolingTower point type. + tagPath: + description: Must match the tagPath from the corresponding BMS metadata exactly. + messages: + valueMessage: + $ref: '#/components/messages/ValueMessage' + # --------------------------------------------------------------------------- # HX # --------------------------------------------------------------------------- @@ -1021,6 +1209,29 @@ channels: genericPoint: $ref: '#/components/messages/GenericEquipmentPointMsg' + hxIntegrationValue: + address: 'BMS/v1/{integration}/Value/HX/{pointType}/{tagPath}' + description: | + Values published by integrations for HX control points. + Subscribe to `BMS/v1/PUB/Metadata/HX/#` and publish only when that metadata payload includes an `integration` field whose value matches the publishing integration identifier. + That matching metadata payload indicates the integration can publish values to this topic. + + **MQTT wildcard examples** + + - All integration HX values: `BMS/v1/+/Value/HX/#` + parameters: + integration: + description: Integration identifier. + pointType: + enum: + - GenericPoint + description: Integration-published HX point type. + tagPath: + description: Must match the tagPath from the corresponding BMS metadata exactly. + messages: + valueMessage: + $ref: '#/components/messages/ValueMessage' + # --------------------------------------------------------------------------- # CRAH # --------------------------------------------------------------------------- @@ -1127,6 +1338,29 @@ channels: genericPoint: $ref: '#/components/messages/GenericEquipmentPointMsg' + crahIntegrationValue: + address: 'BMS/v1/{integration}/Value/CRAH/{pointType}/{tagPath}' + description: | + Values published by integrations for CRAH control points. + Subscribe to `BMS/v1/PUB/Metadata/CRAH/#` and publish only when that metadata payload includes an `integration` field whose value matches the publishing integration identifier. + That matching metadata payload indicates the integration can publish values to this topic. + + **MQTT wildcard examples** + + - All integration CRAH values: `BMS/v1/+/Value/CRAH/#` + parameters: + integration: + description: Integration identifier. + pointType: + enum: + - GenericPoint + description: Integration-published CRAH point type. + tagPath: + description: Must match the tagPath from the corresponding BMS metadata exactly. + messages: + valueMessage: + $ref: '#/components/messages/ValueMessage' + # --------------------------------------------------------------------------- # CRAC # --------------------------------------------------------------------------- @@ -1233,6 +1467,29 @@ channels: genericPoint: $ref: '#/components/messages/GenericEquipmentPointMsg' + cracIntegrationValue: + address: 'BMS/v1/{integration}/Value/CRAC/{pointType}/{tagPath}' + description: | + Values published by integrations for CRAC control points. + Subscribe to `BMS/v1/PUB/Metadata/CRAC/#` and publish only when that metadata payload includes an `integration` field whose value matches the publishing integration identifier. + That matching metadata payload indicates the integration can publish values to this topic. + + **MQTT wildcard examples** + + - All integration CRAC values: `BMS/v1/+/Value/CRAC/#` + parameters: + integration: + description: Integration identifier. + pointType: + enum: + - GenericPoint + description: Integration-published CRAC point type. + tagPath: + description: Must match the tagPath from the corresponding BMS metadata exactly. + messages: + valueMessage: + $ref: '#/components/messages/ValueMessage' + # --------------------------------------------------------------------------- # AHU # --------------------------------------------------------------------------- @@ -1339,6 +1596,29 @@ channels: genericPoint: $ref: '#/components/messages/GenericEquipmentPointMsg' + ahuIntegrationValue: + address: 'BMS/v1/{integration}/Value/AHU/{pointType}/{tagPath}' + description: | + Values published by integrations for AHU control points. + Subscribe to `BMS/v1/PUB/Metadata/AHU/#` and publish only when that metadata payload includes an `integration` field whose value matches the publishing integration identifier. + That matching metadata payload indicates the integration can publish values to this topic. + + **MQTT wildcard examples** + + - All integration AHU values: `BMS/v1/+/Value/AHU/#` + parameters: + integration: + description: Integration identifier. + pointType: + enum: + - GenericPoint + description: Integration-published AHU point type. + tagPath: + description: Must match the tagPath from the corresponding BMS metadata exactly. + messages: + valueMessage: + $ref: '#/components/messages/ValueMessage' + # --------------------------------------------------------------------------- # Chiller # --------------------------------------------------------------------------- @@ -1445,6 +1725,29 @@ channels: genericPoint: $ref: '#/components/messages/GenericEquipmentPointMsg' + chillerIntegrationValue: + address: 'BMS/v1/{integration}/Value/Chiller/{pointType}/{tagPath}' + description: | + Values published by integrations for Chiller control points. + Subscribe to `BMS/v1/PUB/Metadata/Chiller/#` and publish only when that metadata payload includes an `integration` field whose value matches the publishing integration identifier. + That matching metadata payload indicates the integration can publish values to this topic. + + **MQTT wildcard examples** + + - All integration Chiller values: `BMS/v1/+/Value/Chiller/#` + parameters: + integration: + description: Integration identifier. + pointType: + enum: + - GenericPoint + description: Integration-published Chiller point type. + tagPath: + description: Must match the tagPath from the corresponding BMS metadata exactly. + messages: + valueMessage: + $ref: '#/components/messages/ValueMessage' + # --------------------------------------------------------------------------- # Valve # --------------------------------------------------------------------------- @@ -1495,6 +1798,29 @@ channels: genericPoint: $ref: '#/components/messages/GenericEquipmentPointMsg' + valveIntegrationValue: + address: 'BMS/v1/{integration}/Value/Valve/{pointType}/{tagPath}' + description: | + Values published by integrations for Valve control points. + Subscribe to `BMS/v1/PUB/Metadata/Valve/#` and publish only when that metadata payload includes an `integration` field whose value matches the publishing integration identifier. + That matching metadata payload indicates the integration can publish values to this topic. + + **MQTT wildcard examples** + + - All integration Valve values: `BMS/v1/+/Value/Valve/#` + parameters: + integration: + description: Integration identifier. + pointType: + enum: + - GenericPoint + description: Integration-published Valve point type. + tagPath: + description: Must match the tagPath from the corresponding BMS metadata exactly. + messages: + valueMessage: + $ref: '#/components/messages/ValueMessage' + # --------------------------------------------------------------------------- # Pump # --------------------------------------------------------------------------- @@ -1545,6 +1871,29 @@ channels: genericPoint: $ref: '#/components/messages/GenericEquipmentPointMsg' + pumpIntegrationValue: + address: 'BMS/v1/{integration}/Value/Pump/{pointType}/{tagPath}' + description: | + Values published by integrations for Pump control points. + Subscribe to `BMS/v1/PUB/Metadata/Pump/#` and publish only when that metadata payload includes an `integration` field whose value matches the publishing integration identifier. + That matching metadata payload indicates the integration can publish values to this topic. + + **MQTT wildcard examples** + + - All integration Pump values: `BMS/v1/+/Value/Pump/#` + parameters: + integration: + description: Integration identifier. + pointType: + enum: + - GenericPoint + description: Integration-published Pump point type. + tagPath: + description: Must match the tagPath from the corresponding BMS metadata exactly. + messages: + valueMessage: + $ref: '#/components/messages/ValueMessage' + # --------------------------------------------------------------------------- # Fan # --------------------------------------------------------------------------- @@ -1595,6 +1944,29 @@ channels: genericPoint: $ref: '#/components/messages/GenericEquipmentPointMsg' + fanIntegrationValue: + address: 'BMS/v1/{integration}/Value/Fan/{pointType}/{tagPath}' + description: | + Values published by integrations for Fan control points. + Subscribe to `BMS/v1/PUB/Metadata/Fan/#` and publish only when that metadata payload includes an `integration` field whose value matches the publishing integration identifier. + That matching metadata payload indicates the integration can publish values to this topic. + + **MQTT wildcard examples** + + - All integration Fan values: `BMS/v1/+/Value/Fan/#` + parameters: + integration: + description: Integration identifier. + pointType: + enum: + - GenericPoint + description: Integration-published Fan point type. + tagPath: + description: Must match the tagPath from the corresponding BMS metadata exactly. + messages: + valueMessage: + $ref: '#/components/messages/ValueMessage' + # --------------------------------------------------------------------------- # Damper # --------------------------------------------------------------------------- @@ -1645,6 +2017,29 @@ channels: genericPoint: $ref: '#/components/messages/GenericEquipmentPointMsg' + damperIntegrationValue: + address: 'BMS/v1/{integration}/Value/Damper/{pointType}/{tagPath}' + description: | + Values published by integrations for Damper control points. + Subscribe to `BMS/v1/PUB/Metadata/Damper/#` and publish only when that metadata payload includes an `integration` field whose value matches the publishing integration identifier. + That matching metadata payload indicates the integration can publish values to this topic. + + **MQTT wildcard examples** + + - All integration Damper values: `BMS/v1/+/Value/Damper/#` + parameters: + integration: + description: Integration identifier. + pointType: + enum: + - GenericPoint + description: Integration-published Damper point type. + tagPath: + description: Must match the tagPath from the corresponding BMS metadata exactly. + messages: + valueMessage: + $ref: '#/components/messages/ValueMessage' + # --------------------------------------------------------------------------- # Sensor # --------------------------------------------------------------------------- @@ -1736,6 +2131,29 @@ channels: $ref: '#/components/messages/SensorSoundMsg' + sensorIntegrationValue: + address: 'BMS/v1/{integration}/Value/Sensor/{pointType}/{tagPath}' + description: | + Values published by integrations for Sensor control points. + Subscribe to `BMS/v1/PUB/Metadata/Sensor/#` and publish only when that metadata payload includes an `integration` field whose value matches the publishing integration identifier. + That matching metadata payload indicates the integration can publish values to this topic. + + **MQTT wildcard examples** + + - All integration Sensor values: `BMS/v1/+/Value/Sensor/#` + parameters: + integration: + description: Integration identifier. + pointType: + enum: + - GenericPoint + description: Integration-published Sensor point type. + tagPath: + description: Must match the tagPath from the corresponding BMS metadata exactly. + messages: + valueMessage: + $ref: '#/components/messages/ValueMessage' + # --------------------------------------------------------------------------- # Tank # --------------------------------------------------------------------------- @@ -1844,6 +2262,29 @@ channels: genericPoint: $ref: '#/components/messages/GenericEquipmentPointMsg' + tankIntegrationValue: + address: 'BMS/v1/{integration}/Value/Tank/{pointType}/{tagPath}' + description: | + Values published by integrations for Tank control points. + Subscribe to `BMS/v1/PUB/Metadata/Tank/#` and publish only when that metadata payload includes an `integration` field whose value matches the publishing integration identifier. + That matching metadata payload indicates the integration can publish values to this topic. + + **MQTT wildcard examples** + + - All integration Tank values: `BMS/v1/+/Value/Tank/#` + parameters: + integration: + description: Integration identifier. + pointType: + enum: + - GenericPoint + description: Integration-published Tank point type. + tagPath: + description: Must match the tagPath from the corresponding BMS metadata exactly. + messages: + valueMessage: + $ref: '#/components/messages/ValueMessage' + # --------------------------------------------------------------------------- # GenericObject # --------------------------------------------------------------------------- @@ -1963,7 +2404,8 @@ channels: address: 'BMS/v1/{integration}/Value/GenericObject/{pointType}/{tagPath}' description: | Values published by integrations for GenericObject control points. - Subscribe to `genericObjectMetadata` first and read `integration` for the exact topic. + Subscribe to `BMS/v1/PUB/Metadata/GenericObject/#` and publish only when that metadata payload includes an `integration` field whose value matches the publishing integration identifier. + That matching metadata payload indicates the integration can publish values to this topic. **MQTT wildcard examples** @@ -1974,6 +2416,7 @@ channels: pointType: enum: - LiquidTemperatureSpRequest + - GenericPoint description: Integration-published GenericObject point type. tagPath: description: Must match the tagPath from the corresponding BMS metadata exactly. @@ -2048,11 +2491,13 @@ channels: systemIntegrationValue: address: 'BMS/v1/{integration}/Value/System/{pointType}/{tagPath}' description: | - Values published by integrations for Heartbeat points. + Values published by integrations for System points (Integration heartbeats, Status, Available, and GenericPoint). + Subscribe to `BMS/v1/PUB/Metadata/System/#` and publish only when that metadata payload includes an `integration` field whose value matches the publishing integration identifier. + That matching metadata payload indicates the integration can publish values to this topic. **MQTT wildcard examples** - - All integration heartbeat values: `BMS/v1/+/Value/System/#` + - All integration System values: `BMS/v1/+/Value/System/#` parameters: integration: description: Integration identifier. @@ -2060,6 +2505,9 @@ channels: enum: - HeartbeatTimestampIntegration - HeartbeatEchoIntegration + - Status + - Available + - GenericPoint description: Integration-published System point type. tagPath: description: Vendor-defined hierarchical tag path. @@ -2136,6 +2584,14 @@ operations: - $ref: '#/channels/powerMeterMetadata/messages/genericPoint' description: Subscribe to BMS-published metadata for all PowerMeter points. + publishPowerMeterIntegrationValue: + action: send + channel: + $ref: '#/channels/powerMeterIntegrationValue' + messages: + - $ref: '#/channels/powerMeterIntegrationValue/messages/valueMessage' + description: Publish integration values for PowerMeter control points. + receiveBESSValue: action: receive channel: @@ -2154,6 +2610,14 @@ operations: - $ref: '#/channels/bessMetadata/messages/genericPoint' description: Subscribe to BMS-published metadata for BESS points. + publishBESSIntegrationValue: + action: send + channel: + $ref: '#/channels/bessIntegrationValue' + messages: + - $ref: '#/channels/bessIntegrationValue/messages/valueMessage' + description: Publish integration values for BESS control points. + receiveUPSValue: action: receive channel: @@ -2172,6 +2636,14 @@ operations: - $ref: '#/channels/upsMetadata/messages/genericPoint' description: Subscribe to BMS-published metadata for UPS points. + publishUPSIntegrationValue: + action: send + channel: + $ref: '#/channels/upsIntegrationValue' + messages: + - $ref: '#/channels/upsIntegrationValue/messages/valueMessage' + description: Publish integration values for UPS control points. + receiveATSValue: action: receive channel: @@ -2190,6 +2662,14 @@ operations: - $ref: '#/channels/atsMetadata/messages/genericPoint' description: Subscribe to BMS-published metadata for ATS points. + publishATSIntegrationValue: + action: send + channel: + $ref: '#/channels/atsIntegrationValue' + messages: + - $ref: '#/channels/atsIntegrationValue/messages/valueMessage' + description: Publish integration values for ATS control points. + receiveGeneratorValue: action: receive channel: @@ -2208,6 +2688,14 @@ operations: - $ref: '#/channels/generatorMetadata/messages/genericPoint' description: Subscribe to BMS-published metadata for Generator points. + publishGeneratorIntegrationValue: + action: send + channel: + $ref: '#/channels/generatorIntegrationValue' + messages: + - $ref: '#/channels/generatorIntegrationValue/messages/valueMessage' + description: Publish integration values for Generator control points. + receiveShuntValue: action: receive channel: @@ -2226,6 +2714,14 @@ operations: - $ref: '#/channels/shuntMetadata/messages/genericPoint' description: Subscribe to BMS-published metadata for Shunt points. + publishShuntIntegrationValue: + action: send + channel: + $ref: '#/channels/shuntIntegrationValue' + messages: + - $ref: '#/channels/shuntIntegrationValue/messages/valueMessage' + description: Publish integration values for Shunt control points. + receiveBreakerValue: action: receive channel: @@ -2244,6 +2740,14 @@ operations: - $ref: '#/channels/breakerMetadata/messages/genericPoint' description: Subscribe to BMS-published metadata for Breaker points. + publishBreakerIntegrationValue: + action: send + channel: + $ref: '#/channels/breakerIntegrationValue' + messages: + - $ref: '#/channels/breakerIntegrationValue/messages/valueMessage' + description: Publish integration values for Breaker control points. + receiveCDUValue: action: receive channel: @@ -2317,6 +2821,14 @@ operations: - $ref: '#/channels/coolingTowerMetadata/messages/genericPoint' description: Subscribe to BMS-published metadata for CoolingTower points. + publishCoolingTowerIntegrationValue: + action: send + channel: + $ref: '#/channels/coolingTowerIntegrationValue' + messages: + - $ref: '#/channels/coolingTowerIntegrationValue/messages/valueMessage' + description: Publish integration values for CoolingTower control points. + receiveHXValue: action: receive channel: @@ -2349,6 +2861,14 @@ operations: - $ref: '#/channels/hxMetadata/messages/genericPoint' description: Subscribe to BMS-published metadata for HX points. + publishHXIntegrationValue: + action: send + channel: + $ref: '#/channels/hxIntegrationValue' + messages: + - $ref: '#/channels/hxIntegrationValue/messages/valueMessage' + description: Publish integration values for HX control points. + receiveCRAHValue: action: receive channel: @@ -2381,6 +2901,14 @@ operations: - $ref: '#/channels/crahMetadata/messages/genericPoint' description: Subscribe to BMS-published metadata for CRAH points. + publishCRAHIntegrationValue: + action: send + channel: + $ref: '#/channels/crahIntegrationValue' + messages: + - $ref: '#/channels/crahIntegrationValue/messages/valueMessage' + description: Publish integration values for CRAH control points. + receiveCRACValue: action: receive channel: @@ -2413,6 +2941,14 @@ operations: - $ref: '#/channels/cracMetadata/messages/genericPoint' description: Subscribe to BMS-published metadata for CRAC points. + publishCRACIntegrationValue: + action: send + channel: + $ref: '#/channels/cracIntegrationValue' + messages: + - $ref: '#/channels/cracIntegrationValue/messages/valueMessage' + description: Publish integration values for CRAC control points. + receiveAHUValue: action: receive channel: @@ -2445,6 +2981,14 @@ operations: - $ref: '#/channels/ahuMetadata/messages/genericPoint' description: Subscribe to BMS-published metadata for AHU points. + publishAHUIntegrationValue: + action: send + channel: + $ref: '#/channels/ahuIntegrationValue' + messages: + - $ref: '#/channels/ahuIntegrationValue/messages/valueMessage' + description: Publish integration values for AHU control points. + receiveChillerValue: action: receive channel: @@ -2477,6 +3021,14 @@ operations: - $ref: '#/channels/chillerMetadata/messages/genericPoint' description: Subscribe to BMS-published metadata for Chiller points. + publishChillerIntegrationValue: + action: send + channel: + $ref: '#/channels/chillerIntegrationValue' + messages: + - $ref: '#/channels/chillerIntegrationValue/messages/valueMessage' + description: Publish integration values for Chiller control points. + receiveValveValue: action: receive channel: @@ -2495,6 +3047,14 @@ operations: - $ref: '#/channels/valveMetadata/messages/genericPoint' description: Subscribe to BMS-published metadata for Valve points. + publishValveIntegrationValue: + action: send + channel: + $ref: '#/channels/valveIntegrationValue' + messages: + - $ref: '#/channels/valveIntegrationValue/messages/valueMessage' + description: Publish integration values for Valve control points. + receivePumpValue: action: receive channel: @@ -2513,6 +3073,14 @@ operations: - $ref: '#/channels/pumpMetadata/messages/genericPoint' description: Subscribe to BMS-published metadata for Pump points. + publishPumpIntegrationValue: + action: send + channel: + $ref: '#/channels/pumpIntegrationValue' + messages: + - $ref: '#/channels/pumpIntegrationValue/messages/valueMessage' + description: Publish integration values for Pump control points. + receiveFanValue: action: receive channel: @@ -2531,6 +3099,14 @@ operations: - $ref: '#/channels/fanMetadata/messages/genericPoint' description: Subscribe to BMS-published metadata for Fan points. + publishFanIntegrationValue: + action: send + channel: + $ref: '#/channels/fanIntegrationValue' + messages: + - $ref: '#/channels/fanIntegrationValue/messages/valueMessage' + description: Publish integration values for Fan control points. + receiveDamperValue: action: receive channel: @@ -2549,6 +3125,14 @@ operations: - $ref: '#/channels/damperMetadata/messages/genericPoint' description: Subscribe to BMS-published metadata for Damper points. + publishDamperIntegrationValue: + action: send + channel: + $ref: '#/channels/damperIntegrationValue' + messages: + - $ref: '#/channels/damperIntegrationValue/messages/valueMessage' + description: Publish integration values for Damper control points. + receiveSensorValue: action: receive channel: @@ -2578,6 +3162,14 @@ operations: description: Subscribe to BMS-published metadata for Sensor points. + publishSensorIntegrationValue: + action: send + channel: + $ref: '#/channels/sensorIntegrationValue' + messages: + - $ref: '#/channels/sensorIntegrationValue/messages/valueMessage' + description: Publish integration values for Sensor control points. + receiveTankValue: action: receive channel: @@ -2610,6 +3202,14 @@ operations: - $ref: '#/channels/tankMetadata/messages/genericPoint' description: Subscribe to BMS-published metadata for Tank points. + publishTankIntegrationValue: + action: send + channel: + $ref: '#/channels/tankIntegrationValue' + messages: + - $ref: '#/channels/tankIntegrationValue/messages/valueMessage' + description: Publish integration values for Tank control points. + receiveGenericObjectValue: action: receive channel: @@ -2669,6 +3269,9 @@ operations: - $ref: '#/channels/systemMetadata/messages/heartbeatEchoBms' - $ref: '#/channels/systemMetadata/messages/heartbeatTimestampIntegration' - $ref: '#/channels/systemMetadata/messages/heartbeatEchoIntegration' + - $ref: '#/channels/systemMetadata/messages/status' + - $ref: '#/channels/systemMetadata/messages/available' + - $ref: '#/channels/systemMetadata/messages/genericPoint' description: Subscribe to System metadata for all System point types. publishSystemIntegrationValue: @@ -4436,6 +5039,7 @@ components: description: Valid pointType values for Integration-published generic equipment points. enum: - LiquidTemperatureSpRequest + - GenericPoint EquipmentObjectType: type: string @@ -4478,6 +5082,9 @@ components: enum: - HeartbeatTimestampIntegration - HeartbeatEchoIntegration + - Status + - Available + - GenericPoint # ========================================================================= From 10119085352e68bbb1f49adb353f6263de407a5e Mon Sep 17 00:00:00 2001 From: Daniyal Rana Date: Wed, 24 Jun 2026 13:23:45 -0500 Subject: [PATCH 21/27] chore(mcp): keep load testing local-only Signed-off-by: Daniyal Rana --- mcp/dsx-exchange-mcp/Dockerfile.load | 19 - .../cmd/dsx-exchange-mcp-load/main.go | 1935 ----------------- .../cmd/dsx-exchange-mcp-load/main_test.go | 408 ---- ...gatewaybackend-stateful-routing-patch.yaml | 9 - .../loadtest/gateway-high-rate-values.yaml | 10 - .../gateway-ratelimit-1000-configmap.yaml | 16 - .../gateway-ratelimit-5000-configmap.yaml | 16 - .../loadtest/run-kind-load-experiment.sh | 390 ---- 8 files changed, 2803 deletions(-) delete mode 100644 mcp/dsx-exchange-mcp/Dockerfile.load delete mode 100644 mcp/dsx-exchange-mcp/cmd/dsx-exchange-mcp-load/main.go delete mode 100644 mcp/dsx-exchange-mcp/cmd/dsx-exchange-mcp-load/main_test.go delete mode 100644 mcp/dsx-exchange-mcp/deploy/loadtest/agentgatewaybackend-stateful-routing-patch.yaml delete mode 100644 mcp/dsx-exchange-mcp/deploy/loadtest/gateway-high-rate-values.yaml delete mode 100644 mcp/dsx-exchange-mcp/deploy/loadtest/gateway-ratelimit-1000-configmap.yaml delete mode 100644 mcp/dsx-exchange-mcp/deploy/loadtest/gateway-ratelimit-5000-configmap.yaml delete mode 100755 mcp/dsx-exchange-mcp/deploy/loadtest/run-kind-load-experiment.sh diff --git a/mcp/dsx-exchange-mcp/Dockerfile.load b/mcp/dsx-exchange-mcp/Dockerfile.load deleted file mode 100644 index 2fdb0d5..0000000 --- a/mcp/dsx-exchange-mcp/Dockerfile.load +++ /dev/null @@ -1,19 +0,0 @@ -# Copyright 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. -# SPDX-License-Identifier: Apache-2.0 - -ARG BUILDER_IMG=golang -ARG BUILDER_TAG=1.25.5 -ARG FINAL_IMG=gcr.io/distroless/static-debian12 -ARG FINAL_TAG=nonroot - -FROM ${BUILDER_IMG}:${BUILDER_TAG} AS build -WORKDIR /src -COPY . . -RUN --mount=type=cache,target=/go/pkg/mod \ - --mount=type=cache,target=/root/.cache/go-build \ - CGO_ENABLED=0 GOOS=linux go build -trimpath -ldflags="-s -w" -mod=vendor -o /out/dsx-exchange-mcp-load ./cmd/dsx-exchange-mcp-load - -FROM ${FINAL_IMG}:${FINAL_TAG} -COPY --from=build /out/dsx-exchange-mcp-load /dsx-exchange-mcp-load -USER nonroot:nonroot -ENTRYPOINT ["/dsx-exchange-mcp-load"] diff --git a/mcp/dsx-exchange-mcp/cmd/dsx-exchange-mcp-load/main.go b/mcp/dsx-exchange-mcp/cmd/dsx-exchange-mcp-load/main.go deleted file mode 100644 index 42d31cb..0000000 --- a/mcp/dsx-exchange-mcp/cmd/dsx-exchange-mcp-load/main.go +++ /dev/null @@ -1,1935 +0,0 @@ -// Copyright 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. -// SPDX-License-Identifier: Apache-2.0 - -package main - -import ( - "bytes" - "context" - "crypto/sha256" - "encoding/base64" - "encoding/csv" - "encoding/json" - "errors" - "flag" - "fmt" - "io" - "math/rand" - "net/http" - "os" - "path/filepath" - "sort" - "strconv" - "strings" - "sync" - "time" -) - -const ( - toolSubscribe = "dsx_exchange_subscribe" - toolReadRetained = "dsx_exchange_read_retained" - toolDescribeTopic = "dsx_exchange_describe_topic" - toolFindTopics = "dsx_exchange_find_topics" - toolStartSubscription = "dsx_exchange_start_subscription" - toolReadSubscription = "dsx_exchange_read_subscription" - toolStatusSubscription = "dsx_exchange_subscription_status" - toolStopSubscription = "dsx_exchange_stop_subscription" - - maxErrorSamples = 5 -) - -type config struct { - endpoint string - bearer string - experiment string - experimentDetail string - scenario string - sessions int - sessionSweep string - backendReplicas int - stickySessionCheck string - duration time.Duration - startupRamp time.Duration - pollInterval time.Duration - rateLimit int - gatewayRateLimit int - manifestName string - backendImageID string - loadImageID string - topic string - retainedTopic string - deniedTopic string - subscribeDuration int - maxMessages int - maxBytes int - watchTTL int - httpTimeout time.Duration - backendConnectS int - backendSubscribeS int - backendCollectMax int - backendWatchMax int - reportDir string -} - -type mcpClient struct { - endpoint string - bearer string - httpc *http.Client - nextID int - limiter *rateLimiter -} - -type rpcResponse struct { - Result json.RawMessage `json:"result"` - Error *rpcError `json:"error"` -} - -type rpcError struct { - Code int `json:"code"` - Message string `json:"message"` -} - -type toolCallResult struct { - IsError bool `json:"isError"` - Content []struct { - Type string `json:"type"` - Text string `json:"text"` - } `json:"content"` -} - -type runReport struct { - StartedAt time.Time `json:"started_at"` - EndedAt time.Time `json:"ended_at"` - DurationSeconds float64 `json:"duration_seconds"` - ThroughputRPS float64 `json:"throughput_requests_per_second"` - SuccessRate float64 `json:"success_rate_percent"` - Endpoint string `json:"endpoint"` - Experiment string `json:"experiment,omitempty"` - ExperimentDetail string `json:"experiment_detail,omitempty"` - Scenario string `json:"scenario"` - Sessions int `json:"sessions"` - BackendReplicas int `json:"backend_replicas,omitempty"` - StickySessionCheck string `json:"sticky_session_check,omitempty"` - RateLimit int `json:"rate_limit_per_second,omitempty"` - GatewayRateLimit int `json:"gateway_rate_limit_rps,omitempty"` - ManifestName string `json:"manifest_name,omitempty"` - BackendImageID string `json:"backend_image_id,omitempty"` - LoadImageID string `json:"load_image_id,omitempty"` - ExperimentConfigHash string `json:"experiment_config_hash,omitempty"` - TokenTTLSecondsAtStart int `json:"token_ttl_seconds_at_start,omitempty"` - Topic string `json:"topic"` - RetainedTopic string `json:"retained_topic"` - DeniedTopic string `json:"denied_topic,omitempty"` - HTTPTimeoutSeconds float64 `json:"http_timeout_seconds"` - StartupRampSeconds float64 `json:"startup_ramp_seconds,omitempty"` - PollIntervalSeconds float64 `json:"poll_interval_seconds,omitempty"` - SubscribeDurationS int `json:"subscribe_duration_seconds"` - MaxMessages int `json:"max_messages"` - MaxBytes int `json:"max_bytes"` - WatchTTLS int `json:"watch_ttl_seconds"` - BackendConnectS int `json:"backend_mqtt_connect_timeout_seconds,omitempty"` - BackendSubscribeS int `json:"backend_mqtt_subscribe_timeout_seconds,omitempty"` - BackendCollectMax int `json:"backend_mqtt_collect_max_concurrent_per_pod,omitempty"` - BackendWatchStartMax int `json:"backend_mqtt_watch_start_max_concurrent_per_pod,omitempty"` - TotalRequests uint64 `json:"total_requests"` - Successes uint64 `json:"successes"` - Failures uint64 `json:"failures"` - ExpectedToolErrors uint64 `json:"expected_tool_errors"` - InitializedSessions uint64 `json:"initialized_sessions"` - StartedWatches uint64 `json:"started_watches"` - StoppedWatches uint64 `json:"stopped_watches"` - SessionNotFoundErrors uint64 `json:"session_not_found_errors,omitempty"` - SubscriptionNotFoundErrors uint64 `json:"subscription_not_found_errors,omitempty"` - ByOperation map[string]operationSnapshot `json:"by_operation"` - Errors map[string]uint64 `json:"errors"` - ErrorSamples map[string][]string `json:"error_samples,omitempty"` -} - -type operationSnapshot struct { - Phase string `json:"phase"` - Count uint64 `json:"count"` - Successes uint64 `json:"successes"` - Failures uint64 `json:"failures"` - P50Milliseconds float64 `json:"p50_ms"` - P95Milliseconds float64 `json:"p95_ms"` - P99Milliseconds float64 `json:"p99_ms"` - Errors map[string]uint64 `json:"errors,omitempty"` -} - -type operationStats struct { - count uint64 - successes uint64 - failures uint64 - latencies []time.Duration - errors map[string]uint64 -} - -type recorder struct { - mu sync.Mutex - startedAt time.Time - endpoint string - experiment string - experimentDetail string - scenario string - sessions int - backendReplicas int - stickySessionCheck string - rateLimit int - gatewayRateLimit int - manifestName string - backendImageID string - loadImageID string - experimentConfigHash string - tokenTTLSecondsAtStart int - topic string - retainedTopic string - deniedTopic string - httpTimeout time.Duration - startupRamp time.Duration - pollInterval time.Duration - subscribeDuration int - maxMessages int - maxBytes int - watchTTL int - backendConnectS int - backendSubscribeS int - backendCollectMax int - backendWatchStartMax int - totalRequests uint64 - successes uint64 - failures uint64 - expectedToolErrors uint64 - initializedSessions uint64 - startedWatches uint64 - stoppedWatches uint64 - sessionNotFoundErrors uint64 - subscriptionNotFoundErrors uint64 - byOperation map[string]*operationStats - errors map[string]uint64 - errorSamples map[string][]string -} - -type rateLimiter struct { - ch <-chan struct{} -} - -func main() { - cfg := parseConfig() - sessionCounts, err := parseSessionCounts(cfg) - if err != nil { - fmt.Fprintf(os.Stderr, "configuration error: %v\n", err) - os.Exit(2) - } - - reports := make([]runReport, 0, len(sessionCounts)) - failed := false - for _, sessions := range sessionCounts { - runCfg := cfg - runCfg.sessions = sessions - if err := validateConfig(runCfg); err != nil { - fmt.Fprintf(os.Stderr, "configuration error: %v\n", err) - os.Exit(2) - } - report := runLoad(runCfg) - printTextReport(os.Stderr, report) - reports = append(reports, report) - if report.Failures > 0 { - failed = true - } - } - - enc := json.NewEncoder(os.Stdout) - enc.SetIndent("", " ") - if len(reports) == 1 { - err = enc.Encode(reports[0]) - } else { - err = enc.Encode(reports) - } - if err != nil { - fmt.Fprintf(os.Stderr, "write report JSON: %v\n", err) - os.Exit(1) - } - if cfg.reportDir != "" { - if err := writeReports(cfg.reportDir, reports); err != nil { - fmt.Fprintf(os.Stderr, "write report files: %v\n", err) - os.Exit(1) - } - } - if failed { - os.Exit(1) - } -} - -func runLoad(cfg config) runReport { - if err := validateConfig(cfg); err != nil { - fmt.Fprintf(os.Stderr, "configuration error: %v\n", err) - os.Exit(2) - } - - limiter := newRateLimiter(cfg.rateLimit) - rec := &recorder{ - startedAt: time.Now().UTC(), - endpoint: cfg.endpoint, - experiment: cfg.experiment, - experimentDetail: cfg.experimentDetail, - scenario: cfg.scenario, - sessions: cfg.sessions, - backendReplicas: cfg.backendReplicas, - stickySessionCheck: cfg.stickySessionCheck, - rateLimit: cfg.rateLimit, - gatewayRateLimit: cfg.gatewayRateLimit, - manifestName: cfg.manifestName, - backendImageID: cfg.backendImageID, - loadImageID: cfg.loadImageID, - experimentConfigHash: experimentConfigHash(cfg), - tokenTTLSecondsAtStart: tokenTTLSeconds(cfg.bearer), - topic: cfg.topic, - retainedTopic: cfg.retainedTopic, - deniedTopic: cfg.deniedTopic, - httpTimeout: cfg.httpTimeout, - startupRamp: cfg.startupRamp, - pollInterval: effectivePollInterval(cfg), - subscribeDuration: cfg.subscribeDuration, - maxMessages: cfg.maxMessages, - maxBytes: cfg.maxBytes, - watchTTL: cfg.watchTTL, - backendConnectS: cfg.backendConnectS, - backendSubscribeS: cfg.backendSubscribeS, - backendCollectMax: cfg.backendCollectMax, - backendWatchStartMax: cfg.backendWatchMax, - byOperation: map[string]*operationStats{}, - errors: map[string]uint64{}, - errorSamples: map[string][]string{}, - } - - ctx, cancel := context.WithTimeout(context.Background(), cfg.duration) - defer cancel() - - var wg sync.WaitGroup - for i := 0; i < cfg.sessions; i++ { - wg.Add(1) - go func(sessionIndex int) { - defer wg.Done() - if !waitStartupRamp(ctx, cfg.startupRamp, sessionIndex, cfg.sessions) { - return - } - client := &mcpClient{ - endpoint: cfg.endpoint, - bearer: cfg.bearer, - httpc: &http.Client{Timeout: cfg.httpTimeout}, - limiter: limiter, - } - runSession(ctx, cfg, rec, client, sessionIndex) - }(i) - } - wg.Wait() - - report := rec.snapshot(time.Now().UTC()) - return report -} - -func parseConfig() config { - cfg := config{} - flag.StringVar(&cfg.endpoint, "endpoint", env("DSX_EXCHANGE_MCP_URL", ""), "MCP endpoint URL") - flag.StringVar(&cfg.bearer, "bearer", firstEnv("DSX_EXCHANGE_E2E_BEARER", "DSX_EXCHANGE_BEARER"), "Bearer token for MCP Authorization") - flag.StringVar(&cfg.experiment, "experiment", env("DSX_EXCHANGE_MCP_LOAD_EXPERIMENT", ""), "experiment label recorded in JSON/CSV reports") - flag.StringVar(&cfg.experimentDetail, "experiment-detail", env("DSX_EXCHANGE_MCP_LOAD_EXPERIMENT_DETAIL", ""), "free-form experiment detail recorded in JSON/CSV reports") - flag.StringVar(&cfg.scenario, "scenario", env("DSX_EXCHANGE_MCP_LOAD_SCENARIO", "discovery"), "scenario: discovery, discovery-hold, schema-resources, bounded-read, mixed, mixed-stateless, or legacy watch/watch-hold/watch-status-hold/sticky-check") - flag.IntVar(&cfg.sessions, "sessions", envInt("DSX_EXCHANGE_MCP_LOAD_SESSIONS", 50), "concurrent MCP sessions") - flag.StringVar(&cfg.sessionSweep, "session-sweep", env("DSX_EXCHANGE_MCP_LOAD_SESSION_SWEEP", ""), "comma-separated concurrent MCP session counts; overrides -sessions when set") - flag.IntVar(&cfg.backendReplicas, "backend-replicas", envInt("DSX_EXCHANGE_MCP_LOAD_BACKEND_REPLICAS", 0), "metadata: MCP backend replica count for this experiment") - flag.StringVar(&cfg.stickySessionCheck, "sticky-session-check", env("DSX_EXCHANGE_MCP_LOAD_STICKY_SESSION_CHECK", ""), "metadata: sticky-session validation state such as not_run, planned, running, pass, or fail") - flag.DurationVar(&cfg.duration, "duration", envDuration("DSX_EXCHANGE_MCP_LOAD_DURATION", time.Minute), "load-test duration") - flag.DurationVar(&cfg.startupRamp, "startup-ramp", envDuration("DSX_EXCHANGE_MCP_LOAD_STARTUP_RAMP", 0), "spread MCP session startup across this duration; 0 starts all sessions immediately") - flag.DurationVar(&cfg.pollInterval, "poll-interval", envDuration("DSX_EXCHANGE_MCP_LOAD_POLL_INTERVAL", 0), "override watch/status poll interval; 0 uses scenario default") - flag.IntVar(&cfg.rateLimit, "rate-limit", envInt("DSX_EXCHANGE_MCP_LOAD_RATE_LIMIT", 0), "global request rate limit per second; 0 means unlimited") - flag.IntVar(&cfg.gatewayRateLimit, "gateway-rate-limit-rps", envInt("DSX_EXCHANGE_MCP_LOAD_GATEWAY_RATE_LIMIT_RPS", -1), "metadata: configured gateway tenant rate limit in requests per second; -1 uses -rate-limit") - flag.StringVar(&cfg.manifestName, "manifest-name", env("DSX_EXCHANGE_MCP_LOAD_MANIFEST_NAME", ""), "metadata: Kubernetes manifest or job name used for this run") - flag.StringVar(&cfg.backendImageID, "backend-image-id", env("DSX_EXCHANGE_MCP_LOAD_BACKEND_IMAGE_ID", ""), "metadata: backend image ID or digest used for this run") - flag.StringVar(&cfg.loadImageID, "load-image-id", env("DSX_EXCHANGE_MCP_LOAD_IMAGE_ID", ""), "metadata: load generator image ID or digest used for this run") - flag.StringVar(&cfg.topic, "topic", env("DSX_EXCHANGE_E2E_ALLOWED_TOPIC", "BMS/v1/PUB/Value/Rack/RackPower/#"), "allowed live topic filter") - flag.StringVar(&cfg.retainedTopic, "retained-topic", env("DSX_EXCHANGE_E2E_RETAINED_TOPIC", "BMS/v1/PUB/Metadata/Rack/RackPower/#"), "allowed retained metadata topic filter") - flag.StringVar(&cfg.deniedTopic, "denied-topic", env("DSX_EXCHANGE_E2E_DENIED_TOPIC", ""), "optional denied topic filter; expected to return MCP tool error") - flag.IntVar(&cfg.subscribeDuration, "subscribe-duration-s", envInt("DSX_EXCHANGE_MCP_LOAD_SUBSCRIBE_DURATION_S", 1), "bounded subscribe duration in seconds") - flag.IntVar(&cfg.maxMessages, "max-messages", envInt("DSX_EXCHANGE_MCP_LOAD_MAX_MESSAGES", 10), "max messages per read/subscribe call") - flag.IntVar(&cfg.maxBytes, "max-bytes", envInt("DSX_EXCHANGE_MCP_LOAD_MAX_BYTES", 32768), "max bytes per watch read") - flag.IntVar(&cfg.watchTTL, "watch-ttl-s", envInt("DSX_EXCHANGE_MCP_LOAD_WATCH_TTL_S", 30), "watch TTL in seconds") - flag.DurationVar(&cfg.httpTimeout, "http-timeout", envDuration("DSX_EXCHANGE_MCP_LOAD_HTTP_TIMEOUT", 30*time.Second), "HTTP request timeout") - flag.IntVar(&cfg.backendConnectS, "backend-mqtt-connect-timeout-s", envInt("DSX_EXCHANGE_MCP_LOAD_BACKEND_MQTT_CONNECT_TIMEOUT_S", 0), "metadata: backend MQTT connect timeout in seconds") - flag.IntVar(&cfg.backendSubscribeS, "backend-mqtt-subscribe-timeout-s", envInt("DSX_EXCHANGE_MCP_LOAD_BACKEND_MQTT_SUBSCRIBE_TIMEOUT_S", 0), "metadata: backend MQTT subscribe timeout in seconds") - flag.IntVar(&cfg.backendCollectMax, "backend-mqtt-collect-max-concurrent-per-pod", envInt("DSX_EXCHANGE_MCP_LOAD_BACKEND_MQTT_COLLECT_MAX_CONCURRENT_PER_POD", 0), "metadata: backend bounded MQTT tool admission limit per pod") - flag.IntVar(&cfg.backendWatchMax, "backend-mqtt-watch-start-max-concurrent-per-pod", envInt("DSX_EXCHANGE_MCP_LOAD_BACKEND_MQTT_WATCH_START_MAX_CONCURRENT_PER_POD", 0), "metadata: backend watch-start MQTT admission limit per pod") - flag.StringVar(&cfg.reportDir, "report-dir", env("DSX_EXCHANGE_MCP_LOAD_REPORT_DIR", ""), "optional directory for JSON, text, and CSV reports") - flag.Parse() - cfg.endpoint = strings.TrimSpace(cfg.endpoint) - cfg.bearer = strings.TrimSpace(cfg.bearer) - cfg.experiment = strings.TrimSpace(cfg.experiment) - cfg.experimentDetail = strings.TrimSpace(cfg.experimentDetail) - cfg.scenario = strings.TrimSpace(cfg.scenario) - cfg.stickySessionCheck = strings.TrimSpace(cfg.stickySessionCheck) - cfg.manifestName = strings.TrimSpace(cfg.manifestName) - cfg.backendImageID = strings.TrimSpace(cfg.backendImageID) - cfg.loadImageID = strings.TrimSpace(cfg.loadImageID) - cfg.topic = strings.TrimSpace(cfg.topic) - cfg.retainedTopic = strings.TrimSpace(cfg.retainedTopic) - cfg.deniedTopic = strings.TrimSpace(cfg.deniedTopic) - if cfg.gatewayRateLimit < 0 { - cfg.gatewayRateLimit = cfg.rateLimit - } - return cfg -} - -func validateConfig(cfg config) error { - if cfg.endpoint == "" { - return errors.New("-endpoint or DSX_EXCHANGE_MCP_URL is required") - } - if cfg.bearer == "" { - return errors.New("-bearer, DSX_EXCHANGE_E2E_BEARER, or DSX_EXCHANGE_BEARER is required") - } - if cfg.sessions <= 0 { - return errors.New("-sessions must be greater than zero") - } - if cfg.duration <= 0 { - return errors.New("-duration must be greater than zero") - } - if cfg.startupRamp < 0 { - return errors.New("-startup-ramp must be zero or greater") - } - if cfg.pollInterval < 0 { - return errors.New("-poll-interval must be zero or greater") - } - if cfg.gatewayRateLimit < 0 { - return errors.New("-gateway-rate-limit-rps must be zero or greater") - } - switch cfg.scenario { - case "discovery", "discovery-hold", "schema-resources", "bounded-read", "mixed-stateless", "watch", "watch-hold", "watch-status-hold", "sticky-check", "mixed": - default: - return fmt.Errorf("unknown scenario %q", cfg.scenario) - } - if cfg.backendReplicas < 0 { - return errors.New("-backend-replicas must be zero or greater") - } - if cfg.topic == "" { - return errors.New("-topic is required") - } - if cfg.retainedTopic == "" { - return errors.New("-retained-topic is required") - } - if cfg.subscribeDuration <= 0 { - return errors.New("-subscribe-duration-s must be greater than zero") - } - if cfg.maxMessages <= 0 { - return errors.New("-max-messages must be greater than zero") - } - if cfg.maxBytes <= 0 { - return errors.New("-max-bytes must be greater than zero") - } - if cfg.watchTTL <= 0 { - return errors.New("-watch-ttl-s must be greater than zero") - } - if cfg.backendCollectMax < 0 { - return errors.New("-backend-mqtt-collect-max-concurrent-per-pod must be zero or greater") - } - if cfg.backendWatchMax < 0 { - return errors.New("-backend-mqtt-watch-start-max-concurrent-per-pod must be zero or greater") - } - return nil -} - -func parseSessionCounts(cfg config) ([]int, error) { - if strings.TrimSpace(cfg.sessionSweep) == "" { - return []int{cfg.sessions}, nil - } - parts := strings.Split(cfg.sessionSweep, ",") - out := make([]int, 0, len(parts)) - for _, part := range parts { - part = strings.TrimSpace(part) - if part == "" { - continue - } - n, err := strconv.Atoi(part) - if err != nil || n <= 0 { - return nil, fmt.Errorf("-session-sweep contains invalid session count %q", part) - } - out = append(out, n) - } - if len(out) == 0 { - return nil, errors.New("-session-sweep did not contain any session counts") - } - return out, nil -} - -func effectivePollInterval(cfg config) time.Duration { - if cfg.pollInterval > 0 { - return cfg.pollInterval - } - switch cfg.scenario { - case "sticky-check": - return 250 * time.Millisecond - case "watch-hold", "watch-status-hold": - return time.Second - default: - return 0 - } -} - -func waitStartupRamp(ctx context.Context, ramp time.Duration, sessionIndex, sessions int) bool { - if ramp <= 0 || sessionIndex <= 0 || sessions <= 1 { - return ctx.Err() == nil - } - delay := time.Duration(int64(ramp) * int64(sessionIndex) / int64(sessions)) - if delay <= 0 { - return ctx.Err() == nil - } - timer := time.NewTimer(delay) - defer timer.Stop() - select { - case <-ctx.Done(): - return false - case <-timer.C: - return true - } -} - -func experimentConfigHash(cfg config) string { - normalized := map[string]any{ - "endpoint": cfg.endpoint, - "experiment": cfg.experiment, - "experiment_detail": cfg.experimentDetail, - "scenario": cfg.scenario, - "sessions": cfg.sessions, - "session_sweep": cfg.sessionSweep, - "backend_replicas": cfg.backendReplicas, - "sticky_session_check": cfg.stickySessionCheck, - "duration": cfg.duration.String(), - "startup_ramp": cfg.startupRamp.String(), - "poll_interval": effectivePollInterval(cfg).String(), - "client_rate_limit_per_second": cfg.rateLimit, - "gateway_rate_limit_rps": cfg.gatewayRateLimit, - "topic": cfg.topic, - "retained_topic": cfg.retainedTopic, - "denied_topic": cfg.deniedTopic, - "subscribe_duration_seconds": cfg.subscribeDuration, - "max_messages": cfg.maxMessages, - "max_bytes": cfg.maxBytes, - "watch_ttl_seconds": cfg.watchTTL, - "http_timeout": cfg.httpTimeout.String(), - "backend_mqtt_connect_timeout_seconds": cfg.backendConnectS, - "backend_mqtt_subscribe_timeout_seconds": cfg.backendSubscribeS, - "backend_mqtt_collect_max_concurrent_per_pod": cfg.backendCollectMax, - "backend_mqtt_watch_start_max_concurrent_per_pod": cfg.backendWatchMax, - "manifest_name": cfg.manifestName, - "backend_image_id": cfg.backendImageID, - "load_image_id": cfg.loadImageID, - } - raw, err := json.Marshal(normalized) - if err != nil { - return "" - } - sum := sha256.Sum256(raw) - return fmt.Sprintf("sha256:%x", sum) -} - -func tokenTTLSeconds(bearer string) int { - parts := strings.Split(strings.TrimSpace(bearer), ".") - if len(parts) < 2 { - return 0 - } - raw, err := base64.RawURLEncoding.DecodeString(parts[1]) - if err != nil { - return 0 - } - var claims struct { - Exp float64 `json:"exp"` - } - if err := json.Unmarshal(raw, &claims); err != nil { - return 0 - } - if claims.Exp <= 0 { - return 0 - } - ttl := int(claims.Exp - float64(time.Now().Unix())) - if ttl < 0 { - return 0 - } - return ttl -} - -func runSession(ctx context.Context, cfg config, rec *recorder, client *mcpClient, sessionIndex int) { - var sessionID string - if _, err := measure(ctx, rec, "initialize", func(ctx context.Context) error { - var initErr error - sessionID, initErr = client.initialize(ctx) - return initErr - }); err != nil { - return - } - if sessionID != "" { - rec.recordInitializedSession() - } - if _, err := measure(ctx, rec, "notifications_initialized", func(ctx context.Context) error { - return client.initialized(ctx, sessionID) - }); err != nil { - return - } - - var tools []string - if _, err := measure(ctx, rec, "tools_list", func(ctx context.Context) error { - var listErr error - tools, listErr = client.listTools(ctx, sessionID) - return listErr - }); err != nil { - return - } - names := resolveTools(tools) - if err := names.require(cfg.scenario); err != nil { - rec.recordError(err.Error()) - return - } - - rng := rand.New(rand.NewSource(time.Now().UnixNano() + int64(sessionIndex))) - switch cfg.scenario { - case "watch-hold": - runWatchHold(ctx, cfg, rec, client, sessionID, names) - return - case "watch-status-hold": - runWatchStatusHold(ctx, cfg, rec, client, sessionID, names) - return - case "sticky-check": - runStickyCheck(ctx, cfg, rec, client, sessionID, names) - return - } - for ctx.Err() == nil { - switch cfg.scenario { - case "discovery", "discovery-hold": - runDiscovery(ctx, cfg, rec, client, sessionID, names) - case "schema-resources": - runSchemaResources(ctx, rec, client, sessionID) - case "bounded-read": - runBoundedRead(ctx, cfg, rec, client, sessionID, names) - case "mixed-stateless": - if rng.Intn(100) < 60 { - runDiscovery(ctx, cfg, rec, client, sessionID, names) - } else { - runBoundedRead(ctx, cfg, rec, client, sessionID, names) - } - case "watch": - runWatch(ctx, cfg, rec, client, sessionID, names) - case "mixed": - if rng.Intn(100) < 60 { - runDiscovery(ctx, cfg, rec, client, sessionID, names) - } else { - runBoundedRead(ctx, cfg, rec, client, sessionID, names) - } - } - } -} - -func runSchemaResources(ctx context.Context, rec *recorder, client *mcpClient, sessionID string) { - measure(ctx, rec, "resources_list", func(ctx context.Context) error { - uris, err := client.listResources(ctx, sessionID) - if err != nil { - return err - } - if len(uris) == 0 { - return errors.New("resources_list_empty") - } - return nil - }) - measure(ctx, rec, "resources_read_index", func(ctx context.Context) error { - return client.readResource(ctx, sessionID, "dsx-exchange://specs/") - }) - measure(ctx, rec, "resources_read_bms", func(ctx context.Context) error { - return client.readResource(ctx, sessionID, "dsx-exchange://specs/bms") - }) -} - -func runDiscovery(ctx context.Context, cfg config, rec *recorder, client *mcpClient, sessionID string, names toolNames) { - measure(ctx, rec, "find_topics", func(ctx context.Context) error { - res, err := client.callTool(ctx, sessionID, names.find, map[string]any{ - "domain": "bms", - "query": "RackPower", - "role": "value", - "point_type": "RackPower", - "limit": 20, - }) - if err != nil { - return err - } - if res.IsError { - return fmt.Errorf("unexpected_tool_error:%s", compact(res.textSummary())) - } - return nil - }) - measure(ctx, rec, "describe_topic", func(ctx context.Context) error { - res, err := client.callTool(ctx, sessionID, names.describe, map[string]any{ - "topic_filter": cfg.topic, - }) - if err != nil { - return err - } - if res.IsError { - return fmt.Errorf("unexpected_tool_error:%s", compact(res.textSummary())) - } - return nil - }) -} - -func runBoundedRead(ctx context.Context, cfg config, rec *recorder, client *mcpClient, sessionID string, names toolNames) { - measure(ctx, rec, "read_retained", func(ctx context.Context) error { - res, err := client.callTool(ctx, sessionID, names.readRetained, map[string]any{ - "topic_filter": cfg.retainedTopic, - "max_messages": cfg.maxMessages, - }) - if err != nil { - return err - } - if res.IsError { - return fmt.Errorf("unexpected_tool_error:%s", compact(res.textSummary())) - } - return nil - }) - measure(ctx, rec, "subscribe", func(ctx context.Context) error { - res, err := client.callTool(ctx, sessionID, names.subscribe, map[string]any{ - "topic_filter": cfg.topic, - "max_messages": cfg.maxMessages, - "max_duration_s": cfg.subscribeDuration, - }) - if err != nil { - return err - } - if res.IsError { - return fmt.Errorf("unexpected_tool_error:%s", compact(res.textSummary())) - } - return nil - }) - if cfg.deniedTopic != "" { - measure(ctx, rec, "subscribe_denied_expected", func(ctx context.Context) error { - res, err := client.callTool(ctx, sessionID, names.subscribe, map[string]any{ - "topic_filter": cfg.deniedTopic, - "max_messages": 1, - "max_duration_s": cfg.subscribeDuration, - }) - if err != nil { - return err - } - if !res.IsError { - return errors.New("denied_topic_unexpected_success") - } - rec.recordExpectedToolError() - return nil - }) - } -} - -func runWatch(ctx context.Context, cfg config, rec *recorder, client *mcpClient, sessionID string, names toolNames) { - started, err := startSubscription(ctx, cfg, rec, client, sessionID, names) - if err != nil { - return - } - - _, _ = statusSubscription(ctx, rec, client, sessionID, names, started.SubscriptionID) - _, _ = readSubscription(ctx, cfg, rec, client, sessionID, names, started.SubscriptionID, started.Cursor) - _ = stopSubscription(ctx, rec, client, sessionID, names, started.SubscriptionID) -} - -func runWatchHold(ctx context.Context, cfg config, rec *recorder, client *mcpClient, sessionID string, names toolNames) { - started, err := startSubscription(ctx, cfg, rec, client, sessionID, names) - if err != nil { - return - } - cursor := started.Cursor - ticker := time.NewTicker(effectivePollInterval(cfg)) - defer ticker.Stop() - - for ctx.Err() == nil { - _, _ = statusSubscription(ctx, rec, client, sessionID, names, started.SubscriptionID) - if nextCursor, err := readSubscription(ctx, cfg, rec, client, sessionID, names, started.SubscriptionID, cursor); err == nil && nextCursor != "" { - cursor = nextCursor - } - select { - case <-ctx.Done(): - case <-ticker.C: - } - } - - cleanupCtx, cancel := context.WithTimeout(context.Background(), cfg.httpTimeout) - defer cancel() - _ = stopSubscription(cleanupCtx, rec, client, sessionID, names, started.SubscriptionID) -} - -func runWatchStatusHold(ctx context.Context, cfg config, rec *recorder, client *mcpClient, sessionID string, names toolNames) { - started, err := startSubscription(ctx, cfg, rec, client, sessionID, names) - if err != nil { - return - } - ticker := time.NewTicker(effectivePollInterval(cfg)) - defer ticker.Stop() - - for ctx.Err() == nil { - _, _ = statusSubscription(ctx, rec, client, sessionID, names, started.SubscriptionID) - select { - case <-ctx.Done(): - case <-ticker.C: - } - } - - cleanupCtx, cancel := context.WithTimeout(context.Background(), cfg.httpTimeout) - defer cancel() - _ = stopSubscription(cleanupCtx, rec, client, sessionID, names, started.SubscriptionID) -} - -func runStickyCheck(ctx context.Context, cfg config, rec *recorder, client *mcpClient, sessionID string, names toolNames) { - started, err := startSubscription(ctx, cfg, rec, client, sessionID, names) - if err != nil { - return - } - cursor := started.Cursor - ticker := time.NewTicker(effectivePollInterval(cfg)) - defer ticker.Stop() - - for ctx.Err() == nil { - _, _ = statusSubscription(ctx, rec, client, sessionID, names, started.SubscriptionID) - if nextCursor, err := readSubscription(ctx, cfg, rec, client, sessionID, names, started.SubscriptionID, cursor); err == nil && nextCursor != "" { - cursor = nextCursor - } - select { - case <-ctx.Done(): - case <-ticker.C: - } - } - - cleanupCtx, cancel := context.WithTimeout(context.Background(), cfg.httpTimeout) - defer cancel() - _ = stopSubscription(cleanupCtx, rec, client, sessionID, names, started.SubscriptionID) -} - -type startedSubscription struct { - SubscriptionID string `json:"subscription_id"` - Cursor string `json:"cursor"` -} - -func startSubscription(ctx context.Context, cfg config, rec *recorder, client *mcpClient, sessionID string, names toolNames) (startedSubscription, error) { - var out startedSubscription - _, err := measure(ctx, rec, "start_subscription", func(ctx context.Context) error { - res, err := client.callTool(ctx, sessionID, names.start, map[string]any{ - "topic_filter": cfg.topic, - "ttl_seconds": cfg.watchTTL, - "buffer_max_messages": cfg.maxMessages, - "buffer_max_bytes": cfg.maxBytes, - }) - if err != nil { - return err - } - if res.IsError { - return fmt.Errorf("unexpected_tool_error:%s", compact(res.textSummary())) - } - if err := json.Unmarshal([]byte(res.lastText()), &out); err != nil { - return fmt.Errorf("decode_start_subscription:%w", err) - } - if out.SubscriptionID == "" { - return errors.New("start_subscription_missing_id") - } - return nil - }) - if err != nil { - return startedSubscription{}, err - } - rec.recordStartedWatch() - return out, nil -} - -func statusSubscription(ctx context.Context, rec *recorder, client *mcpClient, sessionID string, names toolNames, subscriptionID string) (string, error) { - var status string - _, err := measure(ctx, rec, "subscription_status", func(ctx context.Context) error { - res, err := client.callTool(ctx, sessionID, names.status, map[string]any{ - "subscription_id": subscriptionID, - }) - if err != nil { - return err - } - if res.IsError { - return fmt.Errorf("unexpected_tool_error:%s", compact(res.textSummary())) - } - var out struct { - Status string `json:"status"` - } - if err := json.Unmarshal([]byte(res.lastText()), &out); err != nil { - return fmt.Errorf("decode_subscription_status:%w", err) - } - status = out.Status - return nil - }) - return status, err -} - -func readSubscription(ctx context.Context, cfg config, rec *recorder, client *mcpClient, sessionID string, names toolNames, subscriptionID, cursor string) (string, error) { - nextCursor := cursor - _, err := measure(ctx, rec, "read_subscription", func(ctx context.Context) error { - res, err := client.callTool(ctx, sessionID, names.read, map[string]any{ - "subscription_id": subscriptionID, - "cursor": cursor, - "max_messages": cfg.maxMessages, - "max_bytes": cfg.maxBytes, - }) - if err != nil { - return err - } - if res.IsError { - return fmt.Errorf("unexpected_tool_error:%s", compact(res.textSummary())) - } - var out struct { - NextCursor string `json:"next_cursor"` - } - if err := json.Unmarshal([]byte(res.lastText()), &out); err != nil { - return fmt.Errorf("decode_read_subscription:%w", err) - } - if out.NextCursor != "" { - nextCursor = out.NextCursor - } - return nil - }) - return nextCursor, err -} - -func stopSubscription(ctx context.Context, rec *recorder, client *mcpClient, sessionID string, names toolNames, subscriptionID string) error { - _, err := measure(ctx, rec, "stop_subscription", func(ctx context.Context) error { - res, err := client.callTool(ctx, sessionID, names.stop, map[string]any{ - "subscription_id": subscriptionID, - }) - if err != nil { - return err - } - if res.IsError { - return fmt.Errorf("unexpected_tool_error:%s", compact(res.textSummary())) - } - return nil - }) - if err == nil { - rec.recordStoppedWatch() - } - return err -} - -func measure(ctx context.Context, rec *recorder, operation string, fn func(context.Context) error) (string, error) { - start := time.Now() - err := fn(ctx) - duration := time.Since(start) - if err != nil { - if ctx.Err() != nil && errors.Is(err, context.DeadlineExceeded) { - return "", err - } - rec.record(operation, duration, false, err) - return "", err - } - rec.record(operation, duration, true, nil) - return "", nil -} - -func (c *mcpClient) initialize(ctx context.Context) (string, error) { - _, sessionID, err := c.request(ctx, "", "initialize", map[string]any{ - "protocolVersion": "2025-06-18", - "capabilities": map[string]any{}, - "clientInfo": map[string]any{ - "name": "dsx-exchange-mcp-load", - "version": "0.1.0", - }, - }) - return sessionID, err -} - -func (c *mcpClient) initialized(ctx context.Context, sessionID string) error { - _, _, err := c.post(ctx, sessionID, map[string]any{ - "jsonrpc": "2.0", - "method": "notifications/initialized", - }) - return err -} - -func (c *mcpClient) listTools(ctx context.Context, sessionID string) ([]string, error) { - raw, _, err := c.request(ctx, sessionID, "tools/list", map[string]any{}) - if err != nil { - return nil, err - } - var result struct { - Tools []struct { - Name string `json:"name"` - } `json:"tools"` - } - if err := json.Unmarshal(raw, &result); err != nil { - return nil, fmt.Errorf("decode_tools_list:%w", err) - } - names := make([]string, 0, len(result.Tools)) - for _, tool := range result.Tools { - names = append(names, tool.Name) - } - return names, nil -} - -func (c *mcpClient) listResources(ctx context.Context, sessionID string) ([]string, error) { - raw, _, err := c.request(ctx, sessionID, "resources/list", map[string]any{}) - if err != nil { - return nil, err - } - var result struct { - Resources []struct { - URI string `json:"uri"` - } `json:"resources"` - } - if err := json.Unmarshal(raw, &result); err != nil { - return nil, fmt.Errorf("decode_resources_list:%w", err) - } - uris := make([]string, 0, len(result.Resources)) - for _, resource := range result.Resources { - if resource.URI != "" { - uris = append(uris, resource.URI) - } - } - return uris, nil -} - -func (c *mcpClient) readResource(ctx context.Context, sessionID string, uri string) error { - raw, _, err := c.request(ctx, sessionID, "resources/read", map[string]any{ - "uri": uri, - }) - if err != nil { - return err - } - var result struct { - Contents []struct { - URI string `json:"uri"` - Text string `json:"text"` - MIMEType string `json:"mimeType"` - } `json:"contents"` - } - if err := json.Unmarshal(raw, &result); err != nil { - return fmt.Errorf("decode_resources_read:%w", err) - } - if len(result.Contents) == 0 || result.Contents[0].Text == "" { - return fmt.Errorf("resource_empty:%s", uri) - } - return nil -} - -func (c *mcpClient) callTool(ctx context.Context, sessionID, name string, args map[string]any) (toolCallResult, error) { - raw, _, err := c.request(ctx, sessionID, "tools/call", map[string]any{ - "name": name, - "arguments": args, - }) - if err != nil { - return toolCallResult{}, err - } - var result toolCallResult - if err := json.Unmarshal(raw, &result); err != nil { - return toolCallResult{}, fmt.Errorf("decode_tools_call:%w", err) - } - return result, nil -} - -func (c *mcpClient) request(ctx context.Context, sessionID, method string, params map[string]any) (json.RawMessage, string, error) { - c.nextID++ - resp, newSessionID, err := c.post(ctx, sessionID, map[string]any{ - "jsonrpc": "2.0", - "id": c.nextID, - "method": method, - "params": params, - }) - if err != nil { - return nil, newSessionID, err - } - if resp.Error != nil { - return nil, newSessionID, fmt.Errorf("jsonrpc_error_%d:%s", resp.Error.Code, resp.Error.Message) - } - return resp.Result, newSessionID, nil -} - -func (c *mcpClient) post(ctx context.Context, sessionID string, payload map[string]any) (rpcResponse, string, error) { - if c.limiter != nil { - if err := c.limiter.wait(ctx); err != nil { - return rpcResponse{}, "", err - } - } - body, err := json.Marshal(payload) - if err != nil { - return rpcResponse{}, "", err - } - req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.endpoint, bytes.NewReader(body)) - if err != nil { - return rpcResponse{}, "", err - } - req.Header.Set("Authorization", "Bearer "+c.bearer) - req.Header.Set("Content-Type", "application/json") - req.Header.Set("Accept", "application/json, text/event-stream") - if sessionID != "" { - req.Header.Set("Mcp-Session-Id", sessionID) - } - - res, err := c.httpc.Do(req) - if err != nil { - return rpcResponse{}, "", fmt.Errorf("http_request:%w", err) - } - defer res.Body.Close() - - raw, err := io.ReadAll(res.Body) - if err != nil { - return rpcResponse{}, res.Header.Get("Mcp-Session-Id"), fmt.Errorf("read_response:%w", err) - } - if res.StatusCode >= http.StatusBadRequest { - return rpcResponse{}, res.Header.Get("Mcp-Session-Id"), fmt.Errorf("http_%d:%s", res.StatusCode, strings.TrimSpace(string(raw))) - } - - data := lastMCPResponseData(raw) - if len(data) == 0 { - return rpcResponse{}, res.Header.Get("Mcp-Session-Id"), nil - } - var decoded rpcResponse - if err := json.Unmarshal(data, &decoded); err != nil { - return rpcResponse{}, res.Header.Get("Mcp-Session-Id"), fmt.Errorf("decode_mcp_response:%w", err) - } - return decoded, res.Header.Get("Mcp-Session-Id"), nil -} - -func lastMCPResponseData(body []byte) []byte { - body = bytes.TrimSpace(body) - if len(body) == 0 || bytes.HasPrefix(body, []byte("{")) { - return body - } - var last []byte - for _, line := range bytes.Split(body, []byte("\n")) { - line = bytes.TrimSpace(line) - if !bytes.HasPrefix(line, []byte("data:")) { - continue - } - data := bytes.TrimSpace(bytes.TrimPrefix(line, []byte("data:"))) - if len(data) == 0 || bytes.Equal(data, []byte("[DONE]")) { - continue - } - last = append(last[:0], data...) - } - return last -} - -func (r toolCallResult) textSummary() string { - var texts []string - for _, item := range r.Content { - if item.Text != "" { - texts = append(texts, item.Text) - } - } - return strings.Join(texts, "\n") -} - -func (r toolCallResult) lastText() string { - for i := len(r.Content) - 1; i >= 0; i-- { - if r.Content[i].Text != "" { - return r.Content[i].Text - } - } - return "" -} - -type toolNames struct { - subscribe string - readRetained string - describe string - find string - start string - read string - status string - stop string -} - -func resolveTools(names []string) toolNames { - return toolNames{ - subscribe: chooseToolName(names, toolSubscribe), - readRetained: chooseToolName(names, toolReadRetained), - describe: chooseToolName(names, toolDescribeTopic), - find: chooseToolName(names, toolFindTopics), - start: chooseToolName(names, toolStartSubscription), - read: chooseToolName(names, toolReadSubscription), - status: chooseToolName(names, toolStatusSubscription), - stop: chooseToolName(names, toolStopSubscription), - } -} - -func (n toolNames) require(scenario string) error { - if scenario == "schema-resources" { - return nil - } - if n.describe == "" { - return errors.New("missing_tool_describe_topic") - } - if n.find == "" { - return errors.New("missing_tool_find_topics") - } - if scenario == "bounded-read" || scenario == "mixed-stateless" || scenario == "mixed" { - if n.readRetained == "" { - return errors.New("missing_tool_read_retained") - } - if n.subscribe == "" { - return errors.New("missing_tool_subscribe") - } - } - if scenario == "watch" || scenario == "watch-hold" || scenario == "watch-status-hold" || scenario == "sticky-check" { - if n.start == "" || n.status == "" || n.stop == "" { - return errors.New("missing_watch_tool") - } - } - if scenario == "watch" || scenario == "watch-hold" || scenario == "sticky-check" { - if n.read == "" { - return errors.New("missing_watch_read_tool") - } - } - return nil -} - -func chooseToolName(names []string, baseName string) string { - for _, name := range names { - if name == baseName { - return name - } - } - for _, name := range names { - if strings.HasSuffix(name, "_"+baseName) { - return name - } - } - return "" -} - -func (r *recorder) record(operation string, duration time.Duration, success bool, err error) { - r.mu.Lock() - defer r.mu.Unlock() - r.totalRequests++ - stats := r.byOperation[operation] - if stats == nil { - stats = &operationStats{} - r.byOperation[operation] = stats - } - stats.count++ - stats.latencies = append(stats.latencies, duration) - if success { - r.successes++ - stats.successes++ - return - } - r.failures++ - stats.failures++ - if err != nil { - code := classifyError(err) - r.errors[code]++ - if stats.errors == nil { - stats.errors = map[string]uint64{} - } - stats.errors[code]++ - r.recordStickyErrorCountersLocked(err.Error()) - r.recordErrorSampleLocked(code, err) - } -} - -func (r *recorder) recordError(code string) { - r.mu.Lock() - defer r.mu.Unlock() - r.failures++ - r.errors[code]++ - r.recordStickyErrorCountersLocked(code) - r.recordErrorSampleLocked(code, errors.New(code)) -} - -func (r *recorder) recordStickyErrorCountersLocked(msg string) { - msg = compact(msg) - if strings.Contains(msg, "session not found") { - r.sessionNotFoundErrors++ - } - if strings.Contains(msg, "subscription_not_found") { - r.subscriptionNotFoundErrors++ - } -} - -func (r *recorder) recordErrorSampleLocked(code string, err error) { - if err == nil { - return - } - samples := r.errorSamples[code] - if len(samples) >= maxErrorSamples { - return - } - sample := compact(err.Error()) - if len(sample) > 300 { - sample = sample[:300] + "..." - } - for _, existing := range samples { - if existing == sample { - return - } - } - r.errorSamples[code] = append(samples, sample) -} - -func (r *recorder) recordExpectedToolError() { - r.mu.Lock() - defer r.mu.Unlock() - r.expectedToolErrors++ -} - -func (r *recorder) recordInitializedSession() { - r.mu.Lock() - defer r.mu.Unlock() - r.initializedSessions++ -} - -func (r *recorder) recordStartedWatch() { - r.mu.Lock() - defer r.mu.Unlock() - r.startedWatches++ -} - -func (r *recorder) recordStoppedWatch() { - r.mu.Lock() - defer r.mu.Unlock() - r.stoppedWatches++ -} - -func (r *recorder) snapshot(endedAt time.Time) runReport { - r.mu.Lock() - defer r.mu.Unlock() - byOperation := map[string]operationSnapshot{} - for op, stats := range r.byOperation { - latencies := append([]time.Duration(nil), stats.latencies...) - sort.Slice(latencies, func(i, j int) bool { return latencies[i] < latencies[j] }) - byOperation[op] = operationSnapshot{ - Phase: operationPhase(op), - Count: stats.count, - Successes: stats.successes, - Failures: stats.failures, - P50Milliseconds: percentileMS(latencies, 0.50), - P95Milliseconds: percentileMS(latencies, 0.95), - P99Milliseconds: percentileMS(latencies, 0.99), - Errors: cloneErrors(stats.errors), - } - } - durationSeconds := endedAt.Sub(r.startedAt).Seconds() - throughput := 0.0 - if durationSeconds > 0 { - throughput = float64(r.totalRequests) / durationSeconds - } - successRate := 0.0 - if r.totalRequests > 0 { - successRate = float64(r.successes) / float64(r.totalRequests) * 100 - } - return runReport{ - StartedAt: r.startedAt, - EndedAt: endedAt, - DurationSeconds: durationSeconds, - ThroughputRPS: throughput, - SuccessRate: successRate, - Endpoint: r.endpoint, - Experiment: r.experiment, - ExperimentDetail: r.experimentDetail, - Scenario: r.scenario, - Sessions: r.sessions, - BackendReplicas: r.backendReplicas, - StickySessionCheck: r.stickySessionCheckResultLocked(), - RateLimit: r.rateLimit, - GatewayRateLimit: r.gatewayRateLimit, - ManifestName: r.manifestName, - BackendImageID: r.backendImageID, - LoadImageID: r.loadImageID, - ExperimentConfigHash: r.experimentConfigHash, - TokenTTLSecondsAtStart: r.tokenTTLSecondsAtStart, - Topic: r.topic, - RetainedTopic: r.retainedTopic, - DeniedTopic: r.deniedTopic, - HTTPTimeoutSeconds: r.httpTimeout.Seconds(), - StartupRampSeconds: r.startupRamp.Seconds(), - PollIntervalSeconds: r.pollInterval.Seconds(), - SubscribeDurationS: r.subscribeDuration, - MaxMessages: r.maxMessages, - MaxBytes: r.maxBytes, - WatchTTLS: r.watchTTL, - BackendConnectS: r.backendConnectS, - BackendSubscribeS: r.backendSubscribeS, - BackendCollectMax: r.backendCollectMax, - BackendWatchStartMax: r.backendWatchStartMax, - TotalRequests: r.totalRequests, - Successes: r.successes, - Failures: r.failures, - ExpectedToolErrors: r.expectedToolErrors, - InitializedSessions: r.initializedSessions, - StartedWatches: r.startedWatches, - StoppedWatches: r.stoppedWatches, - SessionNotFoundErrors: r.sessionNotFoundErrors, - SubscriptionNotFoundErrors: r.subscriptionNotFoundErrors, - ByOperation: byOperation, - Errors: cloneErrors(r.errors), - ErrorSamples: cloneErrorSamples(r.errorSamples), - } -} - -func (r *recorder) stickySessionCheckResultLocked() string { - if r.scenario != "sticky-check" { - return r.stickySessionCheck - } - if r.failures == 0 { - return "pass" - } - return "fail" -} - -func operationPhase(operation string) string { - switch operation { - case "initialize", "notifications_initialized", "tools_list", "start_subscription": - return "startup" - case "stop_subscription": - return "cleanup" - default: - return "steady" - } -} - -func cloneErrors(in map[string]uint64) map[string]uint64 { - out := make(map[string]uint64, len(in)) - for k, v := range in { - out[k] = v - } - return out -} - -func cloneErrorSamples(in map[string][]string) map[string][]string { - out := make(map[string][]string, len(in)) - for k, v := range in { - out[k] = append([]string(nil), v...) - } - return out -} - -func percentileMS(latencies []time.Duration, p float64) float64 { - if len(latencies) == 0 { - return 0 - } - if len(latencies) == 1 { - return float64(latencies[0].Microseconds()) / 1000 - } - idx := int(float64(len(latencies)-1) * p) - return float64(latencies[idx].Microseconds()) / 1000 -} - -func classifyError(err error) string { - if err == nil { - return "" - } - msg := compact(err.Error()) - switch { - case strings.HasPrefix(msg, "http_"): - return strings.SplitN(msg, ":", 2)[0] - case strings.HasPrefix(msg, "jsonrpc_error_"): - return strings.SplitN(msg, ":", 2)[0] - case strings.HasPrefix(msg, "unexpected_tool_error:"): - if code := classifyToolError(msg); code != "" { - return "tool_error_" + code - } - return "unexpected_tool_error" - case strings.HasPrefix(msg, "context deadline exceeded"): - return "context_deadline" - case strings.HasPrefix(msg, "context canceled"): - return "context_canceled" - case strings.HasPrefix(msg, "http_request:"): - return "http_request" - default: - if len(msg) > 80 { - return msg[:80] - } - return msg - } -} - -func classifyToolError(msg string) string { - raw := strings.TrimPrefix(msg, "unexpected_tool_error:") - var body struct { - Error struct { - Code string `json:"code"` - } `json:"error"` - } - if err := json.Unmarshal([]byte(raw), &body); err != nil { - return "" - } - return strings.TrimSpace(body.Error.Code) -} - -func printTextReport(w io.Writer, report runReport) { - fmt.Fprintf(w, "MCP load report\n") - fmt.Fprintf(w, " endpoint: %s\n", report.Endpoint) - if report.Experiment != "" { - fmt.Fprintf(w, " experiment: %s\n", report.Experiment) - } - if report.ExperimentDetail != "" { - fmt.Fprintf(w, " experiment_detail: %s\n", report.ExperimentDetail) - } - fmt.Fprintf(w, " scenario: %s\n", report.Scenario) - fmt.Fprintf(w, " sessions: %d\n", report.Sessions) - if report.BackendReplicas > 0 || report.StickySessionCheck != "" { - fmt.Fprintf(w, " scale_metadata: backend_replicas=%d sticky_session_check=%s\n", report.BackendReplicas, report.StickySessionCheck) - } - fmt.Fprintf(w, " reproducibility: config_hash=%s manifest=%s backend_image=%s load_image=%s token_ttl_start=%ds gateway_rps=%d\n", - report.ExperimentConfigHash, report.ManifestName, report.BackendImageID, report.LoadImageID, report.TokenTTLSecondsAtStart, report.GatewayRateLimit) - fmt.Fprintf(w, " backend_mqtt_timeouts: connect=%ds subscribe=%ds http_timeout=%.1fs\n", report.BackendConnectS, report.BackendSubscribeS, report.HTTPTimeoutSeconds) - fmt.Fprintf(w, " backend_mqtt_admission: collect_max_per_pod=%d watch_start_max_per_pod=%d\n", report.BackendCollectMax, report.BackendWatchStartMax) - fmt.Fprintf(w, " workload_args: startup_ramp=%.1fs poll_interval=%.1fs subscribe_duration=%ds watch_ttl=%ds max_messages=%d max_bytes=%d\n", report.StartupRampSeconds, report.PollIntervalSeconds, report.SubscribeDurationS, report.WatchTTLS, report.MaxMessages, report.MaxBytes) - fmt.Fprintf(w, " duration: %.1fs\n", report.DurationSeconds) - fmt.Fprintf(w, " requests: %d success=%d failures=%d expected_tool_errors=%d throughput=%.2f/s success_rate=%.2f%%\n", report.TotalRequests, report.Successes, report.Failures, report.ExpectedToolErrors, report.ThroughputRPS, report.SuccessRate) - fmt.Fprintf(w, " initialized_sessions=%d started_watches=%d stopped_watches=%d\n", report.InitializedSessions, report.StartedWatches, report.StoppedWatches) - fmt.Fprintf(w, " sticky_errors: session_not_found=%d subscription_not_found=%d\n", report.SessionNotFoundErrors, report.SubscriptionNotFoundErrors) - ops := make([]string, 0, len(report.ByOperation)) - for op := range report.ByOperation { - ops = append(ops, op) - } - sort.Strings(ops) - for _, op := range ops { - s := report.ByOperation[op] - fmt.Fprintf(w, " %-28s phase=%-7s count=%-6d ok=%-6d fail=%-4d p50=%7.2fms p95=%7.2fms p99=%7.2fms\n", - op, s.Phase, s.Count, s.Successes, s.Failures, s.P50Milliseconds, s.P95Milliseconds, s.P99Milliseconds) - } - if len(report.Errors) > 0 { - fmt.Fprintf(w, " errors:\n") - keys := make([]string, 0, len(report.Errors)) - for k := range report.Errors { - keys = append(keys, k) - } - sort.Strings(keys) - for _, k := range keys { - fmt.Fprintf(w, " %s: %d\n", k, report.Errors[k]) - for _, sample := range report.ErrorSamples[k] { - fmt.Fprintf(w, " sample: %s\n", sample) - } - } - } -} - -func writeReports(dir string, reports []runReport) error { - if err := os.MkdirAll(dir, 0755); err != nil { - return err - } - timestamp := time.Now().UTC().Format("20060102-150405") - jsonPath := filepath.Join(dir, "dsx-exchange-mcp-load-"+timestamp+".json") - textPath := filepath.Join(dir, "dsx-exchange-mcp-load-"+timestamp+".txt") - csvPath := filepath.Join(dir, "dsx-exchange-mcp-load-"+timestamp+".csv") - - jsonFile, err := os.Create(jsonPath) - if err != nil { - return err - } - enc := json.NewEncoder(jsonFile) - enc.SetIndent("", " ") - if len(reports) == 1 { - err = enc.Encode(reports[0]) - } else { - err = enc.Encode(reports) - } - closeErr := jsonFile.Close() - if err != nil { - return err - } - if closeErr != nil { - return closeErr - } - - textFile, err := os.Create(textPath) - if err != nil { - return err - } - for i, report := range reports { - if i > 0 { - fmt.Fprintln(textFile) - } - printTextReport(textFile, report) - } - if err := textFile.Close(); err != nil { - return err - } - if err := writeCSVReport(csvPath, reports); err != nil { - return err - } - bundleDir, err := writeReportBundle(dir, timestamp, reports) - if err != nil { - return err - } - fmt.Fprintf(os.Stderr, "report files written: %s %s %s\n", jsonPath, textPath, csvPath) - fmt.Fprintf(os.Stderr, "report bundle written: %s\n", bundleDir) - return nil -} - -func writeReportBundle(dir, timestamp string, reports []runReport) (string, error) { - name := "dsx-exchange-mcp-load" - if len(reports) > 0 && reports[0].Experiment != "" { - name = reports[0].Experiment - } - bundleDir := filepath.Join(dir, sanitizeFilename(name)+"-"+timestamp) - if err := os.MkdirAll(bundleDir, 0755); err != nil { - return "", err - } - if err := writeJSONReport(filepath.Join(bundleDir, "report.json"), reports); err != nil { - return "", err - } - if err := writeTextReport(filepath.Join(bundleDir, "report.txt"), reports); err != nil { - return "", err - } - if err := writeCSVReport(filepath.Join(bundleDir, "report.csv"), reports); err != nil { - return "", err - } - if err := writeConfigYAML(filepath.Join(bundleDir, "config.yaml"), reports); err != nil { - return "", err - } - if err := writeSummaryMarkdown(filepath.Join(bundleDir, "summary.md"), reports); err != nil { - return "", err - } - return bundleDir, nil -} - -func writeJSONReport(path string, reports []runReport) error { - f, err := os.Create(path) - if err != nil { - return err - } - enc := json.NewEncoder(f) - enc.SetIndent("", " ") - if len(reports) == 1 { - err = enc.Encode(reports[0]) - } else { - err = enc.Encode(reports) - } - closeErr := f.Close() - if err != nil { - return err - } - return closeErr -} - -func writeTextReport(path string, reports []runReport) error { - f, err := os.Create(path) - if err != nil { - return err - } - for i, report := range reports { - if i > 0 { - fmt.Fprintln(f) - } - printTextReport(f, report) - } - return f.Close() -} - -func writeConfigYAML(path string, reports []runReport) error { - f, err := os.Create(path) - if err != nil { - return err - } - fmt.Fprintln(f, "runs:") - for _, report := range reports { - fmt.Fprintf(f, " - experiment: %q\n", report.Experiment) - fmt.Fprintf(f, " experiment_config_hash: %q\n", report.ExperimentConfigHash) - fmt.Fprintf(f, " manifest_name: %q\n", report.ManifestName) - fmt.Fprintf(f, " scenario: %q\n", report.Scenario) - fmt.Fprintf(f, " sessions: %d\n", report.Sessions) - fmt.Fprintf(f, " backend_replicas: %d\n", report.BackendReplicas) - fmt.Fprintf(f, " client_rate_limit_per_second: %d\n", report.RateLimit) - fmt.Fprintf(f, " gateway_rate_limit_rps: %d\n", report.GatewayRateLimit) - fmt.Fprintf(f, " duration_seconds: %.3f\n", report.DurationSeconds) - fmt.Fprintf(f, " startup_ramp_seconds: %.3f\n", report.StartupRampSeconds) - fmt.Fprintf(f, " poll_interval_seconds: %.3f\n", report.PollIntervalSeconds) - fmt.Fprintf(f, " http_timeout_seconds: %.3f\n", report.HTTPTimeoutSeconds) - fmt.Fprintf(f, " backend_mqtt_connect_timeout_seconds: %d\n", report.BackendConnectS) - fmt.Fprintf(f, " backend_mqtt_subscribe_timeout_seconds: %d\n", report.BackendSubscribeS) - fmt.Fprintf(f, " backend_mqtt_collect_max_concurrent_per_pod: %d\n", report.BackendCollectMax) - fmt.Fprintf(f, " backend_mqtt_watch_start_max_concurrent_per_pod: %d\n", report.BackendWatchStartMax) - fmt.Fprintf(f, " watch_ttl_seconds: %d\n", report.WatchTTLS) - fmt.Fprintf(f, " max_messages: %d\n", report.MaxMessages) - fmt.Fprintf(f, " max_bytes: %d\n", report.MaxBytes) - fmt.Fprintf(f, " token_ttl_seconds_at_start: %d\n", report.TokenTTLSecondsAtStart) - fmt.Fprintf(f, " backend_image_id: %q\n", report.BackendImageID) - fmt.Fprintf(f, " load_image_id: %q\n", report.LoadImageID) - fmt.Fprintf(f, " topic: %q\n", report.Topic) - fmt.Fprintf(f, " retained_topic: %q\n", report.RetainedTopic) - } - return f.Close() -} - -func writeSummaryMarkdown(path string, reports []runReport) error { - f, err := os.Create(path) - if err != nil { - return err - } - fmt.Fprintln(f, "# MCP Load Test Summary") - fmt.Fprintln(f) - fmt.Fprintln(f, "| Experiment | Scenario | Sessions | Replicas | Ramp | Poll | Success | Failures | Started Watches | Stopped Watches | Session 404 | Subscription Missing |") - fmt.Fprintln(f, "|---|---|---:|---:|---:|---:|---:|---:|---:|---:|---:|---:|") - for _, report := range reports { - fmt.Fprintf(f, "| %s | %s | %d | %d | %.0fs | %.3fs | %.2f%% | %d | %d | %d | %d | %d |\n", - report.Experiment, report.Scenario, report.Sessions, report.BackendReplicas, - report.StartupRampSeconds, report.PollIntervalSeconds, report.SuccessRate, - report.Failures, report.StartedWatches, report.StoppedWatches, - report.SessionNotFoundErrors, report.SubscriptionNotFoundErrors) - } - return f.Close() -} - -func writeCSVReport(path string, reports []runReport) error { - f, err := os.Create(path) - if err != nil { - return err - } - w := csv.NewWriter(f) - header := []string{ - "started_at", - "ended_at", - "experiment", - "experiment_detail", - "scenario", - "sessions", - "backend_replicas", - "sticky_session_check", - "rate_limit_per_second", - "gateway_rate_limit_rps", - "manifest_name", - "backend_image_id", - "load_image_id", - "experiment_config_hash", - "token_ttl_seconds_at_start", - "endpoint", - "topic", - "retained_topic", - "http_timeout_seconds", - "startup_ramp_seconds", - "poll_interval_seconds", - "subscribe_duration_seconds", - "max_messages", - "max_bytes", - "watch_ttl_seconds", - "backend_mqtt_connect_timeout_seconds", - "backend_mqtt_subscribe_timeout_seconds", - "backend_mqtt_collect_max_concurrent_per_pod", - "backend_mqtt_watch_start_max_concurrent_per_pod", - "duration_seconds", - "throughput_requests_per_second", - "success_rate_percent", - "total_requests", - "successes", - "failures", - "expected_tool_errors", - "initialized_sessions", - "started_watches", - "stopped_watches", - "session_not_found_errors", - "subscription_not_found_errors", - "operation", - "phase", - "operation_count", - "operation_successes", - "operation_failures", - "p50_ms", - "p95_ms", - "p99_ms", - "operation_errors", - "errors", - } - if err := w.Write(header); err != nil { - _ = f.Close() - return err - } - for _, report := range reports { - ops := make([]string, 0, len(report.ByOperation)) - for op := range report.ByOperation { - ops = append(ops, op) - } - sort.Strings(ops) - for _, op := range ops { - s := report.ByOperation[op] - if err := w.Write([]string{ - report.StartedAt.Format(time.RFC3339Nano), - report.EndedAt.Format(time.RFC3339Nano), - report.Experiment, - report.ExperimentDetail, - report.Scenario, - strconv.Itoa(report.Sessions), - strconv.Itoa(report.BackendReplicas), - report.StickySessionCheck, - strconv.Itoa(report.RateLimit), - strconv.Itoa(report.GatewayRateLimit), - report.ManifestName, - report.BackendImageID, - report.LoadImageID, - report.ExperimentConfigHash, - strconv.Itoa(report.TokenTTLSecondsAtStart), - report.Endpoint, - report.Topic, - report.RetainedTopic, - formatFloat(report.HTTPTimeoutSeconds), - formatFloat(report.StartupRampSeconds), - formatFloat(report.PollIntervalSeconds), - strconv.Itoa(report.SubscribeDurationS), - strconv.Itoa(report.MaxMessages), - strconv.Itoa(report.MaxBytes), - strconv.Itoa(report.WatchTTLS), - strconv.Itoa(report.BackendConnectS), - strconv.Itoa(report.BackendSubscribeS), - strconv.Itoa(report.BackendCollectMax), - strconv.Itoa(report.BackendWatchStartMax), - formatFloat(report.DurationSeconds), - formatFloat(report.ThroughputRPS), - formatFloat(report.SuccessRate), - strconv.FormatUint(report.TotalRequests, 10), - strconv.FormatUint(report.Successes, 10), - strconv.FormatUint(report.Failures, 10), - strconv.FormatUint(report.ExpectedToolErrors, 10), - strconv.FormatUint(report.InitializedSessions, 10), - strconv.FormatUint(report.StartedWatches, 10), - strconv.FormatUint(report.StoppedWatches, 10), - strconv.FormatUint(report.SessionNotFoundErrors, 10), - strconv.FormatUint(report.SubscriptionNotFoundErrors, 10), - op, - s.Phase, - strconv.FormatUint(s.Count, 10), - strconv.FormatUint(s.Successes, 10), - strconv.FormatUint(s.Failures, 10), - formatFloat(s.P50Milliseconds), - formatFloat(s.P95Milliseconds), - formatFloat(s.P99Milliseconds), - formatErrorSummary(s.Errors), - formatErrorSummary(report.Errors), - }); err != nil { - _ = f.Close() - return err - } - } - } - w.Flush() - if err := w.Error(); err != nil { - _ = f.Close() - return err - } - return f.Close() -} - -func formatFloat(v float64) string { - return strconv.FormatFloat(v, 'f', 3, 64) -} - -func sanitizeFilename(s string) string { - s = strings.TrimSpace(s) - if s == "" { - return "run" - } - var b strings.Builder - for _, r := range s { - switch { - case r >= 'a' && r <= 'z': - b.WriteRune(r) - case r >= 'A' && r <= 'Z': - b.WriteRune(r) - case r >= '0' && r <= '9': - b.WriteRune(r) - case r == '-' || r == '_' || r == '.': - b.WriteRune(r) - default: - b.WriteByte('-') - } - } - out := strings.Trim(b.String(), "-.") - if out == "" { - return "run" - } - return out -} - -func formatErrorSummary(errorsByCode map[string]uint64) string { - if len(errorsByCode) == 0 { - return "" - } - keys := make([]string, 0, len(errorsByCode)) - for k := range errorsByCode { - keys = append(keys, k) - } - sort.Strings(keys) - parts := make([]string, 0, len(keys)) - for _, k := range keys { - parts = append(parts, k+"="+strconv.FormatUint(errorsByCode[k], 10)) - } - return strings.Join(parts, ";") -} - -func newRateLimiter(rate int) *rateLimiter { - if rate <= 0 { - return nil - } - ch := make(chan struct{}, rate) - for i := 0; i < rate; i++ { - ch <- struct{}{} - } - interval := time.Second / time.Duration(rate) - go func() { - ticker := time.NewTicker(interval) - defer ticker.Stop() - for range ticker.C { - select { - case ch <- struct{}{}: - default: - } - } - }() - return &rateLimiter{ch: ch} -} - -func (l *rateLimiter) wait(ctx context.Context) error { - if l == nil { - return nil - } - select { - case <-ctx.Done(): - return ctx.Err() - case <-l.ch: - return nil - } -} - -func env(key, fallback string) string { - if v := os.Getenv(key); v != "" { - return v - } - return fallback -} - -func firstEnv(keys ...string) string { - for _, key := range keys { - if v := os.Getenv(key); v != "" { - return v - } - } - return "" -} - -func envInt(key string, fallback int) int { - if v := os.Getenv(key); v != "" { - var out int - if _, err := fmt.Sscanf(v, "%d", &out); err == nil { - return out - } - } - return fallback -} - -func envDuration(key string, fallback time.Duration) time.Duration { - if v := os.Getenv(key); v != "" { - if out, err := time.ParseDuration(v); err == nil { - return out - } - } - return fallback -} - -func compact(s string) string { - s = strings.TrimSpace(s) - s = strings.ReplaceAll(s, "\n", " ") - for strings.Contains(s, " ") { - s = strings.ReplaceAll(s, " ", " ") - } - return s -} diff --git a/mcp/dsx-exchange-mcp/cmd/dsx-exchange-mcp-load/main_test.go b/mcp/dsx-exchange-mcp/cmd/dsx-exchange-mcp-load/main_test.go deleted file mode 100644 index ac1aed5..0000000 --- a/mcp/dsx-exchange-mcp/cmd/dsx-exchange-mcp-load/main_test.go +++ /dev/null @@ -1,408 +0,0 @@ -// Copyright 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. -// SPDX-License-Identifier: Apache-2.0 - -package main - -import ( - "context" - "encoding/base64" - "encoding/csv" - "encoding/json" - "errors" - "os" - "path/filepath" - "testing" - "time" -) - -func TestRecorderSnapshotKeepsStartupVisible(t *testing.T) { - start := time.Now().Add(-2 * time.Second) - rec := &recorder{ - startedAt: start, - endpoint: "http://gateway/mcp", - experiment: "baseline", - experimentDetail: "mqtt_connect_timeout_s=5;mqtt_subscribe_timeout_s=5", - scenario: "watch-hold", - sessions: 2, - backendReplicas: 1, - stickySessionCheck: "not_run", - rateLimit: 100, - gatewayRateLimit: 1000, - manifestName: "kind-gateway-load-job.yaml", - backendImageID: "sha256:backend", - loadImageID: "sha256:load", - experimentConfigHash: "sha256:abc123", - tokenTTLSecondsAtStart: 600, - httpTimeout: 30 * time.Second, - startupRamp: 30 * time.Second, - pollInterval: time.Second, - subscribeDuration: 1, - maxMessages: 10, - maxBytes: 32768, - watchTTL: 30, - backendConnectS: 5, - backendSubscribeS: 5, - backendCollectMax: 100, - backendWatchStartMax: 500, - byOperation: map[string]*operationStats{}, - errors: map[string]uint64{}, - errorSamples: map[string][]string{}, - } - rec.record("initialize", 100*time.Millisecond, true, nil) - rec.record("start_subscription", 250*time.Millisecond, true, nil) - rec.record("subscription_status", 10*time.Millisecond, true, nil) - rec.record("stop_subscription", 20*time.Millisecond, true, nil) - rec.recordInitializedSession() - rec.recordStartedWatch() - rec.recordStoppedWatch() - - report := rec.snapshot(start.Add(2 * time.Second)) - if report.TotalRequests != 4 || report.Successes != 4 || report.Failures != 0 { - t.Fatalf("request counts = total %d success %d failure %d", report.TotalRequests, report.Successes, report.Failures) - } - if report.InitializedSessions != 1 || report.StartedWatches != 1 || report.StoppedWatches != 1 { - t.Fatalf("lifecycle counts = sessions %d started %d stopped %d", report.InitializedSessions, report.StartedWatches, report.StoppedWatches) - } - if got := report.ByOperation["initialize"].Phase; got != "startup" { - t.Fatalf("initialize phase = %q, want startup", got) - } - if got := report.ByOperation["subscription_status"].Phase; got != "steady" { - t.Fatalf("subscription_status phase = %q, want steady", got) - } - if got := report.ByOperation["stop_subscription"].Phase; got != "cleanup" { - t.Fatalf("stop_subscription phase = %q, want cleanup", got) - } - if report.ThroughputRPS != 2 { - t.Fatalf("throughput = %f, want 2", report.ThroughputRPS) - } - if report.SuccessRate != 100 { - t.Fatalf("success rate = %f, want 100", report.SuccessRate) - } - if report.Experiment != "baseline" || report.BackendConnectS != 5 || report.BackendSubscribeS != 5 || report.StartupRampSeconds != 30 { - t.Fatalf("experiment metadata = %q connect=%d subscribe=%d startup_ramp=%f", report.Experiment, report.BackendConnectS, report.BackendSubscribeS, report.StartupRampSeconds) - } - if report.BackendCollectMax != 100 || report.BackendWatchStartMax != 500 { - t.Fatalf("admission metadata = collect %d watch_start %d, want 100/500", report.BackendCollectMax, report.BackendWatchStartMax) - } - if report.BackendReplicas != 1 || report.StickySessionCheck != "not_run" { - t.Fatalf("scale metadata = replicas %d sticky %q", report.BackendReplicas, report.StickySessionCheck) - } - if report.GatewayRateLimit != 1000 || report.ManifestName != "kind-gateway-load-job.yaml" || report.BackendImageID != "sha256:backend" || report.LoadImageID != "sha256:load" { - t.Fatalf("repro metadata = gateway %d manifest %q backend %q load %q", report.GatewayRateLimit, report.ManifestName, report.BackendImageID, report.LoadImageID) - } - if report.ExperimentConfigHash != "sha256:abc123" || report.TokenTTLSecondsAtStart != 600 || report.PollIntervalSeconds != 1 { - t.Fatalf("config metadata = hash %q token_ttl=%d poll=%f", report.ExperimentConfigHash, report.TokenTTLSecondsAtStart, report.PollIntervalSeconds) - } -} - -func TestRecorderCountsContextFailures(t *testing.T) { - rec := &recorder{ - startedAt: time.Now(), - byOperation: map[string]*operationStats{}, - errors: map[string]uint64{}, - errorSamples: map[string][]string{}, - } - ctx := t.Context() - _, err := measure(ctx, rec, "initialize", func(context.Context) error { - return errors.New("context deadline exceeded") - }) - if err == nil { - t.Fatal("measure returned nil error") - } - report := rec.snapshot(time.Now()) - if report.Failures != 1 { - t.Fatalf("failures = %d, want 1", report.Failures) - } - if report.Errors["context_deadline"] != 1 { - t.Fatalf("context_deadline errors = %d, want 1", report.Errors["context_deadline"]) - } - if got := report.ErrorSamples["context_deadline"]; len(got) != 1 || got[0] != "context deadline exceeded" { - t.Fatalf("context_deadline samples = %#v, want context deadline exceeded", got) - } -} - -func TestMeasureSkipsParentDeadlineCancellation(t *testing.T) { - rec := &recorder{ - startedAt: time.Now(), - byOperation: map[string]*operationStats{}, - errors: map[string]uint64{}, - errorSamples: map[string][]string{}, - } - ctx, cancel := context.WithTimeout(context.Background(), time.Nanosecond) - defer cancel() - <-ctx.Done() - - if _, err := measure(ctx, rec, "subscribe", func(context.Context) error { - return context.DeadlineExceeded - }); err == nil { - t.Fatal("measure returned nil error, want deadline") - } - - report := rec.snapshot(time.Now()) - if report.TotalRequests != 0 || report.Failures != 0 { - t.Fatalf("parent deadline cancellation was recorded: total=%d failures=%d", report.TotalRequests, report.Failures) - } -} - -func TestClassifyErrorExtractsStructuredToolCode(t *testing.T) { - err := errors.New(`unexpected_tool_error:{"error":{"code":"mqtt_admission_limited","message":"retry later","retry_after_seconds":1}}`) - if got := classifyError(err); got != "tool_error_mqtt_admission_limited" { - t.Fatalf("classifyError = %q, want tool_error_mqtt_admission_limited", got) - } -} - -func TestRecorderSnapshotMarksStickyCheckResult(t *testing.T) { - start := time.Now().Add(-time.Second) - rec := &recorder{ - startedAt: start, - scenario: "sticky-check", - stickySessionCheck: "running", - byOperation: map[string]*operationStats{}, - errors: map[string]uint64{}, - errorSamples: map[string][]string{}, - } - rec.record("subscription_status", time.Millisecond, true, nil) - pass := rec.snapshot(start.Add(time.Second)) - if pass.StickySessionCheck != "pass" { - t.Fatalf("sticky_session_check = %q, want pass", pass.StickySessionCheck) - } - - rec.record("read_subscription", time.Millisecond, false, errors.New("unexpected_tool_error:subscription_not_found")) - rec.record("subscription_status", time.Millisecond, false, errors.New("http_404:session not found")) - fail := rec.snapshot(start.Add(2 * time.Second)) - if fail.StickySessionCheck != "fail" { - t.Fatalf("sticky_session_check = %q, want fail", fail.StickySessionCheck) - } - if fail.SessionNotFoundErrors != 1 || fail.SubscriptionNotFoundErrors != 1 { - t.Fatalf("sticky error counters = session %d subscription %d, want 1/1", fail.SessionNotFoundErrors, fail.SubscriptionNotFoundErrors) - } -} - -func TestWaitStartupRampSpreadsSessionStarts(t *testing.T) { - start := time.Now() - if ok := waitStartupRamp(t.Context(), 60*time.Millisecond, 0, 3); !ok { - t.Fatal("session 0 unexpectedly skipped") - } - if elapsed := time.Since(start); elapsed > 20*time.Millisecond { - t.Fatalf("session 0 waited %s, want immediate", elapsed) - } - - start = time.Now() - if ok := waitStartupRamp(t.Context(), 60*time.Millisecond, 2, 3); !ok { - t.Fatal("session 2 unexpectedly skipped") - } - if elapsed := time.Since(start); elapsed < 35*time.Millisecond || elapsed > 150*time.Millisecond { - t.Fatalf("session 2 waited %s, want roughly 40ms", elapsed) - } -} - -func TestWaitStartupRampHonorsContextCancellation(t *testing.T) { - ctx, cancel := context.WithCancel(t.Context()) - cancel() - if ok := waitStartupRamp(ctx, time.Minute, 1, 2); ok { - t.Fatal("waitStartupRamp returned true after cancellation") - } -} - -func TestEffectivePollIntervalDefaults(t *testing.T) { - if got := effectivePollInterval(config{scenario: "watch-status-hold"}); got != time.Second { - t.Fatalf("watch-status poll interval = %s, want 1s", got) - } - if got := effectivePollInterval(config{scenario: "sticky-check"}); got != 250*time.Millisecond { - t.Fatalf("sticky poll interval = %s, want 250ms", got) - } - if got := effectivePollInterval(config{scenario: "sticky-check", pollInterval: time.Second}); got != time.Second { - t.Fatalf("override poll interval = %s, want 1s", got) - } -} - -func TestExperimentConfigHashExcludesBearer(t *testing.T) { - cfg := config{ - endpoint: "http://gateway/mcp", - bearer: "secret-a", - scenario: "sticky-check", - sessions: 100, - duration: time.Minute, - gatewayRateLimit: 1000, - topic: "BMS/#", - retainedTopic: "BMS/meta/#", - subscribeDuration: 1, - maxMessages: 10, - maxBytes: 1024, - watchTTL: 60, - httpTimeout: 30 * time.Second, - } - a := experimentConfigHash(cfg) - cfg.bearer = "secret-b" - b := experimentConfigHash(cfg) - if a == "" || a != b { - t.Fatalf("hash changed when only bearer changed: %q vs %q", a, b) - } - cfg.sessions = 101 - if c := experimentConfigHash(cfg); c == a { - t.Fatalf("hash did not change after config change: %q", c) - } -} - -func TestTokenTTLSeconds(t *testing.T) { - claims, err := json.Marshal(map[string]int64{"exp": time.Now().Add(time.Minute).Unix()}) - if err != nil { - t.Fatal(err) - } - token := "header." + base64.RawURLEncoding.EncodeToString(claims) + ".signature" - if got := tokenTTLSeconds(token); got < 1 || got > 60 { - t.Fatalf("token ttl = %d, want within 1..60", got) - } - if got := tokenTTLSeconds("not-a-jwt"); got != 0 { - t.Fatalf("invalid token ttl = %d, want 0", got) - } -} - -func TestWriteCSVReportIncludesOperationRows(t *testing.T) { - start := time.Date(2026, 6, 8, 12, 0, 0, 0, time.UTC) - report := runReport{ - StartedAt: start, - EndedAt: start.Add(time.Minute), - DurationSeconds: 60, - ThroughputRPS: 10, - SuccessRate: 99.5, - Endpoint: "http://gateway/mcp", - Experiment: "mixed-baseline", - ExperimentDetail: "gateway_rps=1000;mqtt_connect_timeout_s=5;mqtt_subscribe_timeout_s=5", - Scenario: "watch-hold", - Sessions: 500, - BackendReplicas: 3, - StickySessionCheck: "planned", - RateLimit: 1000, - GatewayRateLimit: 5000, - ManifestName: "kind-gateway-load-job.yaml", - BackendImageID: "sha256:backend", - LoadImageID: "sha256:load", - ExperimentConfigHash: "sha256:abc123", - TokenTTLSecondsAtStart: 600, - Topic: "BMS/v1/PUB/Value/#", - RetainedTopic: "BMS/v1/PUB/Metadata/#", - HTTPTimeoutSeconds: 30, - StartupRampSeconds: 30, - PollIntervalSeconds: 1, - SubscribeDurationS: 1, - MaxMessages: 10, - MaxBytes: 32768, - WatchTTLS: 180, - BackendConnectS: 5, - BackendSubscribeS: 5, - BackendCollectMax: 100, - BackendWatchStartMax: 500, - TotalRequests: 1000, - Successes: 995, - Failures: 5, - InitializedSessions: 500, - StartedWatches: 491, - StoppedWatches: 491, - SessionNotFoundErrors: 2, - SubscriptionNotFoundErrors: 3, - ByOperation: map[string]operationSnapshot{ - "start_subscription": { - Phase: "startup", - Count: 500, - Successes: 491, - Failures: 9, - P50Milliseconds: 5683.353, - P95Milliseconds: 7466.277, - P99Milliseconds: 8002.154, - }, - }, - Errors: map[string]uint64{"unexpected_tool_error": 9}, - } - - path := filepath.Join(t.TempDir(), "report.csv") - if err := writeCSVReport(path, []runReport{report}); err != nil { - t.Fatalf("writeCSVReport returned error: %v", err) - } - f, err := os.Open(path) - if err != nil { - t.Fatalf("open csv: %v", err) - } - defer f.Close() - rows, err := csv.NewReader(f).ReadAll() - if err != nil { - t.Fatalf("read csv: %v", err) - } - if len(rows) != 2 { - t.Fatalf("csv rows = %d, want 2", len(rows)) - } - header := rows[0] - row := rows[1] - col := func(name string) string { - for i, h := range header { - if h == name { - return row[i] - } - } - t.Fatalf("missing csv column %q", name) - return "" - } - if got := col("sessions"); got != "500" { - t.Fatalf("sessions column = %q, want 500", got) - } - if got := col("backend_replicas"); got != "3" { - t.Fatalf("backend_replicas column = %q, want 3", got) - } - if got := col("sticky_session_check"); got != "planned" { - t.Fatalf("sticky_session_check column = %q, want planned", got) - } - if got := col("gateway_rate_limit_rps"); got != "5000" { - t.Fatalf("gateway_rate_limit_rps column = %q, want 5000", got) - } - if got := col("manifest_name"); got != "kind-gateway-load-job.yaml" { - t.Fatalf("manifest_name column = %q, want kind-gateway-load-job.yaml", got) - } - if got := col("backend_image_id"); got != "sha256:backend" { - t.Fatalf("backend_image_id column = %q, want sha256:backend", got) - } - if got := col("load_image_id"); got != "sha256:load" { - t.Fatalf("load_image_id column = %q, want sha256:load", got) - } - if got := col("experiment_config_hash"); got != "sha256:abc123" { - t.Fatalf("experiment_config_hash column = %q, want sha256:abc123", got) - } - if got := col("token_ttl_seconds_at_start"); got != "600" { - t.Fatalf("token_ttl_seconds_at_start column = %q, want 600", got) - } - if got := col("experiment"); got != "mixed-baseline" { - t.Fatalf("experiment column = %q, want mixed-baseline", got) - } - if got := col("backend_mqtt_connect_timeout_seconds"); got != "5" { - t.Fatalf("backend_mqtt_connect_timeout_seconds column = %q, want 5", got) - } - if got := col("backend_mqtt_collect_max_concurrent_per_pod"); got != "100" { - t.Fatalf("backend_mqtt_collect_max_concurrent_per_pod column = %q, want 100", got) - } - if got := col("backend_mqtt_watch_start_max_concurrent_per_pod"); got != "500" { - t.Fatalf("backend_mqtt_watch_start_max_concurrent_per_pod column = %q, want 500", got) - } - if got := col("http_timeout_seconds"); got != "30.000" { - t.Fatalf("http_timeout_seconds column = %q, want 30.000", got) - } - if got := col("startup_ramp_seconds"); got != "30.000" { - t.Fatalf("startup_ramp_seconds column = %q, want 30.000", got) - } - if got := col("poll_interval_seconds"); got != "1.000" { - t.Fatalf("poll_interval_seconds column = %q, want 1.000", got) - } - if got := col("session_not_found_errors"); got != "2" { - t.Fatalf("session_not_found_errors column = %q, want 2", got) - } - if got := col("subscription_not_found_errors"); got != "3" { - t.Fatalf("subscription_not_found_errors column = %q, want 3", got) - } - if got := col("operation"); got != "start_subscription" { - t.Fatalf("operation column = %q, want start_subscription", got) - } - if got := col("p95_ms"); got != "7466.277" { - t.Fatalf("p95_ms column = %q, want 7466.277", got) - } - if got := col("errors"); got != "unexpected_tool_error=9" { - t.Fatalf("errors column = %q, want unexpected_tool_error=9", got) - } -} diff --git a/mcp/dsx-exchange-mcp/deploy/loadtest/agentgatewaybackend-stateful-routing-patch.yaml b/mcp/dsx-exchange-mcp/deploy/loadtest/agentgatewaybackend-stateful-routing-patch.yaml deleted file mode 100644 index cd9824d..0000000 --- a/mcp/dsx-exchange-mcp/deploy/loadtest/agentgatewaybackend-stateful-routing-patch.yaml +++ /dev/null @@ -1,9 +0,0 @@ -# Copyright 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. -# SPDX-License-Identifier: Apache-2.0 - -# Apply with: -# kubectl --context kind-dsx-mcp patch agentgatewaybackend mcp-agentgw-mcp \ -# -n mcp-gateway --type=merge --patch-file agentgatewaybackend-stateful-routing-patch.yaml -spec: - mcp: - sessionRouting: Stateful diff --git a/mcp/dsx-exchange-mcp/deploy/loadtest/gateway-high-rate-values.yaml b/mcp/dsx-exchange-mcp/deploy/loadtest/gateway-high-rate-values.yaml deleted file mode 100644 index ea3d74e..0000000 --- a/mcp/dsx-exchange-mcp/deploy/loadtest/gateway-high-rate-values.yaml +++ /dev/null @@ -1,10 +0,0 @@ -# Copyright 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. -# SPDX-License-Identifier: Apache-2.0 - -# Test-only overlay for backend-capacity runs through Latinum MCP Gateway. -# Keep rate limiting enabled, but raise the tenant quota so the DSX Exchange -# MCP backend, not the production-like 1000 RPS gateway limiter, becomes the measured -# bottleneck. -rateLimit: - enabled: true - tenantRequestsPerSecond: 5000 diff --git a/mcp/dsx-exchange-mcp/deploy/loadtest/gateway-ratelimit-1000-configmap.yaml b/mcp/dsx-exchange-mcp/deploy/loadtest/gateway-ratelimit-1000-configmap.yaml deleted file mode 100644 index 8f81de4..0000000 --- a/mcp/dsx-exchange-mcp/deploy/loadtest/gateway-ratelimit-1000-configmap.yaml +++ /dev/null @@ -1,16 +0,0 @@ -# Copyright 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. -# SPDX-License-Identifier: Apache-2.0 - -apiVersion: v1 -kind: ConfigMap -metadata: - name: latinum-mcp-gateway-ratelimit-config - namespace: mcp-gateway -data: - mcp-gateway.yaml: | - domain: mcp-gateway - descriptors: - - key: tenant - rate_limit: - unit: second - requests_per_unit: 1000 diff --git a/mcp/dsx-exchange-mcp/deploy/loadtest/gateway-ratelimit-5000-configmap.yaml b/mcp/dsx-exchange-mcp/deploy/loadtest/gateway-ratelimit-5000-configmap.yaml deleted file mode 100644 index cf542d1..0000000 --- a/mcp/dsx-exchange-mcp/deploy/loadtest/gateway-ratelimit-5000-configmap.yaml +++ /dev/null @@ -1,16 +0,0 @@ -# Copyright 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. -# SPDX-License-Identifier: Apache-2.0 - -apiVersion: v1 -kind: ConfigMap -metadata: - name: latinum-mcp-gateway-ratelimit-config - namespace: mcp-gateway -data: - mcp-gateway.yaml: | - domain: mcp-gateway - descriptors: - - key: tenant - rate_limit: - unit: second - requests_per_unit: 5000 diff --git a/mcp/dsx-exchange-mcp/deploy/loadtest/run-kind-load-experiment.sh b/mcp/dsx-exchange-mcp/deploy/loadtest/run-kind-load-experiment.sh deleted file mode 100755 index def2568..0000000 --- a/mcp/dsx-exchange-mcp/deploy/loadtest/run-kind-load-experiment.sh +++ /dev/null @@ -1,390 +0,0 @@ -#!/usr/bin/env bash -# Copyright 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. -# SPDX-License-Identifier: Apache-2.0 - -set -euo pipefail - -ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../../.." && pwd)" -LOADTEST_DIR="$ROOT_DIR/dsx-exchange-mcp/deploy/loadtest" - -KUBECTL_CONTEXT="${KUBECTL_CONTEXT:-kind-dsx-mcp}" -MCP_NAMESPACE="${MCP_NAMESPACE:-mcp-backends}" -GATEWAY_NAMESPACE="${GATEWAY_NAMESPACE:-mcp-gateway}" -LOAD_NAMESPACE="${LOAD_NAMESPACE:-mcp-loadtest}" -BACKEND_DEPLOYMENT="${BACKEND_DEPLOYMENT:-dsx-exchange-mcp}" -BACKEND_REPLICAS="${BACKEND_REPLICAS:-1}" -SCENARIO="${SCENARIO:-mixed}" -SESSIONS="${SESSIONS:-100}" -SESSION_SWEEP="${SESSION_SWEEP:-}" -STARTUP_RAMP="${STARTUP_RAMP:-30s}" -DURATION="${DURATION:-90s}" -POLL_INTERVAL="${POLL_INTERVAL:-0s}" -GATEWAY_RPS="${GATEWAY_RPS:-1000}" -CLIENT_RPS="${CLIENT_RPS:-$GATEWAY_RPS}" -HTTP_TIMEOUT="${HTTP_TIMEOUT:-60s}" -WATCH_TTL_S="${WATCH_TTL_S:-360}" -SUBSCRIBE_DURATION_S="${SUBSCRIBE_DURATION_S:-1}" -MAX_MESSAGES="${MAX_MESSAGES:-10}" -MAX_BYTES="${MAX_BYTES:-32768}" -BACKEND_CONNECT_TIMEOUT_S="${BACKEND_CONNECT_TIMEOUT_S:-10}" -BACKEND_SUBSCRIBE_TIMEOUT_S="${BACKEND_SUBSCRIBE_TIMEOUT_S:-10}" -BACKEND_COLLECT_MAX_CONCURRENT="${BACKEND_COLLECT_MAX_CONCURRENT:-100}" -BACKEND_WATCH_START_MAX_CONCURRENT="${BACKEND_WATCH_START_MAX_CONCURRENT:-500}" -ENDPOINT="${ENDPOINT:-http://mcp-agentgw.mcp-gateway.svc.cluster.local/mcp}" -TOPIC="${TOPIC:-BMS/v1/PUB/Value/Rack/RackPower/#}" -RETAINED_TOPIC="${RETAINED_TOPIC:-BMS/v1/PUB/Metadata/Rack/RackPower/#}" -STICKY_SESSION_CHECK="${STICKY_SESSION_CHECK:-not_run}" -RESET_BACKEND="${RESET_BACKEND:-1}" -APPLY_BACKEND_ENV="${APPLY_BACKEND_ENV:-1}" -ENSURE_STATEFUL_GATEWAY="${ENSURE_STATEFUL_GATEWAY:-1}" -APPLY_GATEWAY_RATELIMIT="${APPLY_GATEWAY_RATELIMIT:-1}" -STRICT_JOB_SUCCESS="${STRICT_JOB_SUCCESS:-0}" -LOAD_IMAGE="${LOAD_IMAGE:-dsx-exchange-mcp-load:dev}" -REPORT_ROOT="${REPORT_ROOT:-$ROOT_DIR/dsx-exchange-mcp/reports/loadtest/live-$(date -u +%Y%m%d)}" - -if [[ -n "$SESSION_SWEEP" ]]; then - SESSION_LABEL="${SESSION_SWEEP//,/-}" -else - SESSION_LABEL="$SESSIONS" -fi -EXPERIMENT="${EXPERIMENT:-${SCENARIO}-r${BACKEND_REPLICAS}-${SESSION_LABEL}-ramp-${STARTUP_RAMP}-gateway-${GATEWAY_RPS}}" -SAFE_EXPERIMENT="$(printf '%s' "$EXPERIMENT" | tr -c 'A-Za-z0-9_.-' '-' | sed 's/^-*//; s/-*$//')" -if [[ -z "$SAFE_EXPERIMENT" ]]; then - SAFE_EXPERIMENT="loadtest" -fi -TIMESTAMP="$(date -u +%Y%m%d-%H%M%S)" -BUNDLE_DIR="$REPORT_ROOT/$SAFE_EXPERIMENT-$TIMESTAMP" -JOB_NAME="${JOB_NAME:-$(printf 'dsx-mcp-%s' "$SAFE_EXPERIMENT" | tr 'A-Z_.' 'a-z--' | cut -c1-60)}" -MANIFEST_NAME="$JOB_NAME.yaml" -MANIFEST_PATH="$BUNDLE_DIR/manifest.yaml" - -mkdir -p "$BUNDLE_DIR" - -kubectl_cmd() { - kubectl --context "$KUBECTL_CONTEXT" "$@" -} - -capture_cluster_state() { - local path="$1" - { - echo "# captured_at=$(date -u +%FT%TZ)" - echo - echo "## dsx-exchange-mcp deployment" - kubectl_cmd get deployment "$BACKEND_DEPLOYMENT" -n "$MCP_NAMESPACE" -o wide || true - echo - kubectl_cmd get pods -n "$MCP_NAMESPACE" -o wide || true - echo - echo "## agentgateway backend" - kubectl_cmd get agentgatewaybackend mcp-agentgw-mcp -n "$GATEWAY_NAMESPACE" -o yaml || true - echo - echo "## agentgateway policy" - kubectl_cmd get agentgatewaypolicy mcp-agentgw-authz -n "$GATEWAY_NAMESPACE" -o yaml || true - echo - echo "## rate limit config" - kubectl_cmd get configmap latinum-mcp-gateway-ratelimit-config -n "$GATEWAY_NAMESPACE" -o yaml || true - echo - echo "## gateway pods" - kubectl_cmd get pods -n "$GATEWAY_NAMESPACE" -o wide || true - } > "$path" -} - -write_token_metadata() { - local path="$1" - TOKEN_B64="$(kubectl_cmd get secret dsx-exchange-mcp-load-token -n "$LOAD_NAMESPACE" -o jsonpath='{.data.bearer}' 2>/dev/null || true)" \ - python3 - "$path" <<'PY' -import base64, json, os, sys, time -out = {"present": False, "valid_jwt_shape": False, "ttl_seconds": 0} -raw_b64 = os.environ.get("TOKEN_B64", "") -try: - token = base64.b64decode(raw_b64).decode().strip() - out["present"] = bool(token) - parts = token.split(".") - if len(parts) >= 2: - payload = parts[1] + "=" * (-len(parts[1]) % 4) - claims = json.loads(base64.urlsafe_b64decode(payload)) - out.update({ - "valid_jwt_shape": True, - "scopes": claims.get("scopes"), - "ttl_seconds": max(0, int(claims.get("exp", 0) - time.time())), - }) -except Exception as exc: - out["error"] = type(exc).__name__ -open(sys.argv[1], "w").write(json.dumps(out, indent=2, sort_keys=True) + "\n") -PY -} - -backend_image_id() { - kubectl_cmd get pods -n "$MCP_NAMESPACE" -l app=dsx-exchange-mcp \ - -o jsonpath='{.items[0].status.containerStatuses[0].imageID}' 2>/dev/null || true -} - -load_image_id() { - docker image inspect "$LOAD_IMAGE" --format '{{.Id}}' 2>/dev/null || true -} - -if [[ "$ENSURE_STATEFUL_GATEWAY" == "1" ]]; then - kubectl_cmd patch agentgatewaybackend mcp-agentgw-mcp -n "$GATEWAY_NAMESPACE" \ - --type=merge --patch-file "$LOADTEST_DIR/agentgatewaybackend-stateful-routing-patch.yaml" -fi - -if [[ "$APPLY_GATEWAY_RATELIMIT" == "1" ]]; then - case "$GATEWAY_RPS" in - 1000) kubectl_cmd apply -f "$LOADTEST_DIR/gateway-ratelimit-1000-configmap.yaml" ;; - 5000) kubectl_cmd apply -f "$LOADTEST_DIR/gateway-ratelimit-5000-configmap.yaml" ;; - *) echo "unsupported GATEWAY_RPS=$GATEWAY_RPS; expected 1000 or 5000" >&2; exit 2 ;; - esac - kubectl_cmd rollout restart deployment/latinum-mcp-gateway-ratelimit -n "$GATEWAY_NAMESPACE" - kubectl_cmd rollout status deployment/latinum-mcp-gateway-ratelimit -n "$GATEWAY_NAMESPACE" --timeout=120s -fi - -if [[ "$APPLY_BACKEND_ENV" == "1" ]]; then - kubectl_cmd set env deployment "$BACKEND_DEPLOYMENT" -n "$MCP_NAMESPACE" \ - MQTT_CONNECT_TIMEOUT_S="$BACKEND_CONNECT_TIMEOUT_S" \ - MQTT_SUBSCRIBE_TIMEOUT_S="$BACKEND_SUBSCRIBE_TIMEOUT_S" \ - MCP_MQTT_COLLECT_MAX_CONCURRENT_PER_POD="$BACKEND_COLLECT_MAX_CONCURRENT" \ - MCP_MQTT_WATCH_START_MAX_CONCURRENT_PER_POD="$BACKEND_WATCH_START_MAX_CONCURRENT" -fi - -kubectl_cmd scale deployment "$BACKEND_DEPLOYMENT" -n "$MCP_NAMESPACE" --replicas="$BACKEND_REPLICAS" -kubectl_cmd rollout status deployment "$BACKEND_DEPLOYMENT" -n "$MCP_NAMESPACE" --timeout=120s -if [[ "$RESET_BACKEND" == "1" ]]; then - kubectl_cmd rollout restart deployment "$BACKEND_DEPLOYMENT" -n "$MCP_NAMESPACE" - kubectl_cmd rollout status deployment "$BACKEND_DEPLOYMENT" -n "$MCP_NAMESPACE" --timeout=120s -fi - -BACKEND_IMAGE_ID="$(backend_image_id)" -LOAD_IMAGE_ID="$(load_image_id)" - -capture_cluster_state "$BUNDLE_DIR/cluster-state-before.txt" -write_token_metadata "$BUNDLE_DIR/token-metadata.json" -cat > "$BUNDLE_DIR/images.txt" < "$MANIFEST_PATH" </dev/null || true)" - echo "poll $i state=$JOB_STATE" - case "$JOB_STATE" in - 1,*) break ;; - *,1|*,2|*,3) break ;; - esac - sleep 10 -done - -kubectl_cmd get job "$JOB_NAME" -n "$LOAD_NAMESPACE" -o wide > "$BUNDLE_DIR/job-status.txt" -kubectl_cmd logs "job/$JOB_NAME" -n "$LOAD_NAMESPACE" > "$BUNDLE_DIR/job.log" || true -capture_cluster_state "$BUNDLE_DIR/cluster-state-after.txt" - -BUNDLE_DIR="$BUNDLE_DIR" python3 <<'PY' -import csv, json, os, pathlib - -bundle = pathlib.Path(os.environ["BUNDLE_DIR"]) -text = (bundle / "job.log").read_text() -decoder = json.JSONDecoder() -reports = None -prefix = "" -for idx, ch in enumerate(text): - if ch not in "[{": - continue - try: - value, end = decoder.raw_decode(text[idx:]) - except json.JSONDecodeError: - continue - candidates = value if isinstance(value, list) else [value] - if candidates and isinstance(candidates[0], dict) and "started_at" in candidates[0]: - reports = candidates - prefix = text[:idx] - break -if reports is None: - raise SystemExit("could not find load report JSON in job.log") - -(bundle / "report.json").write_text(json.dumps(reports if len(reports) > 1 else reports[0], indent=2) + "\n") -(bundle / "report.txt").write_text(prefix.rstrip() + "\n") - -header = [ - "started_at","ended_at","experiment","experiment_detail","scenario","sessions", - "backend_replicas","sticky_session_check","rate_limit_per_second","gateway_rate_limit_rps", - "manifest_name","backend_image_id","load_image_id","experiment_config_hash", - "token_ttl_seconds_at_start","endpoint","topic","retained_topic","http_timeout_seconds", - "startup_ramp_seconds","poll_interval_seconds","subscribe_duration_seconds", - "max_messages","max_bytes","watch_ttl_seconds","backend_mqtt_connect_timeout_seconds", - "backend_mqtt_subscribe_timeout_seconds","backend_mqtt_collect_max_concurrent_per_pod", - "backend_mqtt_watch_start_max_concurrent_per_pod","duration_seconds","throughput_requests_per_second", - "success_rate_percent","total_requests","successes","failures","expected_tool_errors", - "initialized_sessions","started_watches","stopped_watches","session_not_found_errors", - "subscription_not_found_errors","operation","phase","operation_count","operation_successes", - "operation_failures","p50_ms","p95_ms","p99_ms","operation_errors","errors", -] -def fnum(value): - return f"{value:.3f}" if isinstance(value, float) else str(value) -with (bundle / "report.csv").open("w", newline="") as out: - writer = csv.writer(out) - writer.writerow(header) - for report in reports: - errors = ";".join(f"{k}={report.get('errors', {}).get(k)}" for k in sorted(report.get("errors", {}))) - for op in sorted(report["by_operation"]): - stats = report["by_operation"][op] - writer.writerow([ - report["started_at"], report["ended_at"], report.get("experiment", ""), - report.get("experiment_detail", ""), report["scenario"], report["sessions"], - report.get("backend_replicas", 0), report.get("sticky_session_check", ""), - report.get("rate_limit_per_second", 0), report.get("gateway_rate_limit_rps", 0), - report.get("manifest_name", ""), report.get("backend_image_id", ""), - report.get("load_image_id", ""), report.get("experiment_config_hash", ""), - report.get("token_ttl_seconds_at_start", 0), report["endpoint"], report["topic"], - report["retained_topic"], fnum(report["http_timeout_seconds"]), - fnum(report.get("startup_ramp_seconds", 0)), fnum(report.get("poll_interval_seconds", 0)), - report["subscribe_duration_seconds"], report["max_messages"], report["max_bytes"], - report["watch_ttl_seconds"], report.get("backend_mqtt_connect_timeout_seconds", 0), - report.get("backend_mqtt_subscribe_timeout_seconds", 0), - report.get("backend_mqtt_collect_max_concurrent_per_pod", 0), - report.get("backend_mqtt_watch_start_max_concurrent_per_pod", 0), - fnum(report["duration_seconds"]), - fnum(report["throughput_requests_per_second"]), fnum(report["success_rate_percent"]), - report["total_requests"], report["successes"], report["failures"], - report["expected_tool_errors"], report["initialized_sessions"], report["started_watches"], - report["stopped_watches"], report.get("session_not_found_errors", 0), - report.get("subscription_not_found_errors", 0), op, stats["phase"], stats["count"], - stats["successes"], stats["failures"], fnum(stats["p50_ms"]), - fnum(stats["p95_ms"]), fnum(stats["p99_ms"]), - ";".join(f"{k}={stats.get('errors', {}).get(k)}" for k in sorted(stats.get("errors", {}))), - errors, - ]) - -with (bundle / "summary.md").open("w") as out: - out.write("# MCP Load Test Summary\n\n") - out.write("| Experiment | Scenario | Sessions | Replicas | Ramp | Poll | Success | Failures | Started Watches | Stopped Watches | Session 404 | Subscription Missing |\n") - out.write("|---|---|---:|---:|---:|---:|---:|---:|---:|---:|---:|---:|\n") - for report in reports: - out.write( - f"| {report.get('experiment', '')} | {report['scenario']} | {report['sessions']} | " - f"{report.get('backend_replicas', 0)} | {report.get('startup_ramp_seconds', 0):.0f}s | " - f"{report.get('poll_interval_seconds', 0):.3f}s | {report['success_rate_percent']:.2f}% | " - f"{report['failures']} | {report['started_watches']} | {report['stopped_watches']} | " - f"{report.get('session_not_found_errors', 0)} | {report.get('subscription_not_found_errors', 0)} |\n" - ) - -with (bundle / "config.yaml").open("w") as out: - out.write("runs:\n") - for report in reports: - out.write(f" - experiment: {json.dumps(report.get('experiment', ''))}\n") - out.write(f" experiment_config_hash: {json.dumps(report.get('experiment_config_hash', ''))}\n") - out.write(f" manifest_name: {json.dumps(report.get('manifest_name', ''))}\n") - out.write(f" scenario: {json.dumps(report['scenario'])}\n") - out.write(f" sessions: {report['sessions']}\n") - out.write(f" backend_replicas: {report.get('backend_replicas', 0)}\n") - out.write(f" gateway_rate_limit_rps: {report.get('gateway_rate_limit_rps', 0)}\n") - out.write(f" client_rate_limit_per_second: {report.get('rate_limit_per_second', 0)}\n") - out.write(f" startup_ramp_seconds: {report.get('startup_ramp_seconds', 0)}\n") - out.write(f" poll_interval_seconds: {report.get('poll_interval_seconds', 0)}\n") - out.write(f" token_ttl_seconds_at_start: {report.get('token_ttl_seconds_at_start', 0)}\n") - out.write(f" backend_mqtt_connect_timeout_seconds: {report.get('backend_mqtt_connect_timeout_seconds', 0)}\n") - out.write(f" backend_mqtt_subscribe_timeout_seconds: {report.get('backend_mqtt_subscribe_timeout_seconds', 0)}\n") - out.write(f" backend_mqtt_collect_max_concurrent_per_pod: {report.get('backend_mqtt_collect_max_concurrent_per_pod', 0)}\n") - out.write(f" backend_mqtt_watch_start_max_concurrent_per_pod: {report.get('backend_mqtt_watch_start_max_concurrent_per_pod', 0)}\n") -PY - -echo "bundle written: $BUNDLE_DIR" -echo "$BUNDLE_DIR" > "$REPORT_ROOT/latest-bundle.txt" - -if [[ "$STRICT_JOB_SUCCESS" == "1" && "$JOB_STATE" != 1,* ]]; then - exit 1 -fi From c0e70dda7747e8ba34b98caa2ae47f45e6af54e5 Mon Sep 17 00:00:00 2001 From: Daniyal Rana Date: Wed, 24 Jun 2026 13:26:05 -0500 Subject: [PATCH 22/27] docs(mcp): align architecture notes with code Signed-off-by: Daniyal Rana --- mcp/dsx-exchange-mcp/Architecture.md | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/mcp/dsx-exchange-mcp/Architecture.md b/mcp/dsx-exchange-mcp/Architecture.md index fb41b17..e45b46f 100644 --- a/mcp/dsx-exchange-mcp/Architecture.md +++ b/mcp/dsx-exchange-mcp/Architecture.md @@ -401,7 +401,7 @@ In `jwt_passthrough` mode, `Collect` requires a bearer token: ```go if strings.TrimSpace(bearer) == "" { - return CollectResult{}, &BusError{Code: ErrMissingBearer, Message: "missing caller bearer token"} + return CollectResult{}, &BusError{Code: CodeMissingBearer, Message: "missing Authorization bearer for jwt_passthrough MQTT auth mode"} } ``` @@ -431,9 +431,8 @@ The collection loop stops for bounded reasons: | `max_messages` | Hit requested or configured message count. | | `max_duration` | Hit requested or configured duration. | | `retained_idle` | Retained-read mode saw no more retained messages for the idle window. | -| `max_result_bytes` | Payload would exceed configured response size. | -| `client_cancelled` | Request context was cancelled. | -| `completed` | Normal completion path. | +| `result_too_large` | Payload would exceed configured response size. | +| `caller_cancelled` | Request context was cancelled. | Payload conversion is also handled here. UTF-8 payloads are returned as strings; @@ -442,7 +441,7 @@ non-UTF-8 payloads are base64 encoded: ```go if utf8.Valid(payload) { msg.Payload = string(payload) - msg.PayloadEncoding = "utf-8" + msg.PayloadEncoding = "utf8" } else { msg.Payload = base64.StdEncoding.EncodeToString(payload) msg.PayloadEncoding = "base64" From a57241bd65ea36d0fff6baea8e73aaa6b08ece85 Mon Sep 17 00:00:00 2001 From: Daniyal Rana Date: Thu, 25 Jun 2026 15:33:47 -0500 Subject: [PATCH 23/27] build(mcp): derive embedded schemas from root specs Signed-off-by: Daniyal Rana --- .github/workflows/ci.yml | 16 +- .../regenerate-third-party-licenses.sh | 1 + mcp/dsx-exchange-mcp/.dockerignore | 20 - mcp/dsx-exchange-mcp/.gitignore | 4 + mcp/dsx-exchange-mcp/Architecture.md | 21 +- mcp/dsx-exchange-mcp/Dockerfile | 7 +- mcp/dsx-exchange-mcp/Dockerfile.dockerignore | 21 + mcp/dsx-exchange-mcp/Makefile | 25 +- mcp/dsx-exchange-mcp/README.md | 36 +- mcp/dsx-exchange-mcp/schemas/.gitignore | 10 - mcp/dsx-exchange-mcp/schemas/README.md | 30 - .../schemas/asyncapi/bms/bms.yaml | 7831 ----------------- .../asyncapi/launch-layer/notifications.yaml | 2 - .../mission-control/leak-response.yaml | 2 - .../asyncapi/monitoring/break-fix.yaml | 2 - .../schemas/asyncapi/nico/nico.yaml | 1796 ---- .../schemas/asyncapi/nke/node-readiness.yaml | 2 - .../power-management/power-management.yaml | 825 -- .../asyncapi/spiffe-exchange/pub-keysets.yaml | 117 - .../asyncapi/tenant/scheduler-events.yaml | 2 - .../schemas/cloud-events-example.yaml | 107 - mcp/dsx-exchange-mcp/skaffold.yaml | 4 +- 22 files changed, 97 insertions(+), 10784 deletions(-) delete mode 100644 mcp/dsx-exchange-mcp/.dockerignore create mode 100644 mcp/dsx-exchange-mcp/Dockerfile.dockerignore delete mode 100644 mcp/dsx-exchange-mcp/schemas/.gitignore delete mode 100644 mcp/dsx-exchange-mcp/schemas/README.md delete mode 100644 mcp/dsx-exchange-mcp/schemas/asyncapi/bms/bms.yaml delete mode 100644 mcp/dsx-exchange-mcp/schemas/asyncapi/launch-layer/notifications.yaml delete mode 100644 mcp/dsx-exchange-mcp/schemas/asyncapi/mission-control/leak-response.yaml delete mode 100644 mcp/dsx-exchange-mcp/schemas/asyncapi/monitoring/break-fix.yaml delete mode 100644 mcp/dsx-exchange-mcp/schemas/asyncapi/nico/nico.yaml delete mode 100644 mcp/dsx-exchange-mcp/schemas/asyncapi/nke/node-readiness.yaml delete mode 100644 mcp/dsx-exchange-mcp/schemas/asyncapi/power-management/power-management.yaml delete mode 100644 mcp/dsx-exchange-mcp/schemas/asyncapi/spiffe-exchange/pub-keysets.yaml delete mode 100644 mcp/dsx-exchange-mcp/schemas/asyncapi/tenant/scheduler-events.yaml delete mode 100644 mcp/dsx-exchange-mcp/schemas/cloud-events-example.yaml diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 071fce6..a6249b8 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -63,6 +63,8 @@ jobs: runs-on: linux-amd64-cpu4 steps: - uses: actions/checkout@v4 + - name: Generate MCP embedded schemas + run: make -C mcp/dsx-exchange-mcp sync-specs - uses: NVIDIA/dsx-github-actions/.github/actions/go-lint@07b465c2147fcbda4836cb869263577ea4719273 # v1.16.1 with: go-version: "1.25.5" @@ -115,6 +117,8 @@ jobs: runs-on: linux-amd64-cpu4 steps: - uses: actions/checkout@v4 + - name: Generate MCP embedded schemas + run: make -C mcp/dsx-exchange-mcp sync-specs - uses: NVIDIA/dsx-github-actions/.github/actions/go-test@07b465c2147fcbda4836cb869263577ea4719273 # v1.16.1 with: go-version: "1.25.5" @@ -128,10 +132,8 @@ jobs: runs-on: linux-amd64-cpu4 steps: - uses: actions/checkout@v4 - - name: Refresh MCP embedded schemas - run: make -C mcp/dsx-exchange-mcp sync-specs - - name: Verify MCP embedded schemas are current - run: git diff --exit-code -- mcp/dsx-exchange-mcp/schemas + - name: Verify MCP embedded schemas can be generated + run: make -C mcp/dsx-exchange-mcp verify-specs third-party-licenses: name: Third-Party Licenses @@ -178,7 +180,7 @@ jobs: with: image: dsx-exchange-mcp tags: ci-validation - context: mcp/dsx-exchange-mcp + context: . dockerfile: mcp/dsx-exchange-mcp/Dockerfile platforms: linux/amd64 push: "false" @@ -197,6 +199,8 @@ jobs: with: go-version: "1.25.5" cache: false + - name: Generate MCP embedded schemas + run: make -C mcp/dsx-exchange-mcp sync-specs - uses: NVIDIA/dsx-github-actions/.github/actions/codeql-scan@07b465c2147fcbda4836cb869263577ea4719273 # v1.16.1 with: languages: go @@ -247,7 +251,7 @@ jobs: with: image: dsx-exchange-mcp tags: scan-validation - context: mcp/dsx-exchange-mcp + context: . dockerfile: mcp/dsx-exchange-mcp/Dockerfile platforms: linux/amd64 push: "false" diff --git a/auth-callout/scripts/regenerate-third-party-licenses.sh b/auth-callout/scripts/regenerate-third-party-licenses.sh index fc6df6f..0e2637d 100755 --- a/auth-callout/scripts/regenerate-third-party-licenses.sh +++ b/auth-callout/scripts/regenerate-third-party-licenses.sh @@ -91,6 +91,7 @@ report_module() { report_module "$auth_dir" "-mod=vendor" report_module "$repo_dir/local/mqtt-client" "" report_module "$repo_dir/local/mqttbs" "" +make -C "$repo_dir/mcp/dsx-exchange-mcp" sync-specs >/dev/null report_module "$repo_dir/mcp/dsx-exchange-mcp" "-mod=vendor" if [[ -n "${DSX_LICENSE_VERBOSE:-}" && -s "$warnings" ]]; then diff --git a/mcp/dsx-exchange-mcp/.dockerignore b/mcp/dsx-exchange-mcp/.dockerignore deleted file mode 100644 index ab9dc56..0000000 --- a/mcp/dsx-exchange-mcp/.dockerignore +++ /dev/null @@ -1,20 +0,0 @@ -# Copyright 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. -# SPDX-License-Identifier: Apache-2.0 - -# Local build and test artifacts. -bin/ -.gocache/ -coverage.out -*.test -*.prof - -# Local OS/editor state. -.DS_Store -.idea/ -.vscode/ - -# Local-only MCP validation helpers that are not part of the released server -# image. The server binary still embeds schemas from ./schemas at build time. -cmd/dsx-exchange-token-proxy/ -deploy/local-check/ -docs/ diff --git a/mcp/dsx-exchange-mcp/.gitignore b/mcp/dsx-exchange-mcp/.gitignore index bf44bec..fa1181b 100644 --- a/mcp/dsx-exchange-mcp/.gitignore +++ b/mcp/dsx-exchange-mcp/.gitignore @@ -8,6 +8,10 @@ .claude/ context/ /schema/ +/schemas/.gitignore +/schemas/README.md +/schemas/cloud-events-example.yaml +/schemas/asyncapi/ /internal/specs/data/* !/internal/specs/data/.gitkeep diff --git a/mcp/dsx-exchange-mcp/Architecture.md b/mcp/dsx-exchange-mcp/Architecture.md index e45b46f..c4a8af1 100644 --- a/mcp/dsx-exchange-mcp/Architecture.md +++ b/mcp/dsx-exchange-mcp/Architecture.md @@ -135,7 +135,7 @@ accepts the same `/mcp` requests and reads the same headers. See | `internal/server/resources.go` | Defines MCP resources backed by embedded DSX specs. | | `internal/specs/specs.go` | Exposes raw spec resources from the embedded `schemas/` tree. | | `internal/schemaindex/index.go` | Parses AsyncAPI channel/message/operation primitives into a topic catalogue for schema exploration tools. | -| `schemas/` | Generated copy of the monorepo root `schemas/`, embedded into the binary by `schemas/embed.go`. | +| `schemas/` | Embed package plus ignored schema files generated from the monorepo root `schemas/` before compile time. | | `internal/mqttbus/client.go` | MQTT/NATS client logic: connect, subscribe, collect messages, classify broker errors. | | `internal/auth/caller.go` | Pulls caller bearer and optional identity headers from the HTTP request into Go context. | | `deploy/helm/dsx-exchange-mcp/templates/deployment.yaml` | Kubernetes Deployment: env vars, probes, security context, runtime class. | @@ -368,14 +368,17 @@ mcp.AddResource(srv, &mcp.Resource{ }, readSpec(domain, uri)) ``` -The embedded specs come from the repository-root `schemas/` package: +The embedded specs come from generated files under the module-local +`schemas/` package: ```go //go:embed README.md cloud-events-example.yaml asyncapi/*/*.yaml var FS embed.FS ``` -`make sync-specs` refreshes those files from the monorepo root `schemas/`: +Go `embed` cannot read files outside its package tree, so `make sync-specs` +refreshes ignored module-local files from the monorepo root `schemas/` before +build, test, lint, and run targets compile the module: ```make sync-specs: @@ -571,16 +574,19 @@ broker decision, result size, and error code. Common Make targets: ```make -build: +build: sync-specs go build ./cmd/dsx-exchange-mcp -run: sync-specs build +run: build go run ./cmd/dsx-exchange-mcp -test: +test: sync-specs go test ./... ``` +Raw `go build`, `go test`, or `go vet` from a clean checkout are unsupported +until `make sync-specs` has populated the ignored generated schema files. + Direct local path: ```text @@ -643,7 +649,8 @@ message conversion, and broker error classification. ### Add or change embedded specs -Start with: +Update the repository root `schemas/` tree. Do not commit the generated MCP +schema copy. To inspect the resulting embedded inputs locally, run: ```text make sync-specs diff --git a/mcp/dsx-exchange-mcp/Dockerfile b/mcp/dsx-exchange-mcp/Dockerfile index 91ff44f..ea4a5e5 100644 --- a/mcp/dsx-exchange-mcp/Dockerfile +++ b/mcp/dsx-exchange-mcp/Dockerfile @@ -12,7 +12,12 @@ ARG LABEL_VERSION=dev FROM ${BUILDER_IMG}:${BUILDER_TAG} AS build WORKDIR /src -COPY . . +COPY mcp/dsx-exchange-mcp/go.mod mcp/dsx-exchange-mcp/go.sum ./ +COPY mcp/dsx-exchange-mcp/vendor ./vendor +COPY mcp/dsx-exchange-mcp/cmd ./cmd +COPY mcp/dsx-exchange-mcp/internal ./internal +COPY mcp/dsx-exchange-mcp/schemas/embed.go ./schemas/embed.go +COPY schemas/. ./schemas/ RUN --mount=type=cache,target=/go/pkg/mod \ --mount=type=cache,target=/root/.cache/go-build \ CGO_ENABLED=0 GOOS=linux go build -trimpath -ldflags="-s -w" -mod=vendor -o /out/dsx-exchange-mcp ./cmd/dsx-exchange-mcp diff --git a/mcp/dsx-exchange-mcp/Dockerfile.dockerignore b/mcp/dsx-exchange-mcp/Dockerfile.dockerignore new file mode 100644 index 0000000..586c393 --- /dev/null +++ b/mcp/dsx-exchange-mcp/Dockerfile.dockerignore @@ -0,0 +1,21 @@ +# Copyright 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +# This Dockerfile builds from the repository root context so it can copy both +# the MCP module and the root schemas/ source of truth. +** +!mcp/ +!mcp/dsx-exchange-mcp/ +!mcp/dsx-exchange-mcp/Dockerfile +!mcp/dsx-exchange-mcp/go.mod +!mcp/dsx-exchange-mcp/go.sum +!mcp/dsx-exchange-mcp/vendor/ +!mcp/dsx-exchange-mcp/vendor/** +!mcp/dsx-exchange-mcp/cmd/ +!mcp/dsx-exchange-mcp/cmd/** +!mcp/dsx-exchange-mcp/internal/ +!mcp/dsx-exchange-mcp/internal/** +!mcp/dsx-exchange-mcp/schemas/ +!mcp/dsx-exchange-mcp/schemas/embed.go +!schemas/ +!schemas/** diff --git a/mcp/dsx-exchange-mcp/Makefile b/mcp/dsx-exchange-mcp/Makefile index c24a2a5..eb56f7b 100644 --- a/mcp/dsx-exchange-mcp/Makefile +++ b/mcp/dsx-exchange-mcp/Makefile @@ -18,22 +18,22 @@ MCP_LOCAL_PORT ?= 18080 help: @printf 'DSX Exchange MCP targets:\n' - @printf ' build build bin/%s\n' "$(BINARY)" + @printf ' build sync specs and build bin/%s\n' "$(BINARY)" @printf ' run sync specs, build, and run the MCP server\n' - @printf ' test run Go tests with $(GOFLAGS)\n' - @printf ' lint run go vet\n' + @printf ' test sync specs and run Go tests with $(GOFLAGS)\n' + @printf ' lint sync specs and run go vet\n' @printf ' sync-specs copy schemas from $(SCHEMA_SRC) into ./schemas\n' - @printf ' verify-specs sync specs and fail if ./schemas changes\n' + @printf ' verify-specs sync specs and verify schema inputs exist\n' @printf ' image build $(IMAGE_REPOSITORY):$(IMAGE_TAG)\n' @printf ' skaffold-run-kind deploy the MCP backend to local Kind\n' @printf ' port-forward-kind expose the Kind service on $(MCP_LOCAL_PORT)\n' @printf ' clean remove local build outputs\n' -build: +build: sync-specs mkdir -p bin go build $(GOFLAGS) -o bin/$(BINARY) ./cmd/$(BINARY) -run: sync-specs build +run: build ./bin/$(BINARY) skaffold-run-kind: @@ -42,7 +42,7 @@ skaffold-run-kind: port-forward-kind: "$(KUBECTL)" --context "$(KIND_CONTEXT)" -n "$(MCP_NAMESPACE)" port-forward "svc/$(MCP_RELEASE)" "$(MCP_LOCAL_PORT):8080" -test: +test: sync-specs go test $(GOFLAGS) ./... tidy: @@ -51,7 +51,7 @@ tidy: vendor: tidy go mod vendor -lint: +lint: sync-specs go vet $(GOFLAGS) $$(go list $(GOFLAGS) ./...) sync-specs: @@ -61,10 +61,13 @@ sync-specs: cp -R $(SCHEMA_SRC)/. schemas/ verify-specs: sync-specs - git diff --exit-code -- schemas + @test -f schemas/README.md + @test -f schemas/cloud-events-example.yaml + @test -d schemas/asyncapi + @find schemas/asyncapi -name '*.yaml' -print -quit | grep -q . image: - docker build -t $(IMAGE_REPOSITORY):$(IMAGE_TAG) . + docker build -f Dockerfile -t $(IMAGE_REPOSITORY):$(IMAGE_TAG) ../.. clean: - rm -rf bin + rm -rf bin schemas/asyncapi schemas/cloud-events-example.yaml schemas/README.md schemas/.gitignore diff --git a/mcp/dsx-exchange-mcp/README.md b/mcp/dsx-exchange-mcp/README.md index 8ab6989..63c2b39 100644 --- a/mcp/dsx-exchange-mcp/README.md +++ b/mcp/dsx-exchange-mcp/README.md @@ -54,7 +54,7 @@ auth-callout remains the source of truth for JWT validation and topic ACLs. | |-- schemaindex/ parsed AsyncAPI topic catalogue | `-- mqttbus/ MQTT client wrapper |-- deploy/helm/ Helm chart -`-- schemas/ embedded copy of the repository root schemas +`-- schemas/ embed package plus generated schema inputs ``` For the full server design, schema indexing behavior, authentication flow, and @@ -66,12 +66,16 @@ Fast local process path: ```sh cd mcp/dsx-exchange-mcp -make sync-specs make test make build make run ``` +The Make targets above run `sync-specs` before compiling. Raw `go build`, +`go test`, or `go vet` from a clean checkout are not supported until +`make sync-specs` has populated `schemas/` from the repository root schema +tree. + Configure an MCP client with `http://127.0.0.1:8080/mcp`. Schema resources and schema discovery tools work without a broker. MQTT-backed tools also need `NATS_URL` to point at a reachable broker and, in `jwt_passthrough` mode, a @@ -83,6 +87,10 @@ Build the local development image: make image ``` +The image build uses the repository root as its Docker context, then copies +root `schemas/` directly into the build stage beside `schemas/embed.go`. It +does not require committing or pre-syncing the generated MCP schema files. + Deploy the local Event Bus stack and MCP backend with the repository Skaffold flow: @@ -108,9 +116,12 @@ pod is installed in `kind-csc`, namespace `mcp-backends`, and uses `MCP_MQTT_AUTH_MODE=noauth` with the local Event Bus MQTT endpoint from `values.kind.yaml`. -Run `make sync-specs` before building the server binary or image when the -repository root `schemas/` tree changes. Override the source with -`SCHEMA_SRC=/path/to/schemas make sync-specs`. +Root `schemas/` is the source of truth. For local Go commands, `make sync-specs` +copies that tree into the MCP module-local `schemas/` directory so +`schemas/embed.go` can embed it at compile time. The copied files are generated +and ignored by Git. Docker and Skaffold image builds instead copy root +`schemas/` directly from the repository root build context. Override the local +copy source with `SCHEMA_SRC=/path/to/schemas make sync-specs`. ## Environment @@ -148,14 +159,17 @@ username or password. ## Specs -Specs are pinned at build time. `make sync-specs` copies the repository root -schema tree into `schemas/`, and `schemas/embed.go` bakes it into the binary. -The image uses the already-synced `./schemas` tree and does not fetch schemas -at runtime. +Specs are pinned at build time. Local Go build/test/lint targets run +`sync-specs` first, which copies the repository root schema tree into +module-local `schemas/`, and `schemas/embed.go` bakes those generated files into +the binary. Docker and Skaffold image builds copy root `schemas/` directly into +the build stage before compiling. The runtime image contains the compiled binary +only and does not fetch schemas at runtime. Empty domain stubs are filtered out at startup so they do not surface as MCP -resources or schema tool matches. To update specs, re-run `make sync-specs` -against a refreshed schema checkout and cut a new image. +resources or schema tool matches. To update specs, edit root `schemas/`. The +next local Go target regenerates the ignored module-local copy, and the next +Docker/Skaffold image build picks up root `schemas/` directly. ## Setup Checklist diff --git a/mcp/dsx-exchange-mcp/schemas/.gitignore b/mcp/dsx-exchange-mcp/schemas/.gitignore deleted file mode 100644 index 0daba5d..0000000 --- a/mcp/dsx-exchange-mcp/schemas/.gitignore +++ /dev/null @@ -1,10 +0,0 @@ -# IDE -.idea/ -.vscode/ -*.swp -*.swo - -# Output files -dist/ - -.DS_Store diff --git a/mcp/dsx-exchange-mcp/schemas/README.md b/mcp/dsx-exchange-mcp/schemas/README.md deleted file mode 100644 index 50daf14..0000000 --- a/mcp/dsx-exchange-mcp/schemas/README.md +++ /dev/null @@ -1,30 +0,0 @@ -# DSX Exchange Schemas - -The DSX Event Bus itself is schema agnostic. Brokers relay subjects, and enforce prefix rules, and enforce ACLs. -Clients participating in the DSX Exchange program must publish a formal [AsyncAPI](https://asyncapi.com/) definition here covering every exposed subject and payload so downstream consumers can rely on consistent contracts and documentation. - -AsyncAPI is our chosen schema format. AsyncAPI is a Linux Foundation project analogous to OpenAPI for async systems. The specification natively models MQTT servers and channels (topics) plus publish/subscribe operations, messages, and security traits, which is sufficient to describe our MQTT endpoints. - -The schema's purpose is to expose clear, human-readable documentation for consumers. It does not drive routing, validation, or otherwise alter broker behaviour. Teams may auto-generate SDKs and diffs from those documents, but any such tooling sits outside of this repository. [Modelina](https://www.asyncapi.com/tools/modelina) may be used for model generation. Full client generation is somewhat lacking. - -The schema documentation is published at [docs.nvidia.com/dsx-exchange/schema](https://docs.nvidia.com/dsx-exchange/schema). - -## Cloud Events - -Clients that elect to emit CloudEvents should follow the official MQTT Protocol Binding so metadata (type, source, id, datacontenttype) is encoded consistently. Since DSX standardizes on MQTT 3.1.1, publishers use the structured mode defined by the binding. - -Adopting CloudEvents remains optional. The binding is simply the formally supported way to represent CloudEvents over MQTT. An [example is given here](cloud-events-example.yaml). - -## Repository Structure - -Each logical component is given a directory with a single YAML specification file in [/schemas/asyncapi](/schemas/asyncapi/). Each component's team is responsible for updating and reviewing their own schema through the standard GitHub pull request workflow. - -## Running Checks Locally - -Run the repository checks before opening a pull request: - -```bash -make check-license-headers -``` - -See `make help` for additional targets. diff --git a/mcp/dsx-exchange-mcp/schemas/asyncapi/bms/bms.yaml b/mcp/dsx-exchange-mcp/schemas/asyncapi/bms/bms.yaml deleted file mode 100644 index 73168d3..0000000 --- a/mcp/dsx-exchange-mcp/schemas/asyncapi/bms/bms.yaml +++ /dev/null @@ -1,7831 +0,0 @@ -# Copyright 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. -# SPDX-License-Identifier: Apache-2.0 - -asyncapi: 3.1.0 - -info: - title: BMS Event Bus - version: 1.0.0 - description: | - Telemetry and control event catalog for the Building Management System (BMS) - over MQTT. Provides real-time point values and point metadata for all supported - object and point types. - - ## How to use this spec - - Each monitored point follows a **Value / Metadata** pattern: - - - **Value** messages carry the live reading (`value`, `timestamp`, `quality`). - - Subscribe to a value channel to receive real-time telemetry. Values are published whenever they change and republished every 100 seconds when they do not change. - - - **Metadata** messages describe the point (units, identifiers, relationships). - - **Always receive metadata before interpreting values.** Metadata is retained and published once at startup. It is subsequently published every 100 seconds. - - ## Topic structure - - | Publisher | Topic Type | Pattern | - | :---------- | :--------- | :--------------------------------------------------------------- | - | BMS | Value | `BMS/v1/PUB/Value/{objectType}/{pointType}/{tagPath}` | - | BMS | Metadata | `BMS/v1/PUB/Metadata/{objectType}/{pointType}/{tagPath}` | - | Integration | Value | `BMS/v1/{integration}/Value/{objectType}/{pointType}/{tagPath}` | - - The `{tagPath}` is a vendor-defined hierarchical path and may contain multiple `/` segments. Each `{tagPath}` must be unique for each point and is usually derived from the BMS system based on the BMS point name. - - Use `#` to subscribe to all topics under a given hierarchy (multi-level wildcard). - - Use `+` to match exactly one topic level (single-level wildcard). - - ## Publisher rules - - - **BMS** publishes all metadata — including for points whose values are written by integrations. - - - **BMS** publishes its own point values on `BMS/v1/PUB/Value/...`. - - - **Integrations** are any system (MQTT Client) external to the BMS. Whenever they need to directly send messages to the BMS, they publish values on `BMS/v1/{integration}/Value/...`. Integrations do **not** publish metadata. - - - ## Integration publishing contract - - > **This rule applies globally to every integration-published point across all - > objectTypes.** - - BMS publishes metadata for every monitored point — including points whose _values_ are written by external integrations. For such points the metadata payload contains an `integration` field identifying which integration owns that point's value. - - | Metadata field | Type | Meaning | - | :------------- | :--- | :------ | - | `integration` | string | Identifier of the integration that must publish the value for this point | - - ### Topic derivation rule - - The value topic is derived **methodically** from the respective metadata topic (published by BMS) that carried the `integration` field. DSX Exchange Access Control Lists will typically be created so that MQTT Clients are provided access to publish and subscribe to specific namespaces that align with the `[Publisher]`: - - BMS/v1/**[Publisher]/[TopicType]**/{objectType}/{pointType}/{tagPath} - - | Segment | Metadata topic | Value topic | - | :------ | :------------- | :---------- | - | Publisher | `PUB` | value of `integration` field in metadata | - | TopicType | `Metadata` | `Value` | - | Remainder | `{objectType}/{pointType}/{tagPath}` | `{objectType}/{pointType}/{tagPath}` | - - In other words, the Publisher integration will derive the following value topic to publish based on the corresponding BMS metadata: - - ```text - BMS/v1/PUB/Metadata/{objectType}/{pointType}/{tagPath} - ↓ replace PUB → {integration}, Metadata → Value - BMS/v1/{integration}/Value/{objectType}/{pointType}/{tagPath} - ``` - - ### Contract - - When an integration receives a BMS metadata message and the `integration` field **matches its own identifier**, the integration **MUST**: - - 1. Note the full metadata topic it arrived on. - 2. Derive the value topic by substituting `PUB` → own identifier and `Metadata` → `Value`, keeping `{objectType}/{pointType}/{tagPath}` unchanged. - 3. Publish value messages to that derived topic. - - Integrations **MUST NOT**: - - - Publish values for points whose metadata `integration` field does not match their own identifier. - - - Publish metadata (BMS is the sole metadata publisher). - - ### Example flow - - ```text - BMS publishes metadata → - Topic: BMS/v1/PUB/Metadata/CDU/LiquidTemperatureSpRequest/site1/row3/cdu5 - Payload: { ..., "integration": "MEPAI", ... } - - Integration "MEPAI" receives the metadata, recognises its identifier, - derives its value topic → - BMS/v1/PUB/Metadata/CDU/LiquidTemperatureSpRequest/site1/row3/cdu5 - ↓ PUB→MEPAI, Metadata→Value - BMS/v1/MEPAI/Value/CDU/LiquidTemperatureSpRequest/site1/row3/cdu5 - ``` - - MEPAI publishes its setpoint reading to that derived topic. - - The same contract governs all other integration-published points, including `Rack/RackLeakDetectTray`, `Rack/RackLiquidIsolationRequest`, `Rack/RackElectricalIsolationRequest`, `System/HeartbeatTimestampIntegration`, `System/HeartbeatEchoIntegration`, `CDU/LiquidTemperatureSpRequest`, and any future integration-owned pointTypes. - - - ## Metadata types and concepts - - - **objectType**: Object Types are restricted to specific strings in accordance with this AsyncAPI. They typically represent BMS equipment or devices. - - - **System**: A System can be the BMS or an Integration to the BMS. Heartbeat points and system-to-system communication point types are typically defined inside of a System object type. System Heartbeat point types are expected to operate as follows. An integration may choose to use the Echo points or not. - - Naming convention: the `BMS`/`Integration` suffix indicates the publisher of the point. - - All four heartbeat pointTypes require `objectName` and `objectId` in metadata to identify which System the heartbeat belongs to. By convention, an integration's `objectId` matches the same string used as its `integration` metadata field on its other points (so MEPAI1's System object has `objectId: "MEPAI1"`). - - - **HeartbeatTimestampBms** — Publisher: BMS. The BMS publishes its own timestamp every 10 seconds. One instance globally. `objectId` identifies the BMS (e.g., `"BMS"`). `integration` field: not used. - - - **HeartbeatTimestampIntegration** — Publisher: Integration. Each integration publishes its own timestamp every 10 seconds, one per integration. `objectId` identifies the integration publishing (e.g., `"MEPAI1"`). `integration` field: required (drives topic). - - - **HeartbeatEchoBms** — Publisher: BMS. The BMS reads each integration's `HeartbeatTimestampIntegration` value and re-publishes it on this point type, allowing each integration to confirm round-trip. One instance per connected integration. `objectId` identifies the integration whose timestamp is being echoed (e.g., `"MEPAI1"`). `integration` field: not used. - - - **HeartbeatEchoIntegration** — Publisher: Integration. Each integration reads the BMS's `HeartbeatTimestampBms` value and re-publishes it on this point type, allowing the BMS to confirm round-trip with that integration. One per connected integration. `objectId` identifies the BMS being echoed (e.g., `"BMS"`). `integration` field: required (drives topic). - - - **Rack**: A Rack is a special object type and has specific point types that can only be used with a Rack object type. Many integrations and MQTT Clients will only ingest data from the Rack object type. Other integrations will consider Rack data as the most important or interesting data in the AI Factory. Mechanical and Electrical design (and therefore BMS Data) at the rack is also more standardized than mechanical and electrical systems as you move out of the white space and to the gray space of the AI Factory. For these reasons, the point types associated with a Rack object type are generally more specific than any other object type, making ingesting and understanding rack data more straight-forward. - - - **PowerMeter**: A PowerMeter is a special object type and has specific point types that can only be used with a PowerMeter object type. PowerMeter point types contain the metadata needed to understand electrical power path. This data is used by integrations / MQTT Clients for power management strategies. - - - **Electrical Equipment**: This is a general category, and several object types exist in this AsyncAPI for electrical equipment. Electrical equipment point types typically use metadata to associate the object / equipment with a power meter to show where the equipment lands in the power path. - - - **Mechanical Equipment**: This is a general category, and several object types exist in this AsyncAPI for mechanical equipment. - - - **GenericObject**: This object type is reserved and should not be used unless no other object type is applicable. - - - **pointType**: Point Types are restricted to specific strings in accordance with this AsyncAPI. Point types are also restricted to specific object types. Some point types apply to multiple object types while others are restricted to specific object types. - - - **engUnit**: Engineering Units describe the units of measure for the point / topic. If engineering units are used, then state text does not apply. - - - **stateText**: State Text describes what a binary or integer value means. Each binary or integer value will have a state text identified. If state text is used, then engineering units do not apply. - - - **rackLocationName**: This is the human readable name given to a rack location. - - - **rackLocationId**: This is the unique identifier for a rack location. It can be the same as the rackLocationName if human readable and unique. This must allow the IT side integrations and OT side BMS to associate a point with the same physical rack (same identifier on both systems). - - - **objectName**: This is the human readable name given to the object. - - - **objectId**: This is the unique identifier for an object. It can be the same as the objectName if human readable and unique. - - - **associateId**: This will list the objectId of an associated object. The intent is for this object to be considered part of the other object, especially for `servesId` metadata. Commonly used to prevent parallel power paths (parallel "serves" relationships) when electrical equipment objects need to be associated with power meters. Also applicable to mechanical equipment object types to prevent liquid and air flow parallel paths where not intended. - - - **servesId**: This will list the objectId of the object that is served by this object. Creates a one-way relationship. Typically used to indicate electrical power path or liquid/air fluid flow path towards the rack. - - - **processArea**: This is used to provide more information on the point location or purpose. In general, process area metadata should be used, in conjunction with other metadata, to make points unique and allow integrations / MQTT Clients to clearly understand what a point represents in the AI factory. Example: for a CDU object type and LiquidTemperature point type, the process area metadata could include `"Secondary"` and `"Supply"`. This would make it clear that the temperature sensor was located on the secondary side of the CDU and on the supply line. - - - **phase**: This is used to identify which phase, of a 3-phase electrical system, the point is associated with. - - - **isSetpoint**: This can be set to `true` (unquoted, lowercase) to indicate that a point is a setpoint rather than a sensor reading. A setpoint is a target or ideal value that a system is trying to maintain a process variable at. - - - **scope**: Scope is used for System heartbeat points. If the BMS has multiple MQTT Clients connected to DSX Exchange, each could be publishing different data to the MQTT Broker. In this case each MQTT Client should have separate heartbeat points. Scope can be used in this case to identify what MQTT Topics / Namespace the heartbeat point is associated with — and thereby what topics are impacted when a heartbeat is lost. - - - **integration**: Integration metadata is used to indicate which integration a point is associated with. It also indicates the namespace `[Publisher]` an integration is required to write the corresponding value back to. - - -# ============================================================================= -# Servers -# ============================================================================= - -# servers: -# production: -# host: broker.example.com -# protocol: mqtt -# description: MQTT broker for BMS telemetry and control - - -# ============================================================================= -# Channels -# ============================================================================= - -channels: - - # --------------------------------------------------------------------------- - # Rack - # --------------------------------------------------------------------------- - - rackBmsValue: - address: 'BMS/v1/PUB/Value/Rack/{pointType}/{tagPath}' - description: | - Real-time values published by the BMS for all Rack monitoring points. - - **MQTT wildcard examples** - - - All rack values: `BMS/v1/PUB/Value/Rack/#` - parameters: - pointType: - enum: - - RackLiquidSupplyTemperature - - RackLiquidReturnTemperature - - RackLiquidFlow - - RackLiquidDifferentialPressure - - RackLiquidDifferentialPressureSp - - RackControlValvePosition - - RackPower - - RackLeakDetect - - RackLeakSensorFault - - RackLiquidIsolationStatus - - RackElectricalIsolationStatus - description: BMS-published Rack point type. - tagPath: - description: Vendor-defined hierarchical tag path. - messages: - valueMessage: - $ref: '#/components/messages/ValueMessage' - - rackMetadata: - address: 'BMS/v1/PUB/Metadata/Rack/{pointType}/{tagPath}' - description: | - BMS-published metadata for all Rack points. - Includes integration-owned points which carries the `integration` field. - - **MQTT wildcard examples** - - - All rack metadata: `BMS/v1/PUB/Metadata/Rack/#` - parameters: - pointType: - enum: - - RackLiquidSupplyTemperature - - RackLiquidReturnTemperature - - RackLiquidFlow - - RackLiquidDifferentialPressure - - RackLiquidDifferentialPressureSp - - RackControlValvePosition - - RackPower - - RackLeakDetect - - RackLeakSensorFault - - RackLeakDetectTray - - RackLiquidIsolationStatus - - RackElectricalIsolationStatus - - RackLiquidIsolationRequest - - RackElectricalIsolationRequest - description: Rack point type. - tagPath: - description: Must match the tagPath of the corresponding value topic exactly. - messages: - rackLiquidSupplyTemperature: - $ref: '#/components/messages/RackLiquidSupplyTemperatureMsg' - rackLiquidReturnTemperature: - $ref: '#/components/messages/RackLiquidReturnTemperatureMsg' - rackLiquidFlow: - $ref: '#/components/messages/RackLiquidFlowMsg' - rackLiquidDifferentialPressure: - $ref: '#/components/messages/RackLiquidDifferentialPressureMsg' - rackLiquidDifferentialPressureSp: - $ref: '#/components/messages/RackLiquidDifferentialPressureSpMsg' - rackControlValvePosition: - $ref: '#/components/messages/RackControlValvePositionMsg' - rackPower: - $ref: '#/components/messages/RackPowerMsg' - rackLeakDetect: - $ref: '#/components/messages/RackLeakDetectMsg' - rackLeakSensorFault: - $ref: '#/components/messages/RackLeakSensorFaultMsg' - rackLeakDetectTray: - $ref: '#/components/messages/RackLeakDetectTrayMsg' - rackLiquidIsolationStatus: - $ref: '#/components/messages/RackLiquidIsolationStatusMsg' - rackElectricalIsolationStatus: - $ref: '#/components/messages/RackElectricalIsolationStatusMsg' - rackLiquidIsolationRequest: - $ref: '#/components/messages/RackLiquidIsolationRequestMsg' - rackElectricalIsolationRequest: - $ref: '#/components/messages/RackElectricalIsolationRequestMsg' - - rackIntegrationValue: - address: 'BMS/v1/{integration}/Value/Rack/{pointType}/{tagPath}' - description: | - Values published by integrations for Rack control points. - Subscribe to `BMS/v1/PUB/Metadata/Rack/#` and publish only when that metadata payload includes an `integration` field whose value matches the publishing integration identifier. - That matching metadata payload indicates the integration can publish values to this topic. - - **MQTT wildcard examples** - - - All integration Rack values: `BMS/v1/+/Value/Rack/#` - parameters: - integration: - description: Integration identifier. - pointType: - enum: - - RackLeakDetectTray - - RackLiquidIsolationRequest - - RackElectricalIsolationRequest - description: Integration-published Rack point type. - tagPath: - description: Must match the tagPath from the corresponding BMS metadata exactly. - messages: - valueMessage: - $ref: '#/components/messages/ValueMessage' - - # --------------------------------------------------------------------------- - # PowerMeter - # --------------------------------------------------------------------------- - - powerMeterValue: - address: 'BMS/v1/PUB/Value/PowerMeter/{pointType}/{tagPath}' - description: | - Real-time values published by the BMS for all PowerMeter points. - - **MQTT wildcard examples** - - - All power meter values: `BMS/v1/PUB/Value/PowerMeter/#` - parameters: - pointType: - enum: - - Voltage - - PowerFactor - - Frequency - - ApparentPower - - ActivePower - - Current - - CurrentLimit - - PhaseCurrent - - GenericPoint - description: PowerMeter point type. - tagPath: - description: Vendor-defined hierarchical tag path. - messages: - valueMessage: - $ref: '#/components/messages/ValueMessage' - - powerMeterMetadata: - address: 'BMS/v1/PUB/Metadata/PowerMeter/{pointType}/{tagPath}' - description: | - BMS-published metadata for all PowerMeter points. - - **MQTT wildcard examples** - - - All power meter metadata: `BMS/v1/PUB/Metadata/PowerMeter/#` - parameters: - pointType: - enum: - - Voltage - - PowerFactor - - Frequency - - ApparentPower - - ActivePower - - Current - - CurrentLimit - - PhaseCurrent - - GenericPoint - description: PowerMeter point type. - tagPath: - description: Vendor-defined hierarchical tag path. - messages: - voltage: - $ref: '#/components/messages/PowerMeterVoltageMsg' - powerFactor: - $ref: '#/components/messages/PowerMeterPowerFactorMsg' - frequency: - $ref: '#/components/messages/PowerMeterFrequencyMsg' - apparentPower: - $ref: '#/components/messages/PowerMeterApparentPowerMsg' - activePower: - $ref: '#/components/messages/PowerMeterActivePowerMsg' - current: - $ref: '#/components/messages/PowerMeterCurrentMsg' - currentLimit: - $ref: '#/components/messages/PowerMeterCurrentLimitMsg' - genericPoint: - $ref: '#/components/messages/GenericPowerMeterPointMsg' - phaseCurrent: - $ref: '#/components/messages/PowerMeterPhaseCurrentMsg' - - powerMeterIntegrationValue: - address: 'BMS/v1/{integration}/Value/PowerMeter/{pointType}/{tagPath}' - description: | - Values published by integrations for PowerMeter control points. - Subscribe to `BMS/v1/PUB/Metadata/PowerMeter/#` and publish only when that metadata payload includes an `integration` field whose value matches the publishing integration identifier. - That matching metadata payload indicates the integration can publish values to this topic. - - **MQTT wildcard examples** - - - All integration PowerMeter values: `BMS/v1/+/Value/PowerMeter/#` - parameters: - integration: - description: Integration identifier. - pointType: - enum: - - GenericPoint - description: Integration-published PowerMeter point type. - tagPath: - description: Must match the tagPath from the corresponding BMS metadata exactly. - messages: - valueMessage: - $ref: '#/components/messages/ValueMessage' - - # --------------------------------------------------------------------------- - # BESS - # --------------------------------------------------------------------------- - - bessValue: - address: 'BMS/v1/PUB/Value/BESS/{pointType}/{tagPath}' - description: | - Real-time values published by the BMS for BESS points. - Extensible: additional vendor-specific pointTypes may be present. - - **MQTT wildcard examples** - - - All BESS values: `BMS/v1/PUB/Value/BESS/#` - parameters: - pointType: - enum: - - Status - - Available - - GenericPoint - description: BMS-published BESS point type. - tagPath: - description: Vendor-defined hierarchical tag path. - messages: - valueMessage: - $ref: '#/components/messages/ValueMessage' - - bessMetadata: - address: 'BMS/v1/PUB/Metadata/BESS/{pointType}/{tagPath}' - description: | - BMS-published metadata for BESS points. - - **MQTT wildcard examples** - - - All BESS metadata: `BMS/v1/PUB/Metadata/BESS/#` - parameters: - pointType: - enum: - - Status - - Available - - GenericPoint - description: BESS point type. - tagPath: - description: Vendor-defined hierarchical tag path. - messages: - status: - $ref: '#/components/messages/BESSStatusMsg' - genericPoint: - $ref: '#/components/messages/GenericEquipmentPointMsg' - available: - $ref: '#/components/messages/BESSAvailableMsg' - - bessIntegrationValue: - address: 'BMS/v1/{integration}/Value/BESS/{pointType}/{tagPath}' - description: | - Values published by integrations for BESS control points. - Subscribe to `BMS/v1/PUB/Metadata/BESS/#` and publish only when that metadata payload includes an `integration` field whose value matches the publishing integration identifier. - That matching metadata payload indicates the integration can publish values to this topic. - - **MQTT wildcard examples** - - - All integration BESS values: `BMS/v1/+/Value/BESS/#` - parameters: - integration: - description: Integration identifier. - pointType: - enum: - - GenericPoint - description: Integration-published BESS point type. - tagPath: - description: Must match the tagPath from the corresponding BMS metadata exactly. - messages: - valueMessage: - $ref: '#/components/messages/ValueMessage' - - # --------------------------------------------------------------------------- - # UPS - # --------------------------------------------------------------------------- - - upsValue: - address: 'BMS/v1/PUB/Value/UPS/{pointType}/{tagPath}' - description: | - Real-time values published by the BMS for UPS points. - Extensible: additional vendor-specific pointTypes may be present. - - **MQTT wildcard examples** - - - All UPS values: `BMS/v1/PUB/Value/UPS/#` - parameters: - pointType: - enum: - - Status - - Available - - GenericPoint - description: BMS-published UPS point type. - tagPath: - description: Vendor-defined hierarchical tag path. - messages: - valueMessage: - $ref: '#/components/messages/ValueMessage' - - upsMetadata: - address: 'BMS/v1/PUB/Metadata/UPS/{pointType}/{tagPath}' - description: | - BMS-published metadata for UPS points. - - **MQTT wildcard examples** - - - All UPS metadata: `BMS/v1/PUB/Metadata/UPS/#` - parameters: - pointType: - enum: - - Status - - Available - - GenericPoint - description: UPS point type. - tagPath: - description: Vendor-defined hierarchical tag path. - messages: - status: - $ref: '#/components/messages/UPSStatusMsg' - genericPoint: - $ref: '#/components/messages/GenericEquipmentPointMsg' - available: - $ref: '#/components/messages/UPSAvailableMsg' - - upsIntegrationValue: - address: 'BMS/v1/{integration}/Value/UPS/{pointType}/{tagPath}' - description: | - Values published by integrations for UPS control points. - Subscribe to `BMS/v1/PUB/Metadata/UPS/#` and publish only when that metadata payload includes an `integration` field whose value matches the publishing integration identifier. - That matching metadata payload indicates the integration can publish values to this topic. - - **MQTT wildcard examples** - - - All integration UPS values: `BMS/v1/+/Value/UPS/#` - parameters: - integration: - description: Integration identifier. - pointType: - enum: - - GenericPoint - description: Integration-published UPS point type. - tagPath: - description: Must match the tagPath from the corresponding BMS metadata exactly. - messages: - valueMessage: - $ref: '#/components/messages/ValueMessage' - - # --------------------------------------------------------------------------- - # ATS - # --------------------------------------------------------------------------- - - atsValue: - address: 'BMS/v1/PUB/Value/ATS/{pointType}/{tagPath}' - description: | - Real-time values published by the BMS for ATS points. - Extensible: additional vendor-specific pointTypes may be present. - - **MQTT wildcard examples** - - - All ATS values: `BMS/v1/PUB/Value/ATS/#` - parameters: - pointType: - enum: - - Status - - Available - - GenericPoint - description: BMS-published ATS point type. - tagPath: - description: Vendor-defined hierarchical tag path. - messages: - valueMessage: - $ref: '#/components/messages/ValueMessage' - - atsMetadata: - address: 'BMS/v1/PUB/Metadata/ATS/{pointType}/{tagPath}' - description: | - BMS-published metadata for ATS points. - - **MQTT wildcard examples** - - - All ATS metadata: `BMS/v1/PUB/Metadata/ATS/#` - parameters: - pointType: - enum: - - Status - - Available - - GenericPoint - description: ATS point type. - tagPath: - description: Vendor-defined hierarchical tag path. - messages: - status: - $ref: '#/components/messages/ATSStatusMsg' - genericPoint: - $ref: '#/components/messages/GenericEquipmentPointMsg' - available: - $ref: '#/components/messages/ATSAvailableMsg' - - atsIntegrationValue: - address: 'BMS/v1/{integration}/Value/ATS/{pointType}/{tagPath}' - description: | - Values published by integrations for ATS control points. - Subscribe to `BMS/v1/PUB/Metadata/ATS/#` and publish only when that metadata payload includes an `integration` field whose value matches the publishing integration identifier. - That matching metadata payload indicates the integration can publish values to this topic. - - **MQTT wildcard examples** - - - All integration ATS values: `BMS/v1/+/Value/ATS/#` - parameters: - integration: - description: Integration identifier. - pointType: - enum: - - GenericPoint - description: Integration-published ATS point type. - tagPath: - description: Must match the tagPath from the corresponding BMS metadata exactly. - messages: - valueMessage: - $ref: '#/components/messages/ValueMessage' - - # --------------------------------------------------------------------------- - # Generator - # --------------------------------------------------------------------------- - - generatorValue: - address: 'BMS/v1/PUB/Value/Generator/{pointType}/{tagPath}' - description: | - Real-time values published by the BMS for Generator points. - Extensible: additional vendor-specific pointTypes may be present. - - **MQTT wildcard examples** - - - All Generator values: `BMS/v1/PUB/Value/Generator/#` - parameters: - pointType: - enum: - - Status - - Available - - GenericPoint - description: BMS-published Generator point type. - tagPath: - description: Vendor-defined hierarchical tag path. - messages: - valueMessage: - $ref: '#/components/messages/ValueMessage' - - generatorMetadata: - address: 'BMS/v1/PUB/Metadata/Generator/{pointType}/{tagPath}' - description: | - BMS-published metadata for Generator points. - - **MQTT wildcard examples** - - - All Generator metadata: `BMS/v1/PUB/Metadata/Generator/#` - parameters: - pointType: - enum: - - Status - - Available - - GenericPoint - description: Generator point type. - tagPath: - description: Vendor-defined hierarchical tag path. - messages: - status: - $ref: '#/components/messages/GeneratorStatusMsg' - genericPoint: - $ref: '#/components/messages/GenericEquipmentPointMsg' - available: - $ref: '#/components/messages/GeneratorAvailableMsg' - - generatorIntegrationValue: - address: 'BMS/v1/{integration}/Value/Generator/{pointType}/{tagPath}' - description: | - Values published by integrations for Generator control points. - Subscribe to `BMS/v1/PUB/Metadata/Generator/#` and publish only when that metadata payload includes an `integration` field whose value matches the publishing integration identifier. - That matching metadata payload indicates the integration can publish values to this topic. - - **MQTT wildcard examples** - - - All integration Generator values: `BMS/v1/+/Value/Generator/#` - parameters: - integration: - description: Integration identifier. - pointType: - enum: - - GenericPoint - description: Integration-published Generator point type. - tagPath: - description: Must match the tagPath from the corresponding BMS metadata exactly. - messages: - valueMessage: - $ref: '#/components/messages/ValueMessage' - - # --------------------------------------------------------------------------- - # Shunt - # --------------------------------------------------------------------------- - - shuntValue: - address: 'BMS/v1/PUB/Value/Shunt/{pointType}/{tagPath}' - description: | - Real-time values published by the BMS for Shunt points. - - **MQTT wildcard examples** - - - All Shunt values: `BMS/v1/PUB/Value/Shunt/#` - parameters: - pointType: - enum: - - Status - - Available - - GenericPoint - description: BMS-published Shunt point type. - tagPath: - description: Vendor-defined hierarchical tag path. - messages: - valueMessage: - $ref: '#/components/messages/ValueMessage' - - shuntMetadata: - address: 'BMS/v1/PUB/Metadata/Shunt/{pointType}/{tagPath}' - description: | - BMS-published metadata for Shunt points. - - **MQTT wildcard examples** - - - All Shunt metadata: `BMS/v1/PUB/Metadata/Shunt/#` - parameters: - pointType: - enum: - - Status - - Available - - GenericPoint - description: Shunt point type. - tagPath: - description: Vendor-defined hierarchical tag path. - messages: - status: - $ref: '#/components/messages/ShuntStatusMsg' - genericPoint: - $ref: '#/components/messages/GenericEquipmentPointMsg' - available: - $ref: '#/components/messages/ShuntAvailableMsg' - - shuntIntegrationValue: - address: 'BMS/v1/{integration}/Value/Shunt/{pointType}/{tagPath}' - description: | - Values published by integrations for Shunt control points. - Subscribe to `BMS/v1/PUB/Metadata/Shunt/#` and publish only when that metadata payload includes an `integration` field whose value matches the publishing integration identifier. - That matching metadata payload indicates the integration can publish values to this topic. - - **MQTT wildcard examples** - - - All integration Shunt values: `BMS/v1/+/Value/Shunt/#` - parameters: - integration: - description: Integration identifier. - pointType: - enum: - - GenericPoint - description: Integration-published Shunt point type. - tagPath: - description: Must match the tagPath from the corresponding BMS metadata exactly. - messages: - valueMessage: - $ref: '#/components/messages/ValueMessage' - - # --------------------------------------------------------------------------- - # Breaker - # --------------------------------------------------------------------------- - - breakerValue: - address: 'BMS/v1/PUB/Value/Breaker/{pointType}/{tagPath}' - description: | - Real-time values published by the BMS for Breaker points. - - **MQTT wildcard examples** - - - All Breaker values: `BMS/v1/PUB/Value/Breaker/#` - parameters: - pointType: - enum: - - Status - - Available - - GenericPoint - description: BMS-published Breaker point type. - tagPath: - description: Vendor-defined hierarchical tag path. - messages: - valueMessage: - $ref: '#/components/messages/ValueMessage' - - breakerMetadata: - address: 'BMS/v1/PUB/Metadata/Breaker/{pointType}/{tagPath}' - description: | - BMS-published metadata for Breaker points. - - **MQTT wildcard examples** - - - All Breaker metadata: `BMS/v1/PUB/Metadata/Breaker/#` - parameters: - pointType: - enum: - - Status - - Available - - GenericPoint - description: Breaker point type. - tagPath: - description: Vendor-defined hierarchical tag path. - messages: - status: - $ref: '#/components/messages/BreakerStatusMsg' - genericPoint: - $ref: '#/components/messages/GenericEquipmentPointMsg' - available: - $ref: '#/components/messages/BreakerAvailableMsg' - - breakerIntegrationValue: - address: 'BMS/v1/{integration}/Value/Breaker/{pointType}/{tagPath}' - description: | - Values published by integrations for Breaker control points. - Subscribe to `BMS/v1/PUB/Metadata/Breaker/#` and publish only when that metadata payload includes an `integration` field whose value matches the publishing integration identifier. - That matching metadata payload indicates the integration can publish values to this topic. - - **MQTT wildcard examples** - - - All integration Breaker values: `BMS/v1/+/Value/Breaker/#` - parameters: - integration: - description: Integration identifier. - pointType: - enum: - - GenericPoint - description: Integration-published Breaker point type. - tagPath: - description: Must match the tagPath from the corresponding BMS metadata exactly. - messages: - valueMessage: - $ref: '#/components/messages/ValueMessage' - - # --------------------------------------------------------------------------- - # CDU - # --------------------------------------------------------------------------- - - cduValue: - address: 'BMS/v1/PUB/Value/CDU/{pointType}/{tagPath}' - description: | - Real-time values published by the BMS for CDU points. - - **MQTT wildcard examples** - - - All CDU values: `BMS/v1/PUB/Value/CDU/#` - parameters: - pointType: - enum: - - LiquidTemperature - - LiquidDifferentialPressure - - LiquidFlow - - LiquidPressure - - Status - - Available - - ValvePosition - - PumpSpeed - - FanSpeed - - DamperPosition - - AirTemperature - - AirDifferentialPressure - - AirRelativeHumidity - - AirFlow - - AirPressure - - LeakDetect - - GenericPoint - description: BMS-published CDU point type. - tagPath: - description: Vendor-defined hierarchical tag path. - messages: - valueMessage: - $ref: '#/components/messages/ValueMessage' - - cduMetadata: - address: 'BMS/v1/PUB/Metadata/CDU/{pointType}/{tagPath}' - description: | - BMS-published metadata for CDU points. - Includes integration-owned points which carries the `integration` field. - - **MQTT wildcard examples** - - - All CDU metadata: `BMS/v1/PUB/Metadata/CDU/#` - parameters: - pointType: - enum: - - LiquidTemperature - - LiquidDifferentialPressure - - LiquidFlow - - LiquidPressure - - Status - - Available - - ValvePosition - - PumpSpeed - - FanSpeed - - DamperPosition - - AirTemperature - - AirDifferentialPressure - - AirRelativeHumidity - - AirFlow - - AirPressure - - LeakDetect - - LiquidTemperatureSpRequest - - GenericPoint - description: CDU point type. - tagPath: - description: Vendor-defined hierarchical tag path. - messages: - liquidTemperature: - $ref: '#/components/messages/CDULiquidTemperatureMsg' - liquidDifferentialPressure: - $ref: '#/components/messages/CDULiquidDifferentialPressureMsg' - liquidFlow: - $ref: '#/components/messages/CDULiquidFlowMsg' - liquidPressure: - $ref: '#/components/messages/CDULiquidPressureMsg' - status: - $ref: '#/components/messages/CDUStatusMsg' - available: - $ref: '#/components/messages/CDUAvailableMsg' - valvePosition: - $ref: '#/components/messages/CDUValvePositionMsg' - pumpSpeed: - $ref: '#/components/messages/CDUPumpSpeedMsg' - fanSpeed: - $ref: '#/components/messages/CDUFanSpeedMsg' - damperPosition: - $ref: '#/components/messages/CDUDamperPositionMsg' - airTemperature: - $ref: '#/components/messages/CDUAirTemperatureMsg' - airDifferentialPressure: - $ref: '#/components/messages/CDUAirDifferentialPressureMsg' - airRelativeHumidity: - $ref: '#/components/messages/CDUAirRelativeHumidityMsg' - airFlow: - $ref: '#/components/messages/CDUAirFlowMsg' - airPressure: - $ref: '#/components/messages/CDUAirPressureMsg' - leakDetect: - $ref: '#/components/messages/CDULeakDetectMsg' - liquidTemperatureSpRequest: - $ref: '#/components/messages/LiquidTemperatureSpRequestMsg' - genericPoint: - $ref: '#/components/messages/GenericEquipmentPointMsg' - - cduIntegrationValue: - address: 'BMS/v1/{integration}/Value/CDU/{pointType}/{tagPath}' - description: | - Values published by integrations for CDU control points. - Subscribe to `BMS/v1/PUB/Metadata/CDU/#` and publish only when that metadata payload includes an `integration` field whose value matches the publishing integration identifier. - That matching metadata payload indicates the integration can publish values to this topic. - - **MQTT wildcard examples** - - - All integration CDU values: `BMS/v1/+/Value/CDU/#` - parameters: - integration: - description: Integration identifier. - pointType: - enum: - - LiquidTemperatureSpRequest - - GenericPoint - description: Integration-published CDU point type. - tagPath: - description: Must match the tagPath from the corresponding BMS metadata exactly. - messages: - valueMessage: - $ref: '#/components/messages/ValueMessage' - - # --------------------------------------------------------------------------- - # CoolingTower - # --------------------------------------------------------------------------- - - coolingTowerValue: - address: 'BMS/v1/PUB/Value/CoolingTower/{pointType}/{tagPath}' - description: | - Real-time values published by the BMS for CoolingTower points. - - **MQTT wildcard examples** - - - All CoolingTower values: `BMS/v1/PUB/Value/CoolingTower/#` - parameters: - pointType: - enum: - - LiquidTemperature - - LiquidDifferentialPressure - - LiquidFlow - - LiquidPressure - - Status - - Available - - ValvePosition - - PumpSpeed - - FanSpeed - - DamperPosition - - AirTemperature - - AirDifferentialPressure - - AirRelativeHumidity - - AirFlow - - AirPressure - - LeakDetect - - GenericPoint - description: BMS-published CoolingTower point type. - tagPath: - description: Vendor-defined hierarchical tag path. - messages: - valueMessage: - $ref: '#/components/messages/ValueMessage' - - coolingTowerMetadata: - address: 'BMS/v1/PUB/Metadata/CoolingTower/{pointType}/{tagPath}' - description: | - BMS-published metadata for CoolingTower points. - - **MQTT wildcard examples** - - - All CoolingTower metadata: `BMS/v1/PUB/Metadata/CoolingTower/#` - parameters: - pointType: - enum: - - LiquidTemperature - - LiquidDifferentialPressure - - LiquidFlow - - LiquidPressure - - Status - - Available - - ValvePosition - - PumpSpeed - - FanSpeed - - DamperPosition - - AirTemperature - - AirDifferentialPressure - - AirRelativeHumidity - - AirFlow - - AirPressure - - LeakDetect - - GenericPoint - description: CoolingTower point type. - tagPath: - description: Vendor-defined hierarchical tag path. - messages: - liquidTemperature: - $ref: '#/components/messages/CoolingTowerLiquidTemperatureMsg' - liquidDifferentialPressure: - $ref: '#/components/messages/CoolingTowerLiquidDifferentialPressureMsg' - liquidFlow: - $ref: '#/components/messages/CoolingTowerLiquidFlowMsg' - liquidPressure: - $ref: '#/components/messages/CoolingTowerLiquidPressureMsg' - status: - $ref: '#/components/messages/CoolingTowerStatusMsg' - available: - $ref: '#/components/messages/CoolingTowerAvailableMsg' - valvePosition: - $ref: '#/components/messages/CoolingTowerValvePositionMsg' - pumpSpeed: - $ref: '#/components/messages/CoolingTowerPumpSpeedMsg' - fanSpeed: - $ref: '#/components/messages/CoolingTowerFanSpeedMsg' - damperPosition: - $ref: '#/components/messages/CoolingTowerDamperPositionMsg' - airTemperature: - $ref: '#/components/messages/CoolingTowerAirTemperatureMsg' - airDifferentialPressure: - $ref: '#/components/messages/CoolingTowerAirDifferentialPressureMsg' - airRelativeHumidity: - $ref: '#/components/messages/CoolingTowerAirRelativeHumidityMsg' - airFlow: - $ref: '#/components/messages/CoolingTowerAirFlowMsg' - airPressure: - $ref: '#/components/messages/CoolingTowerAirPressureMsg' - leakDetect: - $ref: '#/components/messages/CoolingTowerLeakDetectMsg' - genericPoint: - $ref: '#/components/messages/GenericEquipmentPointMsg' - - coolingTowerIntegrationValue: - address: 'BMS/v1/{integration}/Value/CoolingTower/{pointType}/{tagPath}' - description: | - Values published by integrations for CoolingTower control points. - Subscribe to `BMS/v1/PUB/Metadata/CoolingTower/#` and publish only when that metadata payload includes an `integration` field whose value matches the publishing integration identifier. - That matching metadata payload indicates the integration can publish values to this topic. - - **MQTT wildcard examples** - - - All integration CoolingTower values: `BMS/v1/+/Value/CoolingTower/#` - parameters: - integration: - description: Integration identifier. - pointType: - enum: - - GenericPoint - description: Integration-published CoolingTower point type. - tagPath: - description: Must match the tagPath from the corresponding BMS metadata exactly. - messages: - valueMessage: - $ref: '#/components/messages/ValueMessage' - - # --------------------------------------------------------------------------- - # HX - # --------------------------------------------------------------------------- - - hxValue: - address: 'BMS/v1/PUB/Value/HX/{pointType}/{tagPath}' - description: | - Real-time values published by the BMS for HX points. - - **MQTT wildcard examples** - - - All HX values: `BMS/v1/PUB/Value/HX/#` - parameters: - pointType: - enum: - - LiquidTemperature - - LiquidDifferentialPressure - - LiquidFlow - - LiquidPressure - - Status - - Available - - ValvePosition - - PumpSpeed - - FanSpeed - - DamperPosition - - AirTemperature - - AirDifferentialPressure - - AirRelativeHumidity - - AirFlow - - AirPressure - - LeakDetect - - GenericPoint - description: BMS-published HX point type. - tagPath: - description: Vendor-defined hierarchical tag path. - messages: - valueMessage: - $ref: '#/components/messages/ValueMessage' - - hxMetadata: - address: 'BMS/v1/PUB/Metadata/HX/{pointType}/{tagPath}' - description: | - BMS-published metadata for HX points. - - **MQTT wildcard examples** - - - All HX metadata: `BMS/v1/PUB/Metadata/HX/#` - parameters: - pointType: - enum: - - LiquidTemperature - - LiquidDifferentialPressure - - LiquidFlow - - LiquidPressure - - Status - - Available - - ValvePosition - - PumpSpeed - - FanSpeed - - DamperPosition - - AirTemperature - - AirDifferentialPressure - - AirRelativeHumidity - - AirFlow - - AirPressure - - LeakDetect - - GenericPoint - description: HX point type. - tagPath: - description: Vendor-defined hierarchical tag path. - messages: - liquidTemperature: - $ref: '#/components/messages/HXLiquidTemperatureMsg' - liquidDifferentialPressure: - $ref: '#/components/messages/HXLiquidDifferentialPressureMsg' - liquidFlow: - $ref: '#/components/messages/HXLiquidFlowMsg' - liquidPressure: - $ref: '#/components/messages/HXLiquidPressureMsg' - status: - $ref: '#/components/messages/HXStatusMsg' - available: - $ref: '#/components/messages/HXAvailableMsg' - valvePosition: - $ref: '#/components/messages/HXValvePositionMsg' - pumpSpeed: - $ref: '#/components/messages/HXPumpSpeedMsg' - fanSpeed: - $ref: '#/components/messages/HXFanSpeedMsg' - damperPosition: - $ref: '#/components/messages/HXDamperPositionMsg' - airTemperature: - $ref: '#/components/messages/HXAirTemperatureMsg' - airDifferentialPressure: - $ref: '#/components/messages/HXAirDifferentialPressureMsg' - airRelativeHumidity: - $ref: '#/components/messages/HXAirRelativeHumidityMsg' - airFlow: - $ref: '#/components/messages/HXAirFlowMsg' - airPressure: - $ref: '#/components/messages/HXAirPressureMsg' - leakDetect: - $ref: '#/components/messages/HXLeakDetectMsg' - genericPoint: - $ref: '#/components/messages/GenericEquipmentPointMsg' - - hxIntegrationValue: - address: 'BMS/v1/{integration}/Value/HX/{pointType}/{tagPath}' - description: | - Values published by integrations for HX control points. - Subscribe to `BMS/v1/PUB/Metadata/HX/#` and publish only when that metadata payload includes an `integration` field whose value matches the publishing integration identifier. - That matching metadata payload indicates the integration can publish values to this topic. - - **MQTT wildcard examples** - - - All integration HX values: `BMS/v1/+/Value/HX/#` - parameters: - integration: - description: Integration identifier. - pointType: - enum: - - GenericPoint - description: Integration-published HX point type. - tagPath: - description: Must match the tagPath from the corresponding BMS metadata exactly. - messages: - valueMessage: - $ref: '#/components/messages/ValueMessage' - - # --------------------------------------------------------------------------- - # CRAH - # --------------------------------------------------------------------------- - - crahValue: - address: 'BMS/v1/PUB/Value/CRAH/{pointType}/{tagPath}' - description: | - Real-time values published by the BMS for CRAH points. - - **MQTT wildcard examples** - - - All CRAH values: `BMS/v1/PUB/Value/CRAH/#` - parameters: - pointType: - enum: - - LiquidTemperature - - LiquidDifferentialPressure - - LiquidFlow - - LiquidPressure - - Status - - Available - - ValvePosition - - PumpSpeed - - FanSpeed - - DamperPosition - - AirTemperature - - AirDifferentialPressure - - AirRelativeHumidity - - AirFlow - - AirPressure - - LeakDetect - - GenericPoint - description: BMS-published CRAH point type. - tagPath: - description: Vendor-defined hierarchical tag path. - messages: - valueMessage: - $ref: '#/components/messages/ValueMessage' - - crahMetadata: - address: 'BMS/v1/PUB/Metadata/CRAH/{pointType}/{tagPath}' - description: | - BMS-published metadata for CRAH points. - - **MQTT wildcard examples** - - - All CRAH metadata: `BMS/v1/PUB/Metadata/CRAH/#` - parameters: - pointType: - enum: - - LiquidTemperature - - LiquidDifferentialPressure - - LiquidFlow - - LiquidPressure - - Status - - Available - - ValvePosition - - PumpSpeed - - FanSpeed - - DamperPosition - - AirTemperature - - AirDifferentialPressure - - AirRelativeHumidity - - AirFlow - - AirPressure - - LeakDetect - - GenericPoint - description: CRAH point type. - tagPath: - description: Vendor-defined hierarchical tag path. - messages: - liquidTemperature: - $ref: '#/components/messages/CRAHLiquidTemperatureMsg' - liquidDifferentialPressure: - $ref: '#/components/messages/CRAHLiquidDifferentialPressureMsg' - liquidFlow: - $ref: '#/components/messages/CRAHLiquidFlowMsg' - liquidPressure: - $ref: '#/components/messages/CRAHLiquidPressureMsg' - status: - $ref: '#/components/messages/CRAHStatusMsg' - available: - $ref: '#/components/messages/CRAHAvailableMsg' - valvePosition: - $ref: '#/components/messages/CRAHValvePositionMsg' - pumpSpeed: - $ref: '#/components/messages/CRAHPumpSpeedMsg' - fanSpeed: - $ref: '#/components/messages/CRAHFanSpeedMsg' - damperPosition: - $ref: '#/components/messages/CRAHDamperPositionMsg' - airTemperature: - $ref: '#/components/messages/CRAHAirTemperatureMsg' - airDifferentialPressure: - $ref: '#/components/messages/CRAHAirDifferentialPressureMsg' - airRelativeHumidity: - $ref: '#/components/messages/CRAHAirRelativeHumidityMsg' - airFlow: - $ref: '#/components/messages/CRAHAirFlowMsg' - airPressure: - $ref: '#/components/messages/CRAHAirPressureMsg' - leakDetect: - $ref: '#/components/messages/CRAHLeakDetectMsg' - genericPoint: - $ref: '#/components/messages/GenericEquipmentPointMsg' - - crahIntegrationValue: - address: 'BMS/v1/{integration}/Value/CRAH/{pointType}/{tagPath}' - description: | - Values published by integrations for CRAH control points. - Subscribe to `BMS/v1/PUB/Metadata/CRAH/#` and publish only when that metadata payload includes an `integration` field whose value matches the publishing integration identifier. - That matching metadata payload indicates the integration can publish values to this topic. - - **MQTT wildcard examples** - - - All integration CRAH values: `BMS/v1/+/Value/CRAH/#` - parameters: - integration: - description: Integration identifier. - pointType: - enum: - - GenericPoint - description: Integration-published CRAH point type. - tagPath: - description: Must match the tagPath from the corresponding BMS metadata exactly. - messages: - valueMessage: - $ref: '#/components/messages/ValueMessage' - - # --------------------------------------------------------------------------- - # CRAC - # --------------------------------------------------------------------------- - - cracValue: - address: 'BMS/v1/PUB/Value/CRAC/{pointType}/{tagPath}' - description: | - Real-time values published by the BMS for CRAC points. - - **MQTT wildcard examples** - - - All CRAC values: `BMS/v1/PUB/Value/CRAC/#` - parameters: - pointType: - enum: - - LiquidTemperature - - LiquidDifferentialPressure - - LiquidFlow - - LiquidPressure - - Status - - Available - - ValvePosition - - PumpSpeed - - FanSpeed - - DamperPosition - - AirTemperature - - AirDifferentialPressure - - AirRelativeHumidity - - AirFlow - - AirPressure - - LeakDetect - - GenericPoint - description: BMS-published CRAC point type. - tagPath: - description: Vendor-defined hierarchical tag path. - messages: - valueMessage: - $ref: '#/components/messages/ValueMessage' - - cracMetadata: - address: 'BMS/v1/PUB/Metadata/CRAC/{pointType}/{tagPath}' - description: | - BMS-published metadata for CRAC points. - - **MQTT wildcard examples** - - - All CRAC metadata: `BMS/v1/PUB/Metadata/CRAC/#` - parameters: - pointType: - enum: - - LiquidTemperature - - LiquidDifferentialPressure - - LiquidFlow - - LiquidPressure - - Status - - Available - - ValvePosition - - PumpSpeed - - FanSpeed - - DamperPosition - - AirTemperature - - AirDifferentialPressure - - AirRelativeHumidity - - AirFlow - - AirPressure - - LeakDetect - - GenericPoint - description: CRAC point type. - tagPath: - description: Vendor-defined hierarchical tag path. - messages: - liquidTemperature: - $ref: '#/components/messages/CRACLiquidTemperatureMsg' - liquidDifferentialPressure: - $ref: '#/components/messages/CRACLiquidDifferentialPressureMsg' - liquidFlow: - $ref: '#/components/messages/CRACLiquidFlowMsg' - liquidPressure: - $ref: '#/components/messages/CRACLiquidPressureMsg' - status: - $ref: '#/components/messages/CRACStatusMsg' - available: - $ref: '#/components/messages/CRACAvailableMsg' - valvePosition: - $ref: '#/components/messages/CRACValvePositionMsg' - pumpSpeed: - $ref: '#/components/messages/CRACPumpSpeedMsg' - fanSpeed: - $ref: '#/components/messages/CRACFanSpeedMsg' - damperPosition: - $ref: '#/components/messages/CRACDamperPositionMsg' - airTemperature: - $ref: '#/components/messages/CRACAirTemperatureMsg' - airDifferentialPressure: - $ref: '#/components/messages/CRACAirDifferentialPressureMsg' - airRelativeHumidity: - $ref: '#/components/messages/CRACAirRelativeHumidityMsg' - airFlow: - $ref: '#/components/messages/CRACAirFlowMsg' - airPressure: - $ref: '#/components/messages/CRACAirPressureMsg' - leakDetect: - $ref: '#/components/messages/CRACLeakDetectMsg' - genericPoint: - $ref: '#/components/messages/GenericEquipmentPointMsg' - - cracIntegrationValue: - address: 'BMS/v1/{integration}/Value/CRAC/{pointType}/{tagPath}' - description: | - Values published by integrations for CRAC control points. - Subscribe to `BMS/v1/PUB/Metadata/CRAC/#` and publish only when that metadata payload includes an `integration` field whose value matches the publishing integration identifier. - That matching metadata payload indicates the integration can publish values to this topic. - - **MQTT wildcard examples** - - - All integration CRAC values: `BMS/v1/+/Value/CRAC/#` - parameters: - integration: - description: Integration identifier. - pointType: - enum: - - GenericPoint - description: Integration-published CRAC point type. - tagPath: - description: Must match the tagPath from the corresponding BMS metadata exactly. - messages: - valueMessage: - $ref: '#/components/messages/ValueMessage' - - # --------------------------------------------------------------------------- - # AHU - # --------------------------------------------------------------------------- - - ahuValue: - address: 'BMS/v1/PUB/Value/AHU/{pointType}/{tagPath}' - description: | - Real-time values published by the BMS for AHU points. - - **MQTT wildcard examples** - - - All AHU values: `BMS/v1/PUB/Value/AHU/#` - parameters: - pointType: - enum: - - LiquidTemperature - - LiquidDifferentialPressure - - LiquidFlow - - LiquidPressure - - Status - - Available - - ValvePosition - - PumpSpeed - - FanSpeed - - DamperPosition - - AirTemperature - - AirDifferentialPressure - - AirRelativeHumidity - - AirFlow - - AirPressure - - LeakDetect - - GenericPoint - description: BMS-published AHU point type. - tagPath: - description: Vendor-defined hierarchical tag path. - messages: - valueMessage: - $ref: '#/components/messages/ValueMessage' - - ahuMetadata: - address: 'BMS/v1/PUB/Metadata/AHU/{pointType}/{tagPath}' - description: | - BMS-published metadata for AHU points. - - **MQTT wildcard examples** - - - All AHU metadata: `BMS/v1/PUB/Metadata/AHU/#` - parameters: - pointType: - enum: - - LiquidTemperature - - LiquidDifferentialPressure - - LiquidFlow - - LiquidPressure - - Status - - Available - - ValvePosition - - PumpSpeed - - FanSpeed - - DamperPosition - - AirTemperature - - AirDifferentialPressure - - AirRelativeHumidity - - AirFlow - - AirPressure - - LeakDetect - - GenericPoint - description: AHU point type. - tagPath: - description: Vendor-defined hierarchical tag path. - messages: - liquidTemperature: - $ref: '#/components/messages/AHULiquidTemperatureMsg' - liquidDifferentialPressure: - $ref: '#/components/messages/AHULiquidDifferentialPressureMsg' - liquidFlow: - $ref: '#/components/messages/AHULiquidFlowMsg' - liquidPressure: - $ref: '#/components/messages/AHULiquidPressureMsg' - status: - $ref: '#/components/messages/AHUStatusMsg' - available: - $ref: '#/components/messages/AHUAvailableMsg' - valvePosition: - $ref: '#/components/messages/AHUValvePositionMsg' - pumpSpeed: - $ref: '#/components/messages/AHUPumpSpeedMsg' - fanSpeed: - $ref: '#/components/messages/AHUFanSpeedMsg' - damperPosition: - $ref: '#/components/messages/AHUDamperPositionMsg' - airTemperature: - $ref: '#/components/messages/AHUAirTemperatureMsg' - airDifferentialPressure: - $ref: '#/components/messages/AHUAirDifferentialPressureMsg' - airRelativeHumidity: - $ref: '#/components/messages/AHUAirRelativeHumidityMsg' - airFlow: - $ref: '#/components/messages/AHUAirFlowMsg' - airPressure: - $ref: '#/components/messages/AHUAirPressureMsg' - leakDetect: - $ref: '#/components/messages/AHULeakDetectMsg' - genericPoint: - $ref: '#/components/messages/GenericEquipmentPointMsg' - - ahuIntegrationValue: - address: 'BMS/v1/{integration}/Value/AHU/{pointType}/{tagPath}' - description: | - Values published by integrations for AHU control points. - Subscribe to `BMS/v1/PUB/Metadata/AHU/#` and publish only when that metadata payload includes an `integration` field whose value matches the publishing integration identifier. - That matching metadata payload indicates the integration can publish values to this topic. - - **MQTT wildcard examples** - - - All integration AHU values: `BMS/v1/+/Value/AHU/#` - parameters: - integration: - description: Integration identifier. - pointType: - enum: - - GenericPoint - description: Integration-published AHU point type. - tagPath: - description: Must match the tagPath from the corresponding BMS metadata exactly. - messages: - valueMessage: - $ref: '#/components/messages/ValueMessage' - - # --------------------------------------------------------------------------- - # Chiller - # --------------------------------------------------------------------------- - - chillerValue: - address: 'BMS/v1/PUB/Value/Chiller/{pointType}/{tagPath}' - description: | - Real-time values published by the BMS for Chiller points. - - **MQTT wildcard examples** - - - All Chiller values: `BMS/v1/PUB/Value/Chiller/#` - parameters: - pointType: - enum: - - LiquidTemperature - - LiquidDifferentialPressure - - LiquidFlow - - LiquidPressure - - Status - - Available - - ValvePosition - - PumpSpeed - - FanSpeed - - DamperPosition - - AirTemperature - - AirDifferentialPressure - - AirRelativeHumidity - - AirFlow - - AirPressure - - LeakDetect - - GenericPoint - description: BMS-published Chiller point type. - tagPath: - description: Vendor-defined hierarchical tag path. - messages: - valueMessage: - $ref: '#/components/messages/ValueMessage' - - chillerMetadata: - address: 'BMS/v1/PUB/Metadata/Chiller/{pointType}/{tagPath}' - description: | - BMS-published metadata for Chiller points. - - **MQTT wildcard examples** - - - All Chiller metadata: `BMS/v1/PUB/Metadata/Chiller/#` - parameters: - pointType: - enum: - - LiquidTemperature - - LiquidDifferentialPressure - - LiquidFlow - - LiquidPressure - - Status - - Available - - ValvePosition - - PumpSpeed - - FanSpeed - - DamperPosition - - AirTemperature - - AirDifferentialPressure - - AirRelativeHumidity - - AirFlow - - AirPressure - - LeakDetect - - GenericPoint - description: Chiller point type. - tagPath: - description: Vendor-defined hierarchical tag path. - messages: - liquidTemperature: - $ref: '#/components/messages/ChillerLiquidTemperatureMsg' - liquidDifferentialPressure: - $ref: '#/components/messages/ChillerLiquidDifferentialPressureMsg' - liquidFlow: - $ref: '#/components/messages/ChillerLiquidFlowMsg' - liquidPressure: - $ref: '#/components/messages/ChillerLiquidPressureMsg' - status: - $ref: '#/components/messages/ChillerStatusMsg' - available: - $ref: '#/components/messages/ChillerAvailableMsg' - valvePosition: - $ref: '#/components/messages/ChillerValvePositionMsg' - pumpSpeed: - $ref: '#/components/messages/ChillerPumpSpeedMsg' - fanSpeed: - $ref: '#/components/messages/ChillerFanSpeedMsg' - damperPosition: - $ref: '#/components/messages/ChillerDamperPositionMsg' - airTemperature: - $ref: '#/components/messages/ChillerAirTemperatureMsg' - airDifferentialPressure: - $ref: '#/components/messages/ChillerAirDifferentialPressureMsg' - airRelativeHumidity: - $ref: '#/components/messages/ChillerAirRelativeHumidityMsg' - airFlow: - $ref: '#/components/messages/ChillerAirFlowMsg' - airPressure: - $ref: '#/components/messages/ChillerAirPressureMsg' - leakDetect: - $ref: '#/components/messages/ChillerLeakDetectMsg' - genericPoint: - $ref: '#/components/messages/GenericEquipmentPointMsg' - - chillerIntegrationValue: - address: 'BMS/v1/{integration}/Value/Chiller/{pointType}/{tagPath}' - description: | - Values published by integrations for Chiller control points. - Subscribe to `BMS/v1/PUB/Metadata/Chiller/#` and publish only when that metadata payload includes an `integration` field whose value matches the publishing integration identifier. - That matching metadata payload indicates the integration can publish values to this topic. - - **MQTT wildcard examples** - - - All integration Chiller values: `BMS/v1/+/Value/Chiller/#` - parameters: - integration: - description: Integration identifier. - pointType: - enum: - - GenericPoint - description: Integration-published Chiller point type. - tagPath: - description: Must match the tagPath from the corresponding BMS metadata exactly. - messages: - valueMessage: - $ref: '#/components/messages/ValueMessage' - - # --------------------------------------------------------------------------- - # Valve - # --------------------------------------------------------------------------- - - valveValue: - address: 'BMS/v1/PUB/Value/Valve/{pointType}/{tagPath}' - description: | - Real-time values published by the BMS for Valve points. - - **MQTT wildcard examples** - - - All Valve values: `BMS/v1/PUB/Value/Valve/#` - parameters: - pointType: - enum: - - ValvePosition - - Available - - GenericPoint - description: BMS-published Valve point type. - tagPath: - description: Vendor-defined hierarchical tag path. - messages: - valueMessage: - $ref: '#/components/messages/ValueMessage' - - valveMetadata: - address: 'BMS/v1/PUB/Metadata/Valve/{pointType}/{tagPath}' - description: | - BMS-published metadata for Valve points. - - **MQTT wildcard examples** - - - All Valve metadata: `BMS/v1/PUB/Metadata/Valve/#` - parameters: - pointType: - enum: - - ValvePosition - - Available - - GenericPoint - description: Valve point type. - tagPath: - description: Vendor-defined hierarchical tag path. - messages: - valvePosition: - $ref: '#/components/messages/ValveValvePositionMsg' - available: - $ref: '#/components/messages/ValveAvailableMsg' - genericPoint: - $ref: '#/components/messages/GenericEquipmentPointMsg' - - valveIntegrationValue: - address: 'BMS/v1/{integration}/Value/Valve/{pointType}/{tagPath}' - description: | - Values published by integrations for Valve control points. - Subscribe to `BMS/v1/PUB/Metadata/Valve/#` and publish only when that metadata payload includes an `integration` field whose value matches the publishing integration identifier. - That matching metadata payload indicates the integration can publish values to this topic. - - **MQTT wildcard examples** - - - All integration Valve values: `BMS/v1/+/Value/Valve/#` - parameters: - integration: - description: Integration identifier. - pointType: - enum: - - GenericPoint - description: Integration-published Valve point type. - tagPath: - description: Must match the tagPath from the corresponding BMS metadata exactly. - messages: - valueMessage: - $ref: '#/components/messages/ValueMessage' - - # --------------------------------------------------------------------------- - # Pump - # --------------------------------------------------------------------------- - - pumpValue: - address: 'BMS/v1/PUB/Value/Pump/{pointType}/{tagPath}' - description: | - Real-time values published by the BMS for Pump points. - - **MQTT wildcard examples** - - - All Pump values: `BMS/v1/PUB/Value/Pump/#` - parameters: - pointType: - enum: - - PumpSpeed - - Available - - GenericPoint - description: BMS-published Pump point type. - tagPath: - description: Vendor-defined hierarchical tag path. - messages: - valueMessage: - $ref: '#/components/messages/ValueMessage' - - pumpMetadata: - address: 'BMS/v1/PUB/Metadata/Pump/{pointType}/{tagPath}' - description: | - BMS-published metadata for Pump points. - - **MQTT wildcard examples** - - - All Pump metadata: `BMS/v1/PUB/Metadata/Pump/#` - parameters: - pointType: - enum: - - PumpSpeed - - Available - - GenericPoint - description: Pump point type. - tagPath: - description: Vendor-defined hierarchical tag path. - messages: - pumpSpeed: - $ref: '#/components/messages/PumpPumpSpeedMsg' - available: - $ref: '#/components/messages/PumpAvailableMsg' - genericPoint: - $ref: '#/components/messages/GenericEquipmentPointMsg' - - pumpIntegrationValue: - address: 'BMS/v1/{integration}/Value/Pump/{pointType}/{tagPath}' - description: | - Values published by integrations for Pump control points. - Subscribe to `BMS/v1/PUB/Metadata/Pump/#` and publish only when that metadata payload includes an `integration` field whose value matches the publishing integration identifier. - That matching metadata payload indicates the integration can publish values to this topic. - - **MQTT wildcard examples** - - - All integration Pump values: `BMS/v1/+/Value/Pump/#` - parameters: - integration: - description: Integration identifier. - pointType: - enum: - - GenericPoint - description: Integration-published Pump point type. - tagPath: - description: Must match the tagPath from the corresponding BMS metadata exactly. - messages: - valueMessage: - $ref: '#/components/messages/ValueMessage' - - # --------------------------------------------------------------------------- - # Fan - # --------------------------------------------------------------------------- - - fanValue: - address: 'BMS/v1/PUB/Value/Fan/{pointType}/{tagPath}' - description: | - Real-time values published by the BMS for Fan points. - - **MQTT wildcard examples** - - - All Fan values: `BMS/v1/PUB/Value/Fan/#` - parameters: - pointType: - enum: - - FanSpeed - - Available - - GenericPoint - description: BMS-published Fan point type. - tagPath: - description: Vendor-defined hierarchical tag path. - messages: - valueMessage: - $ref: '#/components/messages/ValueMessage' - - fanMetadata: - address: 'BMS/v1/PUB/Metadata/Fan/{pointType}/{tagPath}' - description: | - BMS-published metadata for Fan points. - - **MQTT wildcard examples** - - - All Fan metadata: `BMS/v1/PUB/Metadata/Fan/#` - parameters: - pointType: - enum: - - FanSpeed - - Available - - GenericPoint - description: Fan point type. - tagPath: - description: Vendor-defined hierarchical tag path. - messages: - fanSpeed: - $ref: '#/components/messages/FanFanSpeedMsg' - available: - $ref: '#/components/messages/FanAvailableMsg' - genericPoint: - $ref: '#/components/messages/GenericEquipmentPointMsg' - - fanIntegrationValue: - address: 'BMS/v1/{integration}/Value/Fan/{pointType}/{tagPath}' - description: | - Values published by integrations for Fan control points. - Subscribe to `BMS/v1/PUB/Metadata/Fan/#` and publish only when that metadata payload includes an `integration` field whose value matches the publishing integration identifier. - That matching metadata payload indicates the integration can publish values to this topic. - - **MQTT wildcard examples** - - - All integration Fan values: `BMS/v1/+/Value/Fan/#` - parameters: - integration: - description: Integration identifier. - pointType: - enum: - - GenericPoint - description: Integration-published Fan point type. - tagPath: - description: Must match the tagPath from the corresponding BMS metadata exactly. - messages: - valueMessage: - $ref: '#/components/messages/ValueMessage' - - # --------------------------------------------------------------------------- - # Damper - # --------------------------------------------------------------------------- - - damperValue: - address: 'BMS/v1/PUB/Value/Damper/{pointType}/{tagPath}' - description: | - Real-time values published by the BMS for Damper points. - - **MQTT wildcard examples** - - - All Damper values: `BMS/v1/PUB/Value/Damper/#` - parameters: - pointType: - enum: - - DamperPosition - - Available - - GenericPoint - description: BMS-published Damper point type. - tagPath: - description: Vendor-defined hierarchical tag path. - messages: - valueMessage: - $ref: '#/components/messages/ValueMessage' - - damperMetadata: - address: 'BMS/v1/PUB/Metadata/Damper/{pointType}/{tagPath}' - description: | - BMS-published metadata for Damper points. - - **MQTT wildcard examples** - - - All Damper metadata: `BMS/v1/PUB/Metadata/Damper/#` - parameters: - pointType: - enum: - - DamperPosition - - Available - - GenericPoint - description: Damper point type. - tagPath: - description: Vendor-defined hierarchical tag path. - messages: - damperPosition: - $ref: '#/components/messages/DamperDamperPositionMsg' - available: - $ref: '#/components/messages/DamperAvailableMsg' - genericPoint: - $ref: '#/components/messages/GenericEquipmentPointMsg' - - damperIntegrationValue: - address: 'BMS/v1/{integration}/Value/Damper/{pointType}/{tagPath}' - description: | - Values published by integrations for Damper control points. - Subscribe to `BMS/v1/PUB/Metadata/Damper/#` and publish only when that metadata payload includes an `integration` field whose value matches the publishing integration identifier. - That matching metadata payload indicates the integration can publish values to this topic. - - **MQTT wildcard examples** - - - All integration Damper values: `BMS/v1/+/Value/Damper/#` - parameters: - integration: - description: Integration identifier. - pointType: - enum: - - GenericPoint - description: Integration-published Damper point type. - tagPath: - description: Must match the tagPath from the corresponding BMS metadata exactly. - messages: - valueMessage: - $ref: '#/components/messages/ValueMessage' - - # --------------------------------------------------------------------------- - # Sensor - # --------------------------------------------------------------------------- - - sensorValue: - address: 'BMS/v1/PUB/Value/Sensor/{pointType}/{tagPath}' - description: | - Real-time values published by the BMS for Sensor points. - - **MQTT wildcard examples** - - - All Sensor values: `BMS/v1/PUB/Value/Sensor/#` - parameters: - pointType: - enum: - - LiquidTemperature - - LiquidDifferentialPressure - - LiquidFlow - - LiquidPressure - - AirTemperature - - AirDifferentialPressure - - AirRelativeHumidity - - AirFlow - - AirPressure - - LeakDetect - - Sound - - Available - - GenericPoint - description: BMS-published Sensor point type. - tagPath: - description: Vendor-defined hierarchical tag path. - messages: - valueMessage: - $ref: '#/components/messages/ValueMessage' - - sensorMetadata: - address: 'BMS/v1/PUB/Metadata/Sensor/{pointType}/{tagPath}' - description: | - BMS-published metadata for Sensor points. - - **MQTT wildcard examples** - - - All Sensor metadata: `BMS/v1/PUB/Metadata/Sensor/#` - parameters: - pointType: - enum: - - LiquidTemperature - - LiquidDifferentialPressure - - LiquidFlow - - LiquidPressure - - AirTemperature - - AirDifferentialPressure - - AirRelativeHumidity - - AirFlow - - AirPressure - - LeakDetect - - Sound - - Available - - GenericPoint - description: Sensor point type. - tagPath: - description: Vendor-defined hierarchical tag path. - messages: - liquidTemperature: - $ref: '#/components/messages/SensorLiquidTemperatureMsg' - liquidDifferentialPressure: - $ref: '#/components/messages/SensorLiquidDifferentialPressureMsg' - liquidFlow: - $ref: '#/components/messages/SensorLiquidFlowMsg' - liquidPressure: - $ref: '#/components/messages/SensorLiquidPressureMsg' - airTemperature: - $ref: '#/components/messages/SensorAirTemperatureMsg' - airDifferentialPressure: - $ref: '#/components/messages/SensorAirDifferentialPressureMsg' - airRelativeHumidity: - $ref: '#/components/messages/SensorAirRelativeHumidityMsg' - airFlow: - $ref: '#/components/messages/SensorAirFlowMsg' - airPressure: - $ref: '#/components/messages/SensorAirPressureMsg' - leakDetect: - $ref: '#/components/messages/SensorLeakDetectMsg' - available: - $ref: '#/components/messages/SensorAvailableMsg' - genericPoint: - $ref: '#/components/messages/GenericEquipmentPointMsg' - sound: - $ref: '#/components/messages/SensorSoundMsg' - - - sensorIntegrationValue: - address: 'BMS/v1/{integration}/Value/Sensor/{pointType}/{tagPath}' - description: | - Values published by integrations for Sensor control points. - Subscribe to `BMS/v1/PUB/Metadata/Sensor/#` and publish only when that metadata payload includes an `integration` field whose value matches the publishing integration identifier. - That matching metadata payload indicates the integration can publish values to this topic. - - **MQTT wildcard examples** - - - All integration Sensor values: `BMS/v1/+/Value/Sensor/#` - parameters: - integration: - description: Integration identifier. - pointType: - enum: - - GenericPoint - description: Integration-published Sensor point type. - tagPath: - description: Must match the tagPath from the corresponding BMS metadata exactly. - messages: - valueMessage: - $ref: '#/components/messages/ValueMessage' - - # --------------------------------------------------------------------------- - # Tank - # --------------------------------------------------------------------------- - - tankValue: - address: 'BMS/v1/PUB/Value/Tank/{pointType}/{tagPath}' - description: | - Real-time values published by the BMS for Tank points. - Supports both liquid and air tanks. - - **MQTT wildcard examples** - - - All Tank values: `BMS/v1/PUB/Value/Tank/#` - parameters: - pointType: - enum: - - LiquidTemperature - - LiquidDifferentialPressure - - LiquidFlow - - LiquidPressure - - Status - - Available - - ValvePosition - - PumpSpeed - - FanSpeed - - DamperPosition - - AirTemperature - - AirDifferentialPressure - - AirRelativeHumidity - - AirFlow - - AirPressure - - LeakDetect - - GenericPoint - description: BMS-published Tank point type. - tagPath: - description: Vendor-defined hierarchical tag path. - messages: - valueMessage: - $ref: '#/components/messages/ValueMessage' - - tankMetadata: - address: 'BMS/v1/PUB/Metadata/Tank/{pointType}/{tagPath}' - description: | - BMS-published metadata for Tank points. - Includes integration-owned points which carries the `integration` field. - - **MQTT wildcard examples** - - - All Tank metadata: `BMS/v1/PUB/Metadata/Tank/#` - parameters: - pointType: - enum: - - LiquidTemperature - - LiquidDifferentialPressure - - LiquidFlow - - LiquidPressure - - Status - - Available - - ValvePosition - - PumpSpeed - - FanSpeed - - DamperPosition - - AirTemperature - - AirDifferentialPressure - - AirRelativeHumidity - - AirFlow - - AirPressure - - LeakDetect - - GenericPoint - description: Tank point type. - tagPath: - description: Vendor-defined hierarchical tag path. - messages: - liquidTemperature: - $ref: '#/components/messages/TankLiquidTemperatureMsg' - liquidDifferentialPressure: - $ref: '#/components/messages/TankLiquidDifferentialPressureMsg' - liquidFlow: - $ref: '#/components/messages/TankLiquidFlowMsg' - liquidPressure: - $ref: '#/components/messages/TankLiquidPressureMsg' - status: - $ref: '#/components/messages/TankStatusMsg' - available: - $ref: '#/components/messages/TankAvailableMsg' - valvePosition: - $ref: '#/components/messages/TankValvePositionMsg' - pumpSpeed: - $ref: '#/components/messages/TankPumpSpeedMsg' - fanSpeed: - $ref: '#/components/messages/TankFanSpeedMsg' - damperPosition: - $ref: '#/components/messages/TankDamperPositionMsg' - airTemperature: - $ref: '#/components/messages/TankAirTemperatureMsg' - airDifferentialPressure: - $ref: '#/components/messages/TankAirDifferentialPressureMsg' - airRelativeHumidity: - $ref: '#/components/messages/TankAirRelativeHumidityMsg' - airFlow: - $ref: '#/components/messages/TankAirFlowMsg' - airPressure: - $ref: '#/components/messages/TankAirPressureMsg' - leakDetect: - $ref: '#/components/messages/TankLeakDetectMsg' - genericPoint: - $ref: '#/components/messages/GenericEquipmentPointMsg' - - tankIntegrationValue: - address: 'BMS/v1/{integration}/Value/Tank/{pointType}/{tagPath}' - description: | - Values published by integrations for Tank control points. - Subscribe to `BMS/v1/PUB/Metadata/Tank/#` and publish only when that metadata payload includes an `integration` field whose value matches the publishing integration identifier. - That matching metadata payload indicates the integration can publish values to this topic. - - **MQTT wildcard examples** - - - All integration Tank values: `BMS/v1/+/Value/Tank/#` - parameters: - integration: - description: Integration identifier. - pointType: - enum: - - GenericPoint - description: Integration-published Tank point type. - tagPath: - description: Must match the tagPath from the corresponding BMS metadata exactly. - messages: - valueMessage: - $ref: '#/components/messages/ValueMessage' - - # --------------------------------------------------------------------------- - # GenericObject - # --------------------------------------------------------------------------- - - genericObjectValue: - address: 'BMS/v1/PUB/Value/GenericObject/{pointType}/{tagPath}' - description: | - Real-time values published by the BMS for GenericObject points. - Use for any equipment type not covered by a named objectType. - - **MQTT wildcard examples** - - - All GenericObject values: `BMS/v1/PUB/Value/GenericObject/#` - parameters: - pointType: - enum: - - LiquidTemperature - - LiquidDifferentialPressure - - LiquidFlow - - LiquidPressure - - Status - - Available - - ValvePosition - - PumpSpeed - - FanSpeed - - DamperPosition - - AirTemperature - - AirDifferentialPressure - - AirRelativeHumidity - - AirFlow - - AirPressure - - LeakDetect - - Sound - - GenericPoint - description: BMS-published GenericObject point type. - tagPath: - description: Vendor-defined hierarchical tag path. - messages: - valueMessage: - $ref: '#/components/messages/ValueMessage' - - genericObjectMetadata: - address: 'BMS/v1/PUB/Metadata/GenericObject/{pointType}/{tagPath}' - description: | - BMS-published metadata for GenericObject points. - Includes integration-owned points which carries the `integration` field. - - **MQTT wildcard examples** - - - All GenericObject metadata: `BMS/v1/PUB/Metadata/GenericObject/#` - parameters: - pointType: - enum: - - LiquidTemperature - - LiquidDifferentialPressure - - LiquidFlow - - LiquidPressure - - Status - - Available - - ValvePosition - - PumpSpeed - - FanSpeed - - DamperPosition - - AirTemperature - - AirDifferentialPressure - - AirRelativeHumidity - - AirFlow - - AirPressure - - LeakDetect - - LiquidTemperatureSpRequest - - Sound - - GenericPoint - description: GenericObject point type. - tagPath: - description: Vendor-defined hierarchical tag path. - messages: - liquidTemperature: - $ref: '#/components/messages/GenericObjectLiquidTemperatureMsg' - liquidDifferentialPressure: - $ref: '#/components/messages/GenericObjectLiquidDifferentialPressureMsg' - liquidFlow: - $ref: '#/components/messages/GenericObjectLiquidFlowMsg' - liquidPressure: - $ref: '#/components/messages/GenericObjectLiquidPressureMsg' - status: - $ref: '#/components/messages/GenericObjectStatusMsg' - available: - $ref: '#/components/messages/GenericObjectAvailableMsg' - valvePosition: - $ref: '#/components/messages/GenericObjectValvePositionMsg' - pumpSpeed: - $ref: '#/components/messages/GenericObjectPumpSpeedMsg' - fanSpeed: - $ref: '#/components/messages/GenericObjectFanSpeedMsg' - damperPosition: - $ref: '#/components/messages/GenericObjectDamperPositionMsg' - airTemperature: - $ref: '#/components/messages/GenericObjectAirTemperatureMsg' - airDifferentialPressure: - $ref: '#/components/messages/GenericObjectAirDifferentialPressureMsg' - airRelativeHumidity: - $ref: '#/components/messages/GenericObjectAirRelativeHumidityMsg' - airFlow: - $ref: '#/components/messages/GenericObjectAirFlowMsg' - airPressure: - $ref: '#/components/messages/GenericObjectAirPressureMsg' - leakDetect: - $ref: '#/components/messages/GenericObjectLeakDetectMsg' - liquidTemperatureSpRequest: - $ref: '#/components/messages/GenericObjectLiquidTemperatureSpRequestMsg' - sound: - $ref: '#/components/messages/GenericObjectSoundMsg' - genericPoint: - $ref: '#/components/messages/GenericEquipmentPointMsg' - - genericObjectIntegrationValue: - address: 'BMS/v1/{integration}/Value/GenericObject/{pointType}/{tagPath}' - description: | - Values published by integrations for GenericObject control points. - Subscribe to `BMS/v1/PUB/Metadata/GenericObject/#` and publish only when that metadata payload includes an `integration` field whose value matches the publishing integration identifier. - That matching metadata payload indicates the integration can publish values to this topic. - - **MQTT wildcard examples** - - - All integration GenericObject values: `BMS/v1/+/Value/GenericObject/#` - parameters: - integration: - description: Integration identifier. - pointType: - enum: - - LiquidTemperatureSpRequest - - GenericPoint - description: Integration-published GenericObject point type. - tagPath: - description: Must match the tagPath from the corresponding BMS metadata exactly. - messages: - valueMessage: - $ref: '#/components/messages/ValueMessage' - - # --------------------------------------------------------------------------- - # System - # --------------------------------------------------------------------------- - - systemBmsValue: - address: 'BMS/v1/PUB/Value/System/{pointType}/{tagPath}' - description: | - BMS-published System values. - - **MQTT wildcard examples** - - - All System values: `BMS/v1/PUB/Value/System/#` - parameters: - pointType: - enum: - - HeartbeatTimestampBms - - HeartbeatEchoBms - - Status - - Available - - GenericPoint - description: BMS-published System point type. - tagPath: - description: Vendor-defined hierarchical tag path. - messages: - valueMessage: - $ref: '#/components/messages/ValueMessage' - - systemMetadata: - address: 'BMS/v1/PUB/Metadata/System/{pointType}/{tagPath}' - description: | - BMS-published metadata for all System point types. - - **MQTT wildcard examples** - - - All System metadata: `BMS/v1/PUB/Metadata/System/#` - parameters: - pointType: - enum: - - HeartbeatTimestampBms - - HeartbeatEchoBms - - HeartbeatTimestampIntegration - - HeartbeatEchoIntegration - - Status - - Available - - GenericPoint - description: System point type. - tagPath: - description: Vendor-defined hierarchical tag path. - messages: - heartbeatTimestampBms: - $ref: '#/components/messages/SystemHeartbeatTimestampBmsMsg' - heartbeatEchoBms: - $ref: '#/components/messages/SystemHeartbeatEchoBmsMsg' - heartbeatTimestampIntegration: - $ref: '#/components/messages/SystemHeartbeatTimestampIntegrationMsg' - heartbeatEchoIntegration: - $ref: '#/components/messages/SystemHeartbeatEchoIntegrationMsg' - status: - $ref: '#/components/messages/SystemStatusMsg' - available: - $ref: '#/components/messages/SystemAvailableMsg' - genericPoint: - $ref: '#/components/messages/GenericEquipmentPointMsg' - - systemIntegrationValue: - address: 'BMS/v1/{integration}/Value/System/{pointType}/{tagPath}' - description: | - Values published by integrations for System points (Integration heartbeats, Status, Available, and GenericPoint). - Subscribe to `BMS/v1/PUB/Metadata/System/#` and publish only when that metadata payload includes an `integration` field whose value matches the publishing integration identifier. - That matching metadata payload indicates the integration can publish values to this topic. - - **MQTT wildcard examples** - - - All integration System values: `BMS/v1/+/Value/System/#` - parameters: - integration: - description: Integration identifier. - pointType: - enum: - - HeartbeatTimestampIntegration - - HeartbeatEchoIntegration - - Status - - Available - - GenericPoint - description: Integration-published System point type. - tagPath: - description: Vendor-defined hierarchical tag path. - messages: - valueMessage: - $ref: '#/components/messages/ValueMessage' - - - -# ============================================================================= -# Operations -# ============================================================================= - -operations: - - receiveRackValue: - action: receive - channel: - $ref: '#/channels/rackBmsValue' - messages: - - $ref: '#/channels/rackBmsValue/messages/valueMessage' - description: Subscribe to real-time BMS-published values for all Rack points. - - receiveRackMetadata: - action: receive - channel: - $ref: '#/channels/rackMetadata' - messages: - - $ref: '#/channels/rackMetadata/messages/rackLiquidSupplyTemperature' - - $ref: '#/channels/rackMetadata/messages/rackLiquidReturnTemperature' - - $ref: '#/channels/rackMetadata/messages/rackLiquidFlow' - - $ref: '#/channels/rackMetadata/messages/rackLiquidDifferentialPressure' - - $ref: '#/channels/rackMetadata/messages/rackLiquidDifferentialPressureSp' - - $ref: '#/channels/rackMetadata/messages/rackControlValvePosition' - - $ref: '#/channels/rackMetadata/messages/rackPower' - - $ref: '#/channels/rackMetadata/messages/rackLeakDetect' - - $ref: '#/channels/rackMetadata/messages/rackLeakSensorFault' - - $ref: '#/channels/rackMetadata/messages/rackLeakDetectTray' - - $ref: '#/channels/rackMetadata/messages/rackLiquidIsolationStatus' - - $ref: '#/channels/rackMetadata/messages/rackElectricalIsolationStatus' - - $ref: '#/channels/rackMetadata/messages/rackLiquidIsolationRequest' - - $ref: '#/channels/rackMetadata/messages/rackElectricalIsolationRequest' - description: Subscribe to BMS-published metadata for all Rack point types. - - publishRackIntegrationValue: - action: send - channel: - $ref: '#/channels/rackIntegrationValue' - messages: - - $ref: '#/channels/rackIntegrationValue/messages/valueMessage' - description: Publish integration values for Rack control points. - - receivePowerMeterValue: - action: receive - channel: - $ref: '#/channels/powerMeterValue' - messages: - - $ref: '#/channels/powerMeterValue/messages/valueMessage' - description: Subscribe to real-time BMS-published values for all PowerMeter points. - - receivePowerMeterMetadata: - action: receive - channel: - $ref: '#/channels/powerMeterMetadata' - messages: - - $ref: '#/channels/powerMeterMetadata/messages/voltage' - - $ref: '#/channels/powerMeterMetadata/messages/powerFactor' - - $ref: '#/channels/powerMeterMetadata/messages/frequency' - - $ref: '#/channels/powerMeterMetadata/messages/apparentPower' - - $ref: '#/channels/powerMeterMetadata/messages/activePower' - - $ref: '#/channels/powerMeterMetadata/messages/current' - - $ref: '#/channels/powerMeterMetadata/messages/currentLimit' - - $ref: '#/channels/powerMeterMetadata/messages/phaseCurrent' - - $ref: '#/channels/powerMeterMetadata/messages/genericPoint' - description: Subscribe to BMS-published metadata for all PowerMeter points. - - publishPowerMeterIntegrationValue: - action: send - channel: - $ref: '#/channels/powerMeterIntegrationValue' - messages: - - $ref: '#/channels/powerMeterIntegrationValue/messages/valueMessage' - description: Publish integration values for PowerMeter control points. - - receiveBESSValue: - action: receive - channel: - $ref: '#/channels/bessValue' - messages: - - $ref: '#/channels/bessValue/messages/valueMessage' - description: Subscribe to real-time BMS-published values for BESS points. - - receiveBESSMetadata: - action: receive - channel: - $ref: '#/channels/bessMetadata' - messages: - - $ref: '#/channels/bessMetadata/messages/status' - - $ref: '#/channels/bessMetadata/messages/available' - - $ref: '#/channels/bessMetadata/messages/genericPoint' - description: Subscribe to BMS-published metadata for BESS points. - - publishBESSIntegrationValue: - action: send - channel: - $ref: '#/channels/bessIntegrationValue' - messages: - - $ref: '#/channels/bessIntegrationValue/messages/valueMessage' - description: Publish integration values for BESS control points. - - receiveUPSValue: - action: receive - channel: - $ref: '#/channels/upsValue' - messages: - - $ref: '#/channels/upsValue/messages/valueMessage' - description: Subscribe to real-time BMS-published values for UPS points. - - receiveUPSMetadata: - action: receive - channel: - $ref: '#/channels/upsMetadata' - messages: - - $ref: '#/channels/upsMetadata/messages/status' - - $ref: '#/channels/upsMetadata/messages/available' - - $ref: '#/channels/upsMetadata/messages/genericPoint' - description: Subscribe to BMS-published metadata for UPS points. - - publishUPSIntegrationValue: - action: send - channel: - $ref: '#/channels/upsIntegrationValue' - messages: - - $ref: '#/channels/upsIntegrationValue/messages/valueMessage' - description: Publish integration values for UPS control points. - - receiveATSValue: - action: receive - channel: - $ref: '#/channels/atsValue' - messages: - - $ref: '#/channels/atsValue/messages/valueMessage' - description: Subscribe to real-time BMS-published values for ATS points. - - receiveATSMetadata: - action: receive - channel: - $ref: '#/channels/atsMetadata' - messages: - - $ref: '#/channels/atsMetadata/messages/status' - - $ref: '#/channels/atsMetadata/messages/available' - - $ref: '#/channels/atsMetadata/messages/genericPoint' - description: Subscribe to BMS-published metadata for ATS points. - - publishATSIntegrationValue: - action: send - channel: - $ref: '#/channels/atsIntegrationValue' - messages: - - $ref: '#/channels/atsIntegrationValue/messages/valueMessage' - description: Publish integration values for ATS control points. - - receiveGeneratorValue: - action: receive - channel: - $ref: '#/channels/generatorValue' - messages: - - $ref: '#/channels/generatorValue/messages/valueMessage' - description: Subscribe to real-time BMS-published values for Generator points. - - receiveGeneratorMetadata: - action: receive - channel: - $ref: '#/channels/generatorMetadata' - messages: - - $ref: '#/channels/generatorMetadata/messages/status' - - $ref: '#/channels/generatorMetadata/messages/available' - - $ref: '#/channels/generatorMetadata/messages/genericPoint' - description: Subscribe to BMS-published metadata for Generator points. - - publishGeneratorIntegrationValue: - action: send - channel: - $ref: '#/channels/generatorIntegrationValue' - messages: - - $ref: '#/channels/generatorIntegrationValue/messages/valueMessage' - description: Publish integration values for Generator control points. - - receiveShuntValue: - action: receive - channel: - $ref: '#/channels/shuntValue' - messages: - - $ref: '#/channels/shuntValue/messages/valueMessage' - description: Subscribe to real-time BMS-published values for Shunt points. - - receiveShuntMetadata: - action: receive - channel: - $ref: '#/channels/shuntMetadata' - messages: - - $ref: '#/channels/shuntMetadata/messages/status' - - $ref: '#/channels/shuntMetadata/messages/available' - - $ref: '#/channels/shuntMetadata/messages/genericPoint' - description: Subscribe to BMS-published metadata for Shunt points. - - publishShuntIntegrationValue: - action: send - channel: - $ref: '#/channels/shuntIntegrationValue' - messages: - - $ref: '#/channels/shuntIntegrationValue/messages/valueMessage' - description: Publish integration values for Shunt control points. - - receiveBreakerValue: - action: receive - channel: - $ref: '#/channels/breakerValue' - messages: - - $ref: '#/channels/breakerValue/messages/valueMessage' - description: Subscribe to real-time BMS-published values for Breaker points. - - receiveBreakerMetadata: - action: receive - channel: - $ref: '#/channels/breakerMetadata' - messages: - - $ref: '#/channels/breakerMetadata/messages/status' - - $ref: '#/channels/breakerMetadata/messages/available' - - $ref: '#/channels/breakerMetadata/messages/genericPoint' - description: Subscribe to BMS-published metadata for Breaker points. - - publishBreakerIntegrationValue: - action: send - channel: - $ref: '#/channels/breakerIntegrationValue' - messages: - - $ref: '#/channels/breakerIntegrationValue/messages/valueMessage' - description: Publish integration values for Breaker control points. - - receiveCDUValue: - action: receive - channel: - $ref: '#/channels/cduValue' - messages: - - $ref: '#/channels/cduValue/messages/valueMessage' - description: Subscribe to real-time BMS-published values for CDU points. - - receiveCDUMetadata: - action: receive - channel: - $ref: '#/channels/cduMetadata' - messages: - - $ref: '#/channels/cduMetadata/messages/liquidTemperature' - - $ref: '#/channels/cduMetadata/messages/liquidDifferentialPressure' - - $ref: '#/channels/cduMetadata/messages/liquidFlow' - - $ref: '#/channels/cduMetadata/messages/liquidPressure' - - $ref: '#/channels/cduMetadata/messages/status' - - $ref: '#/channels/cduMetadata/messages/available' - - $ref: '#/channels/cduMetadata/messages/valvePosition' - - $ref: '#/channels/cduMetadata/messages/pumpSpeed' - - $ref: '#/channels/cduMetadata/messages/fanSpeed' - - $ref: '#/channels/cduMetadata/messages/damperPosition' - - $ref: '#/channels/cduMetadata/messages/airTemperature' - - $ref: '#/channels/cduMetadata/messages/airDifferentialPressure' - - $ref: '#/channels/cduMetadata/messages/airRelativeHumidity' - - $ref: '#/channels/cduMetadata/messages/airFlow' - - $ref: '#/channels/cduMetadata/messages/airPressure' - - $ref: '#/channels/cduMetadata/messages/leakDetect' - - $ref: '#/channels/cduMetadata/messages/liquidTemperatureSpRequest' - - $ref: '#/channels/cduMetadata/messages/genericPoint' - description: Subscribe to BMS-published metadata for CDU points. - - publishCDUIntegrationValue: - action: send - channel: - $ref: '#/channels/cduIntegrationValue' - messages: - - $ref: '#/channels/cduIntegrationValue/messages/valueMessage' - description: Publish integration values for CDU control points. - - receiveCoolingTowerValue: - action: receive - channel: - $ref: '#/channels/coolingTowerValue' - messages: - - $ref: '#/channels/coolingTowerValue/messages/valueMessage' - description: Subscribe to real-time BMS-published values for CoolingTower points. - - receiveCoolingTowerMetadata: - action: receive - channel: - $ref: '#/channels/coolingTowerMetadata' - messages: - - $ref: '#/channels/coolingTowerMetadata/messages/liquidTemperature' - - $ref: '#/channels/coolingTowerMetadata/messages/liquidDifferentialPressure' - - $ref: '#/channels/coolingTowerMetadata/messages/liquidFlow' - - $ref: '#/channels/coolingTowerMetadata/messages/liquidPressure' - - $ref: '#/channels/coolingTowerMetadata/messages/status' - - $ref: '#/channels/coolingTowerMetadata/messages/available' - - $ref: '#/channels/coolingTowerMetadata/messages/valvePosition' - - $ref: '#/channels/coolingTowerMetadata/messages/pumpSpeed' - - $ref: '#/channels/coolingTowerMetadata/messages/fanSpeed' - - $ref: '#/channels/coolingTowerMetadata/messages/damperPosition' - - $ref: '#/channels/coolingTowerMetadata/messages/airTemperature' - - $ref: '#/channels/coolingTowerMetadata/messages/airDifferentialPressure' - - $ref: '#/channels/coolingTowerMetadata/messages/airRelativeHumidity' - - $ref: '#/channels/coolingTowerMetadata/messages/airFlow' - - $ref: '#/channels/coolingTowerMetadata/messages/airPressure' - - $ref: '#/channels/coolingTowerMetadata/messages/leakDetect' - - $ref: '#/channels/coolingTowerMetadata/messages/genericPoint' - description: Subscribe to BMS-published metadata for CoolingTower points. - - publishCoolingTowerIntegrationValue: - action: send - channel: - $ref: '#/channels/coolingTowerIntegrationValue' - messages: - - $ref: '#/channels/coolingTowerIntegrationValue/messages/valueMessage' - description: Publish integration values for CoolingTower control points. - - receiveHXValue: - action: receive - channel: - $ref: '#/channels/hxValue' - messages: - - $ref: '#/channels/hxValue/messages/valueMessage' - description: Subscribe to real-time BMS-published values for HX points. - - receiveHXMetadata: - action: receive - channel: - $ref: '#/channels/hxMetadata' - messages: - - $ref: '#/channels/hxMetadata/messages/liquidTemperature' - - $ref: '#/channels/hxMetadata/messages/liquidDifferentialPressure' - - $ref: '#/channels/hxMetadata/messages/liquidFlow' - - $ref: '#/channels/hxMetadata/messages/liquidPressure' - - $ref: '#/channels/hxMetadata/messages/status' - - $ref: '#/channels/hxMetadata/messages/available' - - $ref: '#/channels/hxMetadata/messages/valvePosition' - - $ref: '#/channels/hxMetadata/messages/pumpSpeed' - - $ref: '#/channels/hxMetadata/messages/fanSpeed' - - $ref: '#/channels/hxMetadata/messages/damperPosition' - - $ref: '#/channels/hxMetadata/messages/airTemperature' - - $ref: '#/channels/hxMetadata/messages/airDifferentialPressure' - - $ref: '#/channels/hxMetadata/messages/airRelativeHumidity' - - $ref: '#/channels/hxMetadata/messages/airFlow' - - $ref: '#/channels/hxMetadata/messages/airPressure' - - $ref: '#/channels/hxMetadata/messages/leakDetect' - - $ref: '#/channels/hxMetadata/messages/genericPoint' - description: Subscribe to BMS-published metadata for HX points. - - publishHXIntegrationValue: - action: send - channel: - $ref: '#/channels/hxIntegrationValue' - messages: - - $ref: '#/channels/hxIntegrationValue/messages/valueMessage' - description: Publish integration values for HX control points. - - receiveCRAHValue: - action: receive - channel: - $ref: '#/channels/crahValue' - messages: - - $ref: '#/channels/crahValue/messages/valueMessage' - description: Subscribe to real-time BMS-published values for CRAH points. - - receiveCRAHMetadata: - action: receive - channel: - $ref: '#/channels/crahMetadata' - messages: - - $ref: '#/channels/crahMetadata/messages/liquidTemperature' - - $ref: '#/channels/crahMetadata/messages/liquidDifferentialPressure' - - $ref: '#/channels/crahMetadata/messages/liquidFlow' - - $ref: '#/channels/crahMetadata/messages/liquidPressure' - - $ref: '#/channels/crahMetadata/messages/status' - - $ref: '#/channels/crahMetadata/messages/available' - - $ref: '#/channels/crahMetadata/messages/valvePosition' - - $ref: '#/channels/crahMetadata/messages/pumpSpeed' - - $ref: '#/channels/crahMetadata/messages/fanSpeed' - - $ref: '#/channels/crahMetadata/messages/damperPosition' - - $ref: '#/channels/crahMetadata/messages/airTemperature' - - $ref: '#/channels/crahMetadata/messages/airDifferentialPressure' - - $ref: '#/channels/crahMetadata/messages/airRelativeHumidity' - - $ref: '#/channels/crahMetadata/messages/airFlow' - - $ref: '#/channels/crahMetadata/messages/airPressure' - - $ref: '#/channels/crahMetadata/messages/leakDetect' - - $ref: '#/channels/crahMetadata/messages/genericPoint' - description: Subscribe to BMS-published metadata for CRAH points. - - publishCRAHIntegrationValue: - action: send - channel: - $ref: '#/channels/crahIntegrationValue' - messages: - - $ref: '#/channels/crahIntegrationValue/messages/valueMessage' - description: Publish integration values for CRAH control points. - - receiveCRACValue: - action: receive - channel: - $ref: '#/channels/cracValue' - messages: - - $ref: '#/channels/cracValue/messages/valueMessage' - description: Subscribe to real-time BMS-published values for CRAC points. - - receiveCRACMetadata: - action: receive - channel: - $ref: '#/channels/cracMetadata' - messages: - - $ref: '#/channels/cracMetadata/messages/liquidTemperature' - - $ref: '#/channels/cracMetadata/messages/liquidDifferentialPressure' - - $ref: '#/channels/cracMetadata/messages/liquidFlow' - - $ref: '#/channels/cracMetadata/messages/liquidPressure' - - $ref: '#/channels/cracMetadata/messages/status' - - $ref: '#/channels/cracMetadata/messages/available' - - $ref: '#/channels/cracMetadata/messages/valvePosition' - - $ref: '#/channels/cracMetadata/messages/pumpSpeed' - - $ref: '#/channels/cracMetadata/messages/fanSpeed' - - $ref: '#/channels/cracMetadata/messages/damperPosition' - - $ref: '#/channels/cracMetadata/messages/airTemperature' - - $ref: '#/channels/cracMetadata/messages/airDifferentialPressure' - - $ref: '#/channels/cracMetadata/messages/airRelativeHumidity' - - $ref: '#/channels/cracMetadata/messages/airFlow' - - $ref: '#/channels/cracMetadata/messages/airPressure' - - $ref: '#/channels/cracMetadata/messages/leakDetect' - - $ref: '#/channels/cracMetadata/messages/genericPoint' - description: Subscribe to BMS-published metadata for CRAC points. - - publishCRACIntegrationValue: - action: send - channel: - $ref: '#/channels/cracIntegrationValue' - messages: - - $ref: '#/channels/cracIntegrationValue/messages/valueMessage' - description: Publish integration values for CRAC control points. - - receiveAHUValue: - action: receive - channel: - $ref: '#/channels/ahuValue' - messages: - - $ref: '#/channels/ahuValue/messages/valueMessage' - description: Subscribe to real-time BMS-published values for AHU points. - - receiveAHUMetadata: - action: receive - channel: - $ref: '#/channels/ahuMetadata' - messages: - - $ref: '#/channels/ahuMetadata/messages/liquidTemperature' - - $ref: '#/channels/ahuMetadata/messages/liquidDifferentialPressure' - - $ref: '#/channels/ahuMetadata/messages/liquidFlow' - - $ref: '#/channels/ahuMetadata/messages/liquidPressure' - - $ref: '#/channels/ahuMetadata/messages/status' - - $ref: '#/channels/ahuMetadata/messages/available' - - $ref: '#/channels/ahuMetadata/messages/valvePosition' - - $ref: '#/channels/ahuMetadata/messages/pumpSpeed' - - $ref: '#/channels/ahuMetadata/messages/fanSpeed' - - $ref: '#/channels/ahuMetadata/messages/damperPosition' - - $ref: '#/channels/ahuMetadata/messages/airTemperature' - - $ref: '#/channels/ahuMetadata/messages/airDifferentialPressure' - - $ref: '#/channels/ahuMetadata/messages/airRelativeHumidity' - - $ref: '#/channels/ahuMetadata/messages/airFlow' - - $ref: '#/channels/ahuMetadata/messages/airPressure' - - $ref: '#/channels/ahuMetadata/messages/leakDetect' - - $ref: '#/channels/ahuMetadata/messages/genericPoint' - description: Subscribe to BMS-published metadata for AHU points. - - publishAHUIntegrationValue: - action: send - channel: - $ref: '#/channels/ahuIntegrationValue' - messages: - - $ref: '#/channels/ahuIntegrationValue/messages/valueMessage' - description: Publish integration values for AHU control points. - - receiveChillerValue: - action: receive - channel: - $ref: '#/channels/chillerValue' - messages: - - $ref: '#/channels/chillerValue/messages/valueMessage' - description: Subscribe to real-time BMS-published values for Chiller points. - - receiveChillerMetadata: - action: receive - channel: - $ref: '#/channels/chillerMetadata' - messages: - - $ref: '#/channels/chillerMetadata/messages/liquidTemperature' - - $ref: '#/channels/chillerMetadata/messages/liquidDifferentialPressure' - - $ref: '#/channels/chillerMetadata/messages/liquidFlow' - - $ref: '#/channels/chillerMetadata/messages/liquidPressure' - - $ref: '#/channels/chillerMetadata/messages/status' - - $ref: '#/channels/chillerMetadata/messages/available' - - $ref: '#/channels/chillerMetadata/messages/valvePosition' - - $ref: '#/channels/chillerMetadata/messages/pumpSpeed' - - $ref: '#/channels/chillerMetadata/messages/fanSpeed' - - $ref: '#/channels/chillerMetadata/messages/damperPosition' - - $ref: '#/channels/chillerMetadata/messages/airTemperature' - - $ref: '#/channels/chillerMetadata/messages/airDifferentialPressure' - - $ref: '#/channels/chillerMetadata/messages/airRelativeHumidity' - - $ref: '#/channels/chillerMetadata/messages/airFlow' - - $ref: '#/channels/chillerMetadata/messages/airPressure' - - $ref: '#/channels/chillerMetadata/messages/leakDetect' - - $ref: '#/channels/chillerMetadata/messages/genericPoint' - description: Subscribe to BMS-published metadata for Chiller points. - - publishChillerIntegrationValue: - action: send - channel: - $ref: '#/channels/chillerIntegrationValue' - messages: - - $ref: '#/channels/chillerIntegrationValue/messages/valueMessage' - description: Publish integration values for Chiller control points. - - receiveValveValue: - action: receive - channel: - $ref: '#/channels/valveValue' - messages: - - $ref: '#/channels/valveValue/messages/valueMessage' - description: Subscribe to real-time BMS-published values for Valve points. - - receiveValveMetadata: - action: receive - channel: - $ref: '#/channels/valveMetadata' - messages: - - $ref: '#/channels/valveMetadata/messages/valvePosition' - - $ref: '#/channels/valveMetadata/messages/available' - - $ref: '#/channels/valveMetadata/messages/genericPoint' - description: Subscribe to BMS-published metadata for Valve points. - - publishValveIntegrationValue: - action: send - channel: - $ref: '#/channels/valveIntegrationValue' - messages: - - $ref: '#/channels/valveIntegrationValue/messages/valueMessage' - description: Publish integration values for Valve control points. - - receivePumpValue: - action: receive - channel: - $ref: '#/channels/pumpValue' - messages: - - $ref: '#/channels/pumpValue/messages/valueMessage' - description: Subscribe to real-time BMS-published values for Pump points. - - receivePumpMetadata: - action: receive - channel: - $ref: '#/channels/pumpMetadata' - messages: - - $ref: '#/channels/pumpMetadata/messages/pumpSpeed' - - $ref: '#/channels/pumpMetadata/messages/available' - - $ref: '#/channels/pumpMetadata/messages/genericPoint' - description: Subscribe to BMS-published metadata for Pump points. - - publishPumpIntegrationValue: - action: send - channel: - $ref: '#/channels/pumpIntegrationValue' - messages: - - $ref: '#/channels/pumpIntegrationValue/messages/valueMessage' - description: Publish integration values for Pump control points. - - receiveFanValue: - action: receive - channel: - $ref: '#/channels/fanValue' - messages: - - $ref: '#/channels/fanValue/messages/valueMessage' - description: Subscribe to real-time BMS-published values for Fan points. - - receiveFanMetadata: - action: receive - channel: - $ref: '#/channels/fanMetadata' - messages: - - $ref: '#/channels/fanMetadata/messages/fanSpeed' - - $ref: '#/channels/fanMetadata/messages/available' - - $ref: '#/channels/fanMetadata/messages/genericPoint' - description: Subscribe to BMS-published metadata for Fan points. - - publishFanIntegrationValue: - action: send - channel: - $ref: '#/channels/fanIntegrationValue' - messages: - - $ref: '#/channels/fanIntegrationValue/messages/valueMessage' - description: Publish integration values for Fan control points. - - receiveDamperValue: - action: receive - channel: - $ref: '#/channels/damperValue' - messages: - - $ref: '#/channels/damperValue/messages/valueMessage' - description: Subscribe to real-time BMS-published values for Damper points. - - receiveDamperMetadata: - action: receive - channel: - $ref: '#/channels/damperMetadata' - messages: - - $ref: '#/channels/damperMetadata/messages/damperPosition' - - $ref: '#/channels/damperMetadata/messages/available' - - $ref: '#/channels/damperMetadata/messages/genericPoint' - description: Subscribe to BMS-published metadata for Damper points. - - publishDamperIntegrationValue: - action: send - channel: - $ref: '#/channels/damperIntegrationValue' - messages: - - $ref: '#/channels/damperIntegrationValue/messages/valueMessage' - description: Publish integration values for Damper control points. - - receiveSensorValue: - action: receive - channel: - $ref: '#/channels/sensorValue' - messages: - - $ref: '#/channels/sensorValue/messages/valueMessage' - description: Subscribe to real-time BMS-published values for Sensor points. - - receiveSensorMetadata: - action: receive - channel: - $ref: '#/channels/sensorMetadata' - messages: - - $ref: '#/channels/sensorMetadata/messages/liquidTemperature' - - $ref: '#/channels/sensorMetadata/messages/liquidDifferentialPressure' - - $ref: '#/channels/sensorMetadata/messages/liquidFlow' - - $ref: '#/channels/sensorMetadata/messages/liquidPressure' - - $ref: '#/channels/sensorMetadata/messages/airTemperature' - - $ref: '#/channels/sensorMetadata/messages/airDifferentialPressure' - - $ref: '#/channels/sensorMetadata/messages/airRelativeHumidity' - - $ref: '#/channels/sensorMetadata/messages/airFlow' - - $ref: '#/channels/sensorMetadata/messages/airPressure' - - $ref: '#/channels/sensorMetadata/messages/leakDetect' - - $ref: '#/channels/sensorMetadata/messages/available' - - $ref: '#/channels/sensorMetadata/messages/sound' - - $ref: '#/channels/sensorMetadata/messages/genericPoint' - description: Subscribe to BMS-published metadata for Sensor points. - - - publishSensorIntegrationValue: - action: send - channel: - $ref: '#/channels/sensorIntegrationValue' - messages: - - $ref: '#/channels/sensorIntegrationValue/messages/valueMessage' - description: Publish integration values for Sensor control points. - - receiveTankValue: - action: receive - channel: - $ref: '#/channels/tankValue' - messages: - - $ref: '#/channels/tankValue/messages/valueMessage' - description: Subscribe to real-time BMS-published values for Tank points. - - receiveTankMetadata: - action: receive - channel: - $ref: '#/channels/tankMetadata' - messages: - - $ref: '#/channels/tankMetadata/messages/liquidTemperature' - - $ref: '#/channels/tankMetadata/messages/liquidDifferentialPressure' - - $ref: '#/channels/tankMetadata/messages/liquidFlow' - - $ref: '#/channels/tankMetadata/messages/liquidPressure' - - $ref: '#/channels/tankMetadata/messages/status' - - $ref: '#/channels/tankMetadata/messages/available' - - $ref: '#/channels/tankMetadata/messages/valvePosition' - - $ref: '#/channels/tankMetadata/messages/pumpSpeed' - - $ref: '#/channels/tankMetadata/messages/fanSpeed' - - $ref: '#/channels/tankMetadata/messages/damperPosition' - - $ref: '#/channels/tankMetadata/messages/airTemperature' - - $ref: '#/channels/tankMetadata/messages/airDifferentialPressure' - - $ref: '#/channels/tankMetadata/messages/airRelativeHumidity' - - $ref: '#/channels/tankMetadata/messages/airFlow' - - $ref: '#/channels/tankMetadata/messages/airPressure' - - $ref: '#/channels/tankMetadata/messages/leakDetect' - - $ref: '#/channels/tankMetadata/messages/genericPoint' - description: Subscribe to BMS-published metadata for Tank points. - - publishTankIntegrationValue: - action: send - channel: - $ref: '#/channels/tankIntegrationValue' - messages: - - $ref: '#/channels/tankIntegrationValue/messages/valueMessage' - description: Publish integration values for Tank control points. - - receiveGenericObjectValue: - action: receive - channel: - $ref: '#/channels/genericObjectValue' - messages: - - $ref: '#/channels/genericObjectValue/messages/valueMessage' - description: Subscribe to real-time BMS-published values for GenericObject points. - - receiveGenericObjectMetadata: - action: receive - channel: - $ref: '#/channels/genericObjectMetadata' - messages: - - $ref: '#/channels/genericObjectMetadata/messages/liquidTemperature' - - $ref: '#/channels/genericObjectMetadata/messages/liquidDifferentialPressure' - - $ref: '#/channels/genericObjectMetadata/messages/liquidFlow' - - $ref: '#/channels/genericObjectMetadata/messages/liquidPressure' - - $ref: '#/channels/genericObjectMetadata/messages/status' - - $ref: '#/channels/genericObjectMetadata/messages/available' - - $ref: '#/channels/genericObjectMetadata/messages/valvePosition' - - $ref: '#/channels/genericObjectMetadata/messages/pumpSpeed' - - $ref: '#/channels/genericObjectMetadata/messages/fanSpeed' - - $ref: '#/channels/genericObjectMetadata/messages/damperPosition' - - $ref: '#/channels/genericObjectMetadata/messages/airTemperature' - - $ref: '#/channels/genericObjectMetadata/messages/airDifferentialPressure' - - $ref: '#/channels/genericObjectMetadata/messages/airRelativeHumidity' - - $ref: '#/channels/genericObjectMetadata/messages/airFlow' - - $ref: '#/channels/genericObjectMetadata/messages/airPressure' - - $ref: '#/channels/genericObjectMetadata/messages/leakDetect' - - $ref: '#/channels/genericObjectMetadata/messages/liquidTemperatureSpRequest' - - $ref: '#/channels/genericObjectMetadata/messages/sound' - - $ref: '#/channels/genericObjectMetadata/messages/genericPoint' - description: Subscribe to BMS-published metadata for GenericObject points. - - publishGenericObjectIntegrationValue: - action: send - channel: - $ref: '#/channels/genericObjectIntegrationValue' - messages: - - $ref: '#/channels/genericObjectIntegrationValue/messages/valueMessage' - description: Publish integration values for GenericObject control points. - - receiveSystemBmsValue: - action: receive - channel: - $ref: '#/channels/systemBmsValue' - messages: - - $ref: '#/channels/systemBmsValue/messages/valueMessage' - description: Subscribe to BMS System values (heartbeat timestamps, status, etc.). - - receiveSystemMetadata: - action: receive - channel: - $ref: '#/channels/systemMetadata' - messages: - - $ref: '#/channels/systemMetadata/messages/heartbeatTimestampBms' - - $ref: '#/channels/systemMetadata/messages/heartbeatEchoBms' - - $ref: '#/channels/systemMetadata/messages/heartbeatTimestampIntegration' - - $ref: '#/channels/systemMetadata/messages/heartbeatEchoIntegration' - - $ref: '#/channels/systemMetadata/messages/status' - - $ref: '#/channels/systemMetadata/messages/available' - - $ref: '#/channels/systemMetadata/messages/genericPoint' - description: Subscribe to System metadata for all System point types. - - publishSystemIntegrationValue: - action: send - channel: - $ref: '#/channels/systemIntegrationValue' - messages: - - $ref: '#/channels/systemIntegrationValue/messages/valueMessage' - description: Publish integration System values to topics derived from BMS metadata. - - - - -# ============================================================================= -# Components -# ============================================================================= - -components: - - messages: - - ValueMessage: - name: ValueMessage - title: Point Value - description: | - Live value message. Payload envelope is identical for all point types. - The semantic meaning of `value` is determined by the corresponding - metadata message. - payload: - type: object - required: - - value - - timestamp - - quality - properties: - value: - oneOf: - - type: number - - type: 'null' - description: > - Live reading for the point (float). May be null when the BMS - has no valid reading available. - examples: - - 22.96215 - - 0.239 - - null - - 1 - - timestamp: - type: integer - description: Unix timestamp in epoch milliseconds (source event time). - examples: - - 1743620423000 - quality: - type: integer - description: > - `1` = good quality. Any other integer indicates value is not trustworthy - examples: - - 1 - - 0 - additionalProperties: false - - # Rack - RackLiquidSupplyTemperatureMsg: - name: RackLiquidSupplyTemperatureMsg - title: Rack RackLiquidSupplyTemperature Metadata - payload: - $ref: '#/components/schemas/RackLiquidSupplyTemperatureMetadata' - RackLiquidReturnTemperatureMsg: - name: RackLiquidReturnTemperatureMsg - title: Rack RackLiquidReturnTemperature Metadata - payload: - $ref: '#/components/schemas/RackLiquidReturnTemperatureMetadata' - RackLiquidFlowMsg: - name: RackLiquidFlowMsg - title: Rack RackLiquidFlow Metadata - payload: - $ref: '#/components/schemas/RackLiquidFlowMetadata' - RackLiquidDifferentialPressureMsg: - name: RackLiquidDifferentialPressureMsg - title: Rack RackLiquidDifferentialPressure Metadata - payload: - $ref: '#/components/schemas/RackLiquidDifferentialPressureMetadata' - RackControlValvePositionMsg: - name: RackControlValvePositionMsg - title: Rack RackControlValvePosition Metadata - payload: - $ref: '#/components/schemas/RackControlValvePositionMetadata' - RackPowerMsg: - name: RackPowerMsg - title: Rack RackPower Metadata - payload: - $ref: '#/components/schemas/RackPowerMetadata' - RackLeakDetectMsg: - name: RackLeakDetectMsg - title: Rack RackLeakDetect Metadata - payload: - $ref: '#/components/schemas/RackLeakDetectMetadata' - RackLeakSensorFaultMsg: - name: RackLeakSensorFaultMsg - title: Rack RackLeakSensorFault Metadata - payload: - $ref: '#/components/schemas/RackLeakSensorFaultMetadata' - RackLiquidIsolationStatusMsg: - name: RackLiquidIsolationStatusMsg - title: Rack RackLiquidIsolationStatus Metadata - payload: - $ref: '#/components/schemas/RackLiquidIsolationStatusMetadata' - RackElectricalIsolationStatusMsg: - name: RackElectricalIsolationStatusMsg - title: Rack RackElectricalIsolationStatus Metadata - payload: - $ref: '#/components/schemas/RackElectricalIsolationStatusMetadata' - RackLeakDetectTrayMsg: - name: RackLeakDetectTrayMsg - title: Rack RackLeakDetectTray Metadata - payload: - $ref: '#/components/schemas/RackLeakDetectTrayMetadata' - RackLiquidIsolationRequestMsg: - name: RackLiquidIsolationRequestMsg - title: Rack RackLiquidIsolationRequest Metadata - payload: - $ref: '#/components/schemas/RackLiquidIsolationRequestMetadata' - RackElectricalIsolationRequestMsg: - name: RackElectricalIsolationRequestMsg - title: Rack RackElectricalIsolationRequest Metadata - payload: - $ref: '#/components/schemas/RackElectricalIsolationRequestMetadata' - - # PowerMeter - PowerMeterVoltageMsg: - name: PowerMeterVoltageMsg - title: PowerMeter Voltage Metadata - payload: - $ref: '#/components/schemas/PowerMeterVoltageMetadata' - PowerMeterPowerFactorMsg: - name: PowerMeterPowerFactorMsg - title: PowerMeter PowerFactor Metadata - payload: - $ref: '#/components/schemas/PowerMeterPowerFactorMetadata' - PowerMeterFrequencyMsg: - name: PowerMeterFrequencyMsg - title: PowerMeter Frequency Metadata - payload: - $ref: '#/components/schemas/PowerMeterFrequencyMetadata' - PowerMeterApparentPowerMsg: - name: PowerMeterApparentPowerMsg - title: PowerMeter ApparentPower Metadata - payload: - $ref: '#/components/schemas/PowerMeterApparentPowerMetadata' - PowerMeterActivePowerMsg: - name: PowerMeterActivePowerMsg - title: PowerMeter ActivePower Metadata - payload: - $ref: '#/components/schemas/PowerMeterActivePowerMetadata' - PowerMeterCurrentMsg: - name: PowerMeterCurrentMsg - title: PowerMeter Current Metadata - payload: - $ref: '#/components/schemas/PowerMeterCurrentMetadata' - PowerMeterCurrentLimitMsg: - name: PowerMeterCurrentLimitMsg - title: PowerMeter CurrentLimit Metadata - payload: - $ref: '#/components/schemas/PowerMeterCurrentLimitMetadata' - PowerMeterPhaseCurrentMsg: - name: PowerMeterPhaseCurrentMsg - title: PowerMeter PhaseCurrent Metadata - payload: - $ref: '#/components/schemas/PowerMeterPhaseCurrentMetadata' - - # Generic Equipment - LiquidTemperatureMsg: - name: LiquidTemperatureMsg - title: LiquidTemperature Metadata - payload: - $ref: '#/components/schemas/LiquidTemperatureMetadata' - LiquidDifferentialPressureMsg: - name: LiquidDifferentialPressureMsg - title: LiquidDifferentialPressure Metadata - payload: - $ref: '#/components/schemas/LiquidDifferentialPressureMetadata' - LiquidFlowMsg: - name: LiquidFlowMsg - title: LiquidFlow Metadata - payload: - $ref: '#/components/schemas/LiquidFlowMetadata' - LiquidPressureMsg: - name: LiquidPressureMsg - title: LiquidPressure Metadata - payload: - $ref: '#/components/schemas/LiquidPressureMetadata' - StatusMsg: - name: StatusMsg - title: Status Metadata - payload: - $ref: '#/components/schemas/StatusMetadata' - AvailableMsg: - name: AvailableMsg - title: Available Metadata - payload: - $ref: '#/components/schemas/AvailableMetadata' - ValvePositionMsg: - name: ValvePositionMsg - title: ValvePosition Metadata - payload: - $ref: '#/components/schemas/ValvePositionMetadata' - PumpSpeedMsg: - name: PumpSpeedMsg - title: PumpSpeed Metadata - payload: - $ref: '#/components/schemas/PumpSpeedMetadata' - FanSpeedMsg: - name: FanSpeedMsg - title: FanSpeed Metadata - payload: - $ref: '#/components/schemas/FanSpeedMetadata' - DamperPositionMsg: - name: DamperPositionMsg - title: DamperPosition Metadata - payload: - $ref: '#/components/schemas/DamperPositionMetadata' - AirTemperatureMsg: - name: AirTemperatureMsg - title: AirTemperature Metadata - payload: - $ref: '#/components/schemas/AirTemperatureMetadata' - AirDifferentialPressureMsg: - name: AirDifferentialPressureMsg - title: AirDifferentialPressure Metadata - payload: - $ref: '#/components/schemas/AirDifferentialPressureMetadata' - AirFlowMsg: - name: AirFlowMsg - title: AirFlow Metadata - payload: - $ref: '#/components/schemas/AirFlowMetadata' - AirPressureMsg: - name: AirPressureMsg - title: AirPressure Metadata - payload: - $ref: '#/components/schemas/AirPressureMetadata' - LiquidTemperatureSpRequestMsg: - name: LiquidTemperatureSpRequestMsg - title: CDU LiquidTemperatureSpRequest Metadata - payload: - $ref: '#/components/schemas/LiquidTemperatureSpRequestMetadata' - SoundMsg: - name: SoundMsg - title: Sound Metadata - payload: - $ref: '#/components/schemas/SoundMetadata' - - # System - SystemHeartbeatTimestampBmsMsg: - name: SystemHeartbeatTimestampBmsMsg - title: HeartbeatTimestampBms Metadata - payload: - $ref: '#/components/schemas/SystemHeartbeatTimestampBmsMetadata' - SystemHeartbeatEchoBmsMsg: - name: SystemHeartbeatEchoBmsMsg - title: HeartbeatEchoBms Metadata - payload: - $ref: '#/components/schemas/SystemHeartbeatEchoBmsMetadata' - SystemHeartbeatTimestampIntegrationMsg: - name: SystemHeartbeatTimestampIntegrationMsg - title: HeartbeatTimestampIntegration Metadata - payload: - $ref: '#/components/schemas/SystemHeartbeatTimestampIntegrationMetadata' - SystemHeartbeatEchoIntegrationMsg: - name: SystemHeartbeatEchoIntegrationMsg - title: HeartbeatEchoIntegration Metadata - payload: - $ref: '#/components/schemas/SystemHeartbeatEchoIntegrationMetadata' - SystemStatusMsg: - name: SystemStatusMsg - title: System Status Metadata - payload: - $ref: '#/components/schemas/SystemStatusMetadata' - SystemAvailableMsg: - name: SystemAvailableMsg - title: System Available Metadata - payload: - $ref: '#/components/schemas/SystemAvailableMetadata' - - - # Generic Equipment - # --- Per-objectType messages (objectType constraint) --- - - BESSStatusMsg: - name: BESSStatusMsg - title: BESS Status Metadata - payload: - $ref: '#/components/schemas/BESSStatusMetadata' - - BESSAvailableMsg: - name: BESSAvailableMsg - title: BESS Available Metadata - payload: - $ref: '#/components/schemas/BESSAvailableMetadata' - - UPSStatusMsg: - name: UPSStatusMsg - title: UPS Status Metadata - payload: - $ref: '#/components/schemas/UPSStatusMetadata' - - UPSAvailableMsg: - name: UPSAvailableMsg - title: UPS Available Metadata - payload: - $ref: '#/components/schemas/UPSAvailableMetadata' - - ATSStatusMsg: - name: ATSStatusMsg - title: ATS Status Metadata - payload: - $ref: '#/components/schemas/ATSStatusMetadata' - - ATSAvailableMsg: - name: ATSAvailableMsg - title: ATS Available Metadata - payload: - $ref: '#/components/schemas/ATSAvailableMetadata' - - GeneratorStatusMsg: - name: GeneratorStatusMsg - title: Generator Status Metadata - payload: - $ref: '#/components/schemas/GeneratorStatusMetadata' - - GeneratorAvailableMsg: - name: GeneratorAvailableMsg - title: Generator Available Metadata - payload: - $ref: '#/components/schemas/GeneratorAvailableMetadata' - - ShuntStatusMsg: - name: ShuntStatusMsg - title: Shunt Status Metadata - payload: - $ref: '#/components/schemas/ShuntStatusMetadata' - - ShuntAvailableMsg: - name: ShuntAvailableMsg - title: Shunt Available Metadata - payload: - $ref: '#/components/schemas/ShuntAvailableMetadata' - - BreakerStatusMsg: - name: BreakerStatusMsg - title: Breaker Status Metadata - payload: - $ref: '#/components/schemas/BreakerStatusMetadata' - - BreakerAvailableMsg: - name: BreakerAvailableMsg - title: Breaker Available Metadata - payload: - $ref: '#/components/schemas/BreakerAvailableMetadata' - - ValveValvePositionMsg: - name: ValveValvePositionMsg - title: Valve ValvePosition Metadata - payload: - $ref: '#/components/schemas/ValveValvePositionMetadata' - - PumpPumpSpeedMsg: - name: PumpPumpSpeedMsg - title: Pump PumpSpeed Metadata - payload: - $ref: '#/components/schemas/PumpPumpSpeedMetadata' - - FanFanSpeedMsg: - name: FanFanSpeedMsg - title: Fan FanSpeed Metadata - payload: - $ref: '#/components/schemas/FanFanSpeedMetadata' - - DamperDamperPositionMsg: - name: DamperDamperPositionMsg - title: Damper DamperPosition Metadata - payload: - $ref: '#/components/schemas/DamperDamperPositionMetadata' - - SensorLiquidTemperatureMsg: - name: SensorLiquidTemperatureMsg - title: Sensor LiquidTemperature Metadata - payload: - $ref: '#/components/schemas/SensorLiquidTemperatureMetadata' - - SensorLiquidDifferentialPressureMsg: - name: SensorLiquidDifferentialPressureMsg - title: Sensor LiquidDifferentialPressure Metadata - payload: - $ref: '#/components/schemas/SensorLiquidDifferentialPressureMetadata' - - SensorLiquidFlowMsg: - name: SensorLiquidFlowMsg - title: Sensor LiquidFlow Metadata - payload: - $ref: '#/components/schemas/SensorLiquidFlowMetadata' - - SensorLiquidPressureMsg: - name: SensorLiquidPressureMsg - title: Sensor LiquidPressure Metadata - payload: - $ref: '#/components/schemas/SensorLiquidPressureMetadata' - - SensorAirTemperatureMsg: - name: SensorAirTemperatureMsg - title: Sensor AirTemperature Metadata - payload: - $ref: '#/components/schemas/SensorAirTemperatureMetadata' - - SensorAirDifferentialPressureMsg: - name: SensorAirDifferentialPressureMsg - title: Sensor AirDifferentialPressure Metadata - payload: - $ref: '#/components/schemas/SensorAirDifferentialPressureMetadata' - - SensorAirFlowMsg: - name: SensorAirFlowMsg - title: Sensor AirFlow Metadata - payload: - $ref: '#/components/schemas/SensorAirFlowMetadata' - - SensorAirPressureMsg: - name: SensorAirPressureMsg - title: Sensor AirPressure Metadata - payload: - $ref: '#/components/schemas/SensorAirPressureMetadata' - - SensorSoundMsg: - name: SensorSoundMsg - title: Sensor Sound Metadata - payload: - $ref: '#/components/schemas/SensorSoundMetadata' - - ValveAvailableMsg: - name: ValveAvailableMsg - title: Valve Available Metadata - payload: - $ref: '#/components/schemas/ValveAvailableMetadata' - - PumpAvailableMsg: - name: PumpAvailableMsg - title: Pump Available Metadata - payload: - $ref: '#/components/schemas/PumpAvailableMetadata' - - FanAvailableMsg: - name: FanAvailableMsg - title: Fan Available Metadata - payload: - $ref: '#/components/schemas/FanAvailableMetadata' - - DamperAvailableMsg: - name: DamperAvailableMsg - title: Damper Available Metadata - payload: - $ref: '#/components/schemas/DamperAvailableMetadata' - - SensorAvailableMsg: - name: SensorAvailableMsg - title: Sensor Available Metadata - payload: - $ref: '#/components/schemas/SensorAvailableMetadata' - - CDULeakDetectMsg: - name: CDULeakDetectMsg - title: CDU LeakDetect Metadata - payload: - $ref: '#/components/schemas/CDULeakDetectMetadata' - - CoolingTowerLeakDetectMsg: - name: CoolingTowerLeakDetectMsg - title: CoolingTower LeakDetect Metadata - payload: - $ref: '#/components/schemas/CoolingTowerLeakDetectMetadata' - - HXLeakDetectMsg: - name: HXLeakDetectMsg - title: HX LeakDetect Metadata - payload: - $ref: '#/components/schemas/HXLeakDetectMetadata' - - CRAHLeakDetectMsg: - name: CRAHLeakDetectMsg - title: CRAH LeakDetect Metadata - payload: - $ref: '#/components/schemas/CRAHLeakDetectMetadata' - - CRACLeakDetectMsg: - name: CRACLeakDetectMsg - title: CRAC LeakDetect Metadata - payload: - $ref: '#/components/schemas/CRACLeakDetectMetadata' - - AHULeakDetectMsg: - name: AHULeakDetectMsg - title: AHU LeakDetect Metadata - payload: - $ref: '#/components/schemas/AHULeakDetectMetadata' - - ChillerLeakDetectMsg: - name: ChillerLeakDetectMsg - title: Chiller LeakDetect Metadata - payload: - $ref: '#/components/schemas/ChillerLeakDetectMetadata' - - TankLeakDetectMsg: - name: TankLeakDetectMsg - title: Tank LeakDetect Metadata - payload: - $ref: '#/components/schemas/TankLeakDetectMetadata' - - SensorLeakDetectMsg: - name: SensorLeakDetectMsg - title: Sensor LeakDetect Metadata - payload: - $ref: '#/components/schemas/SensorLeakDetectMetadata' - - GenericObjectLeakDetectMsg: - name: GenericObjectLeakDetectMsg - title: GenericObject LeakDetect Metadata - payload: - $ref: '#/components/schemas/GenericObjectLeakDetectMetadata' - - CDUAirRelativeHumidityMsg: - name: CDUAirRelativeHumidityMsg - title: CDU AirRelativeHumidity Metadata - payload: - $ref: '#/components/schemas/CDUAirRelativeHumidityMetadata' - - CoolingTowerAirRelativeHumidityMsg: - name: CoolingTowerAirRelativeHumidityMsg - title: CoolingTower AirRelativeHumidity Metadata - payload: - $ref: '#/components/schemas/CoolingTowerAirRelativeHumidityMetadata' - - HXAirRelativeHumidityMsg: - name: HXAirRelativeHumidityMsg - title: HX AirRelativeHumidity Metadata - payload: - $ref: '#/components/schemas/HXAirRelativeHumidityMetadata' - - CRAHAirRelativeHumidityMsg: - name: CRAHAirRelativeHumidityMsg - title: CRAH AirRelativeHumidity Metadata - payload: - $ref: '#/components/schemas/CRAHAirRelativeHumidityMetadata' - - CRACAirRelativeHumidityMsg: - name: CRACAirRelativeHumidityMsg - title: CRAC AirRelativeHumidity Metadata - payload: - $ref: '#/components/schemas/CRACAirRelativeHumidityMetadata' - - AHUAirRelativeHumidityMsg: - name: AHUAirRelativeHumidityMsg - title: AHU AirRelativeHumidity Metadata - payload: - $ref: '#/components/schemas/AHUAirRelativeHumidityMetadata' - - ChillerAirRelativeHumidityMsg: - name: ChillerAirRelativeHumidityMsg - title: Chiller AirRelativeHumidity Metadata - payload: - $ref: '#/components/schemas/ChillerAirRelativeHumidityMetadata' - - TankAirRelativeHumidityMsg: - name: TankAirRelativeHumidityMsg - title: Tank AirRelativeHumidity Metadata - payload: - $ref: '#/components/schemas/TankAirRelativeHumidityMetadata' - - SensorAirRelativeHumidityMsg: - name: SensorAirRelativeHumidityMsg - title: Sensor AirRelativeHumidity Metadata - payload: - $ref: '#/components/schemas/SensorAirRelativeHumidityMetadata' - - GenericObjectAirRelativeHumidityMsg: - name: GenericObjectAirRelativeHumidityMsg - title: GenericObject AirRelativeHumidity Metadata - payload: - $ref: '#/components/schemas/GenericObjectAirRelativeHumidityMetadata' - - RackLiquidDifferentialPressureSpMsg: - name: RackLiquidDifferentialPressureSpMsg - title: Rack RackLiquidDifferentialPressureSp Metadata - payload: - $ref: '#/components/schemas/RackLiquidDifferentialPressureSpMetadata' - - CDULiquidTemperatureMsg: - name: CDULiquidTemperatureMsg - title: CDU LiquidTemperature Metadata - payload: - $ref: '#/components/schemas/CDULiquidTemperatureMetadata' - - CDULiquidDifferentialPressureMsg: - name: CDULiquidDifferentialPressureMsg - title: CDU LiquidDifferentialPressure Metadata - payload: - $ref: '#/components/schemas/CDULiquidDifferentialPressureMetadata' - - CDULiquidFlowMsg: - name: CDULiquidFlowMsg - title: CDU LiquidFlow Metadata - payload: - $ref: '#/components/schemas/CDULiquidFlowMetadata' - - CDULiquidPressureMsg: - name: CDULiquidPressureMsg - title: CDU LiquidPressure Metadata - payload: - $ref: '#/components/schemas/CDULiquidPressureMetadata' - - CDUStatusMsg: - name: CDUStatusMsg - title: CDU Status Metadata - payload: - $ref: '#/components/schemas/CDUStatusMetadata' - - CDUAvailableMsg: - name: CDUAvailableMsg - title: CDU Available Metadata - payload: - $ref: '#/components/schemas/CDUAvailableMetadata' - - CDUValvePositionMsg: - name: CDUValvePositionMsg - title: CDU ValvePosition Metadata - payload: - $ref: '#/components/schemas/CDUValvePositionMetadata' - - CDUPumpSpeedMsg: - name: CDUPumpSpeedMsg - title: CDU PumpSpeed Metadata - payload: - $ref: '#/components/schemas/CDUPumpSpeedMetadata' - - CDUFanSpeedMsg: - name: CDUFanSpeedMsg - title: CDU FanSpeed Metadata - payload: - $ref: '#/components/schemas/CDUFanSpeedMetadata' - - CDUDamperPositionMsg: - name: CDUDamperPositionMsg - title: CDU DamperPosition Metadata - payload: - $ref: '#/components/schemas/CDUDamperPositionMetadata' - - CDUAirTemperatureMsg: - name: CDUAirTemperatureMsg - title: CDU AirTemperature Metadata - payload: - $ref: '#/components/schemas/CDUAirTemperatureMetadata' - - CDUAirDifferentialPressureMsg: - name: CDUAirDifferentialPressureMsg - title: CDU AirDifferentialPressure Metadata - payload: - $ref: '#/components/schemas/CDUAirDifferentialPressureMetadata' - - CDUAirFlowMsg: - name: CDUAirFlowMsg - title: CDU AirFlow Metadata - payload: - $ref: '#/components/schemas/CDUAirFlowMetadata' - - CDUAirPressureMsg: - name: CDUAirPressureMsg - title: CDU AirPressure Metadata - payload: - $ref: '#/components/schemas/CDUAirPressureMetadata' - - CoolingTowerLiquidTemperatureMsg: - name: CoolingTowerLiquidTemperatureMsg - title: CoolingTower LiquidTemperature Metadata - payload: - $ref: '#/components/schemas/CoolingTowerLiquidTemperatureMetadata' - - CoolingTowerLiquidDifferentialPressureMsg: - name: CoolingTowerLiquidDifferentialPressureMsg - title: CoolingTower LiquidDifferentialPressure Metadata - payload: - $ref: '#/components/schemas/CoolingTowerLiquidDifferentialPressureMetadata' - - CoolingTowerLiquidFlowMsg: - name: CoolingTowerLiquidFlowMsg - title: CoolingTower LiquidFlow Metadata - payload: - $ref: '#/components/schemas/CoolingTowerLiquidFlowMetadata' - - CoolingTowerLiquidPressureMsg: - name: CoolingTowerLiquidPressureMsg - title: CoolingTower LiquidPressure Metadata - payload: - $ref: '#/components/schemas/CoolingTowerLiquidPressureMetadata' - - CoolingTowerStatusMsg: - name: CoolingTowerStatusMsg - title: CoolingTower Status Metadata - payload: - $ref: '#/components/schemas/CoolingTowerStatusMetadata' - - CoolingTowerAvailableMsg: - name: CoolingTowerAvailableMsg - title: CoolingTower Available Metadata - payload: - $ref: '#/components/schemas/CoolingTowerAvailableMetadata' - - CoolingTowerValvePositionMsg: - name: CoolingTowerValvePositionMsg - title: CoolingTower ValvePosition Metadata - payload: - $ref: '#/components/schemas/CoolingTowerValvePositionMetadata' - - CoolingTowerPumpSpeedMsg: - name: CoolingTowerPumpSpeedMsg - title: CoolingTower PumpSpeed Metadata - payload: - $ref: '#/components/schemas/CoolingTowerPumpSpeedMetadata' - - CoolingTowerFanSpeedMsg: - name: CoolingTowerFanSpeedMsg - title: CoolingTower FanSpeed Metadata - payload: - $ref: '#/components/schemas/CoolingTowerFanSpeedMetadata' - - CoolingTowerDamperPositionMsg: - name: CoolingTowerDamperPositionMsg - title: CoolingTower DamperPosition Metadata - payload: - $ref: '#/components/schemas/CoolingTowerDamperPositionMetadata' - - CoolingTowerAirTemperatureMsg: - name: CoolingTowerAirTemperatureMsg - title: CoolingTower AirTemperature Metadata - payload: - $ref: '#/components/schemas/CoolingTowerAirTemperatureMetadata' - - CoolingTowerAirDifferentialPressureMsg: - name: CoolingTowerAirDifferentialPressureMsg - title: CoolingTower AirDifferentialPressure Metadata - payload: - $ref: '#/components/schemas/CoolingTowerAirDifferentialPressureMetadata' - - CoolingTowerAirFlowMsg: - name: CoolingTowerAirFlowMsg - title: CoolingTower AirFlow Metadata - payload: - $ref: '#/components/schemas/CoolingTowerAirFlowMetadata' - - CoolingTowerAirPressureMsg: - name: CoolingTowerAirPressureMsg - title: CoolingTower AirPressure Metadata - payload: - $ref: '#/components/schemas/CoolingTowerAirPressureMetadata' - - HXLiquidTemperatureMsg: - name: HXLiquidTemperatureMsg - title: HX LiquidTemperature Metadata - payload: - $ref: '#/components/schemas/HXLiquidTemperatureMetadata' - - HXLiquidDifferentialPressureMsg: - name: HXLiquidDifferentialPressureMsg - title: HX LiquidDifferentialPressure Metadata - payload: - $ref: '#/components/schemas/HXLiquidDifferentialPressureMetadata' - - HXLiquidFlowMsg: - name: HXLiquidFlowMsg - title: HX LiquidFlow Metadata - payload: - $ref: '#/components/schemas/HXLiquidFlowMetadata' - - HXLiquidPressureMsg: - name: HXLiquidPressureMsg - title: HX LiquidPressure Metadata - payload: - $ref: '#/components/schemas/HXLiquidPressureMetadata' - - HXStatusMsg: - name: HXStatusMsg - title: HX Status Metadata - payload: - $ref: '#/components/schemas/HXStatusMetadata' - - HXAvailableMsg: - name: HXAvailableMsg - title: HX Available Metadata - payload: - $ref: '#/components/schemas/HXAvailableMetadata' - - HXValvePositionMsg: - name: HXValvePositionMsg - title: HX ValvePosition Metadata - payload: - $ref: '#/components/schemas/HXValvePositionMetadata' - - HXPumpSpeedMsg: - name: HXPumpSpeedMsg - title: HX PumpSpeed Metadata - payload: - $ref: '#/components/schemas/HXPumpSpeedMetadata' - - HXFanSpeedMsg: - name: HXFanSpeedMsg - title: HX FanSpeed Metadata - payload: - $ref: '#/components/schemas/HXFanSpeedMetadata' - - HXDamperPositionMsg: - name: HXDamperPositionMsg - title: HX DamperPosition Metadata - payload: - $ref: '#/components/schemas/HXDamperPositionMetadata' - - HXAirTemperatureMsg: - name: HXAirTemperatureMsg - title: HX AirTemperature Metadata - payload: - $ref: '#/components/schemas/HXAirTemperatureMetadata' - - HXAirDifferentialPressureMsg: - name: HXAirDifferentialPressureMsg - title: HX AirDifferentialPressure Metadata - payload: - $ref: '#/components/schemas/HXAirDifferentialPressureMetadata' - - HXAirFlowMsg: - name: HXAirFlowMsg - title: HX AirFlow Metadata - payload: - $ref: '#/components/schemas/HXAirFlowMetadata' - - HXAirPressureMsg: - name: HXAirPressureMsg - title: HX AirPressure Metadata - payload: - $ref: '#/components/schemas/HXAirPressureMetadata' - - CRAHLiquidTemperatureMsg: - name: CRAHLiquidTemperatureMsg - title: CRAH LiquidTemperature Metadata - payload: - $ref: '#/components/schemas/CRAHLiquidTemperatureMetadata' - - CRAHLiquidDifferentialPressureMsg: - name: CRAHLiquidDifferentialPressureMsg - title: CRAH LiquidDifferentialPressure Metadata - payload: - $ref: '#/components/schemas/CRAHLiquidDifferentialPressureMetadata' - - CRAHLiquidFlowMsg: - name: CRAHLiquidFlowMsg - title: CRAH LiquidFlow Metadata - payload: - $ref: '#/components/schemas/CRAHLiquidFlowMetadata' - - CRAHLiquidPressureMsg: - name: CRAHLiquidPressureMsg - title: CRAH LiquidPressure Metadata - payload: - $ref: '#/components/schemas/CRAHLiquidPressureMetadata' - - CRAHStatusMsg: - name: CRAHStatusMsg - title: CRAH Status Metadata - payload: - $ref: '#/components/schemas/CRAHStatusMetadata' - - CRAHAvailableMsg: - name: CRAHAvailableMsg - title: CRAH Available Metadata - payload: - $ref: '#/components/schemas/CRAHAvailableMetadata' - - CRAHValvePositionMsg: - name: CRAHValvePositionMsg - title: CRAH ValvePosition Metadata - payload: - $ref: '#/components/schemas/CRAHValvePositionMetadata' - - CRAHPumpSpeedMsg: - name: CRAHPumpSpeedMsg - title: CRAH PumpSpeed Metadata - payload: - $ref: '#/components/schemas/CRAHPumpSpeedMetadata' - - CRAHFanSpeedMsg: - name: CRAHFanSpeedMsg - title: CRAH FanSpeed Metadata - payload: - $ref: '#/components/schemas/CRAHFanSpeedMetadata' - - CRAHDamperPositionMsg: - name: CRAHDamperPositionMsg - title: CRAH DamperPosition Metadata - payload: - $ref: '#/components/schemas/CRAHDamperPositionMetadata' - - CRAHAirTemperatureMsg: - name: CRAHAirTemperatureMsg - title: CRAH AirTemperature Metadata - payload: - $ref: '#/components/schemas/CRAHAirTemperatureMetadata' - - CRAHAirDifferentialPressureMsg: - name: CRAHAirDifferentialPressureMsg - title: CRAH AirDifferentialPressure Metadata - payload: - $ref: '#/components/schemas/CRAHAirDifferentialPressureMetadata' - - CRAHAirFlowMsg: - name: CRAHAirFlowMsg - title: CRAH AirFlow Metadata - payload: - $ref: '#/components/schemas/CRAHAirFlowMetadata' - - CRAHAirPressureMsg: - name: CRAHAirPressureMsg - title: CRAH AirPressure Metadata - payload: - $ref: '#/components/schemas/CRAHAirPressureMetadata' - - CRACLiquidTemperatureMsg: - name: CRACLiquidTemperatureMsg - title: CRAC LiquidTemperature Metadata - payload: - $ref: '#/components/schemas/CRACLiquidTemperatureMetadata' - - CRACLiquidDifferentialPressureMsg: - name: CRACLiquidDifferentialPressureMsg - title: CRAC LiquidDifferentialPressure Metadata - payload: - $ref: '#/components/schemas/CRACLiquidDifferentialPressureMetadata' - - CRACLiquidFlowMsg: - name: CRACLiquidFlowMsg - title: CRAC LiquidFlow Metadata - payload: - $ref: '#/components/schemas/CRACLiquidFlowMetadata' - - CRACLiquidPressureMsg: - name: CRACLiquidPressureMsg - title: CRAC LiquidPressure Metadata - payload: - $ref: '#/components/schemas/CRACLiquidPressureMetadata' - - CRACStatusMsg: - name: CRACStatusMsg - title: CRAC Status Metadata - payload: - $ref: '#/components/schemas/CRACStatusMetadata' - - CRACAvailableMsg: - name: CRACAvailableMsg - title: CRAC Available Metadata - payload: - $ref: '#/components/schemas/CRACAvailableMetadata' - - CRACValvePositionMsg: - name: CRACValvePositionMsg - title: CRAC ValvePosition Metadata - payload: - $ref: '#/components/schemas/CRACValvePositionMetadata' - - CRACPumpSpeedMsg: - name: CRACPumpSpeedMsg - title: CRAC PumpSpeed Metadata - payload: - $ref: '#/components/schemas/CRACPumpSpeedMetadata' - - CRACFanSpeedMsg: - name: CRACFanSpeedMsg - title: CRAC FanSpeed Metadata - payload: - $ref: '#/components/schemas/CRACFanSpeedMetadata' - - CRACDamperPositionMsg: - name: CRACDamperPositionMsg - title: CRAC DamperPosition Metadata - payload: - $ref: '#/components/schemas/CRACDamperPositionMetadata' - - CRACAirTemperatureMsg: - name: CRACAirTemperatureMsg - title: CRAC AirTemperature Metadata - payload: - $ref: '#/components/schemas/CRACAirTemperatureMetadata' - - CRACAirDifferentialPressureMsg: - name: CRACAirDifferentialPressureMsg - title: CRAC AirDifferentialPressure Metadata - payload: - $ref: '#/components/schemas/CRACAirDifferentialPressureMetadata' - - CRACAirFlowMsg: - name: CRACAirFlowMsg - title: CRAC AirFlow Metadata - payload: - $ref: '#/components/schemas/CRACAirFlowMetadata' - - CRACAirPressureMsg: - name: CRACAirPressureMsg - title: CRAC AirPressure Metadata - payload: - $ref: '#/components/schemas/CRACAirPressureMetadata' - - AHULiquidTemperatureMsg: - name: AHULiquidTemperatureMsg - title: AHU LiquidTemperature Metadata - payload: - $ref: '#/components/schemas/AHULiquidTemperatureMetadata' - - AHULiquidDifferentialPressureMsg: - name: AHULiquidDifferentialPressureMsg - title: AHU LiquidDifferentialPressure Metadata - payload: - $ref: '#/components/schemas/AHULiquidDifferentialPressureMetadata' - - AHULiquidFlowMsg: - name: AHULiquidFlowMsg - title: AHU LiquidFlow Metadata - payload: - $ref: '#/components/schemas/AHULiquidFlowMetadata' - - AHULiquidPressureMsg: - name: AHULiquidPressureMsg - title: AHU LiquidPressure Metadata - payload: - $ref: '#/components/schemas/AHULiquidPressureMetadata' - - AHUStatusMsg: - name: AHUStatusMsg - title: AHU Status Metadata - payload: - $ref: '#/components/schemas/AHUStatusMetadata' - - AHUAvailableMsg: - name: AHUAvailableMsg - title: AHU Available Metadata - payload: - $ref: '#/components/schemas/AHUAvailableMetadata' - - AHUValvePositionMsg: - name: AHUValvePositionMsg - title: AHU ValvePosition Metadata - payload: - $ref: '#/components/schemas/AHUValvePositionMetadata' - - AHUPumpSpeedMsg: - name: AHUPumpSpeedMsg - title: AHU PumpSpeed Metadata - payload: - $ref: '#/components/schemas/AHUPumpSpeedMetadata' - - AHUFanSpeedMsg: - name: AHUFanSpeedMsg - title: AHU FanSpeed Metadata - payload: - $ref: '#/components/schemas/AHUFanSpeedMetadata' - - AHUDamperPositionMsg: - name: AHUDamperPositionMsg - title: AHU DamperPosition Metadata - payload: - $ref: '#/components/schemas/AHUDamperPositionMetadata' - - AHUAirTemperatureMsg: - name: AHUAirTemperatureMsg - title: AHU AirTemperature Metadata - payload: - $ref: '#/components/schemas/AHUAirTemperatureMetadata' - - AHUAirDifferentialPressureMsg: - name: AHUAirDifferentialPressureMsg - title: AHU AirDifferentialPressure Metadata - payload: - $ref: '#/components/schemas/AHUAirDifferentialPressureMetadata' - - AHUAirFlowMsg: - name: AHUAirFlowMsg - title: AHU AirFlow Metadata - payload: - $ref: '#/components/schemas/AHUAirFlowMetadata' - - AHUAirPressureMsg: - name: AHUAirPressureMsg - title: AHU AirPressure Metadata - payload: - $ref: '#/components/schemas/AHUAirPressureMetadata' - - ChillerLiquidTemperatureMsg: - name: ChillerLiquidTemperatureMsg - title: Chiller LiquidTemperature Metadata - payload: - $ref: '#/components/schemas/ChillerLiquidTemperatureMetadata' - - ChillerLiquidDifferentialPressureMsg: - name: ChillerLiquidDifferentialPressureMsg - title: Chiller LiquidDifferentialPressure Metadata - payload: - $ref: '#/components/schemas/ChillerLiquidDifferentialPressureMetadata' - - ChillerLiquidFlowMsg: - name: ChillerLiquidFlowMsg - title: Chiller LiquidFlow Metadata - payload: - $ref: '#/components/schemas/ChillerLiquidFlowMetadata' - - ChillerLiquidPressureMsg: - name: ChillerLiquidPressureMsg - title: Chiller LiquidPressure Metadata - payload: - $ref: '#/components/schemas/ChillerLiquidPressureMetadata' - - ChillerStatusMsg: - name: ChillerStatusMsg - title: Chiller Status Metadata - payload: - $ref: '#/components/schemas/ChillerStatusMetadata' - - ChillerAvailableMsg: - name: ChillerAvailableMsg - title: Chiller Available Metadata - payload: - $ref: '#/components/schemas/ChillerAvailableMetadata' - - ChillerValvePositionMsg: - name: ChillerValvePositionMsg - title: Chiller ValvePosition Metadata - payload: - $ref: '#/components/schemas/ChillerValvePositionMetadata' - - ChillerPumpSpeedMsg: - name: ChillerPumpSpeedMsg - title: Chiller PumpSpeed Metadata - payload: - $ref: '#/components/schemas/ChillerPumpSpeedMetadata' - - ChillerFanSpeedMsg: - name: ChillerFanSpeedMsg - title: Chiller FanSpeed Metadata - payload: - $ref: '#/components/schemas/ChillerFanSpeedMetadata' - - ChillerDamperPositionMsg: - name: ChillerDamperPositionMsg - title: Chiller DamperPosition Metadata - payload: - $ref: '#/components/schemas/ChillerDamperPositionMetadata' - - ChillerAirTemperatureMsg: - name: ChillerAirTemperatureMsg - title: Chiller AirTemperature Metadata - payload: - $ref: '#/components/schemas/ChillerAirTemperatureMetadata' - - ChillerAirDifferentialPressureMsg: - name: ChillerAirDifferentialPressureMsg - title: Chiller AirDifferentialPressure Metadata - payload: - $ref: '#/components/schemas/ChillerAirDifferentialPressureMetadata' - - ChillerAirFlowMsg: - name: ChillerAirFlowMsg - title: Chiller AirFlow Metadata - payload: - $ref: '#/components/schemas/ChillerAirFlowMetadata' - - ChillerAirPressureMsg: - name: ChillerAirPressureMsg - title: Chiller AirPressure Metadata - payload: - $ref: '#/components/schemas/ChillerAirPressureMetadata' - GenericEquipmentPointMsg: - name: GenericEquipmentPointMsg - title: GenericEquipment GenericPoint Metadata - payload: - $ref: '#/components/schemas/GenericEquipmentPointMetadata' - - # Generic PowerMeter - GenericPowerMeterPointMsg: - name: GenericPowerMeterPointMsg - title: PowerMeter GenericPoint Metadata - payload: - $ref: '#/components/schemas/GenericPowerMeterPointMetadata' - - # Tank - TankLiquidTemperatureMsg: - name: TankLiquidTemperatureMsg - title: Tank LiquidTemperature Metadata - payload: - $ref: '#/components/schemas/TankLiquidTemperatureMetadata' - - TankLiquidDifferentialPressureMsg: - name: TankLiquidDifferentialPressureMsg - title: Tank LiquidDifferentialPressure Metadata - payload: - $ref: '#/components/schemas/TankLiquidDifferentialPressureMetadata' - - TankLiquidFlowMsg: - name: TankLiquidFlowMsg - title: Tank LiquidFlow Metadata - payload: - $ref: '#/components/schemas/TankLiquidFlowMetadata' - - TankLiquidPressureMsg: - name: TankLiquidPressureMsg - title: Tank LiquidPressure Metadata - payload: - $ref: '#/components/schemas/TankLiquidPressureMetadata' - - TankStatusMsg: - name: TankStatusMsg - title: Tank Status Metadata - payload: - $ref: '#/components/schemas/TankStatusMetadata' - - TankAvailableMsg: - name: TankAvailableMsg - title: Tank Available Metadata - payload: - $ref: '#/components/schemas/TankAvailableMetadata' - - TankValvePositionMsg: - name: TankValvePositionMsg - title: Tank ValvePosition Metadata - payload: - $ref: '#/components/schemas/TankValvePositionMetadata' - - TankPumpSpeedMsg: - name: TankPumpSpeedMsg - title: Tank PumpSpeed Metadata - payload: - $ref: '#/components/schemas/TankPumpSpeedMetadata' - - TankFanSpeedMsg: - name: TankFanSpeedMsg - title: Tank FanSpeed Metadata - payload: - $ref: '#/components/schemas/TankFanSpeedMetadata' - - TankDamperPositionMsg: - name: TankDamperPositionMsg - title: Tank DamperPosition Metadata - payload: - $ref: '#/components/schemas/TankDamperPositionMetadata' - - TankAirTemperatureMsg: - name: TankAirTemperatureMsg - title: Tank AirTemperature Metadata - payload: - $ref: '#/components/schemas/TankAirTemperatureMetadata' - - TankAirDifferentialPressureMsg: - name: TankAirDifferentialPressureMsg - title: Tank AirDifferentialPressure Metadata - payload: - $ref: '#/components/schemas/TankAirDifferentialPressureMetadata' - - TankAirFlowMsg: - name: TankAirFlowMsg - title: Tank AirFlow Metadata - payload: - $ref: '#/components/schemas/TankAirFlowMetadata' - - TankAirPressureMsg: - name: TankAirPressureMsg - title: Tank AirPressure Metadata - payload: - $ref: '#/components/schemas/TankAirPressureMetadata' - - # GenericObject - GenericObjectLiquidTemperatureMsg: - name: GenericObjectLiquidTemperatureMsg - title: GenericObject LiquidTemperature Metadata - payload: - $ref: '#/components/schemas/GenericObjectLiquidTemperatureMetadata' - - GenericObjectLiquidDifferentialPressureMsg: - name: GenericObjectLiquidDifferentialPressureMsg - title: GenericObject LiquidDifferentialPressure Metadata - payload: - $ref: '#/components/schemas/GenericObjectLiquidDifferentialPressureMetadata' - - GenericObjectLiquidFlowMsg: - name: GenericObjectLiquidFlowMsg - title: GenericObject LiquidFlow Metadata - payload: - $ref: '#/components/schemas/GenericObjectLiquidFlowMetadata' - - GenericObjectLiquidPressureMsg: - name: GenericObjectLiquidPressureMsg - title: GenericObject LiquidPressure Metadata - payload: - $ref: '#/components/schemas/GenericObjectLiquidPressureMetadata' - - GenericObjectStatusMsg: - name: GenericObjectStatusMsg - title: GenericObject Status Metadata - payload: - $ref: '#/components/schemas/GenericObjectStatusMetadata' - - GenericObjectAvailableMsg: - name: GenericObjectAvailableMsg - title: GenericObject Available Metadata - payload: - $ref: '#/components/schemas/GenericObjectAvailableMetadata' - - GenericObjectValvePositionMsg: - name: GenericObjectValvePositionMsg - title: GenericObject ValvePosition Metadata - payload: - $ref: '#/components/schemas/GenericObjectValvePositionMetadata' - - GenericObjectPumpSpeedMsg: - name: GenericObjectPumpSpeedMsg - title: GenericObject PumpSpeed Metadata - payload: - $ref: '#/components/schemas/GenericObjectPumpSpeedMetadata' - - GenericObjectFanSpeedMsg: - name: GenericObjectFanSpeedMsg - title: GenericObject FanSpeed Metadata - payload: - $ref: '#/components/schemas/GenericObjectFanSpeedMetadata' - - GenericObjectDamperPositionMsg: - name: GenericObjectDamperPositionMsg - title: GenericObject DamperPosition Metadata - payload: - $ref: '#/components/schemas/GenericObjectDamperPositionMetadata' - - GenericObjectAirTemperatureMsg: - name: GenericObjectAirTemperatureMsg - title: GenericObject AirTemperature Metadata - payload: - $ref: '#/components/schemas/GenericObjectAirTemperatureMetadata' - - GenericObjectAirDifferentialPressureMsg: - name: GenericObjectAirDifferentialPressureMsg - title: GenericObject AirDifferentialPressure Metadata - payload: - $ref: '#/components/schemas/GenericObjectAirDifferentialPressureMetadata' - - GenericObjectAirFlowMsg: - name: GenericObjectAirFlowMsg - title: GenericObject AirFlow Metadata - payload: - $ref: '#/components/schemas/GenericObjectAirFlowMetadata' - - GenericObjectAirPressureMsg: - name: GenericObjectAirPressureMsg - title: GenericObject AirPressure Metadata - payload: - $ref: '#/components/schemas/GenericObjectAirPressureMetadata' - - GenericObjectLiquidTemperatureSpRequestMsg: - name: GenericObjectLiquidTemperatureSpRequestMsg - title: GenericObject LiquidTemperatureSpRequest Metadata - payload: - $ref: '#/components/schemas/GenericObjectLiquidTemperatureSpRequestMetadata' - - GenericObjectSoundMsg: - name: GenericObjectSoundMsg - title: GenericObject Sound Metadata - payload: - $ref: '#/components/schemas/GenericObjectSoundMetadata' - - - - schemas: - - # ========================================================================= - # SECTION 1 — Primitive building-block schemas - # - # Every field in an allOf/oneOf is a named $ref — no anonymous schemas. - # Naming convention: - # *Identifiers — the object-identity fields added by a base schema - # *Fields — the pointType-specific fields (pointType enum + engUnit) - # *Mode — a oneOf variant for identifier selection (named-object or associate) - # *CommonFields — optional fields shared across a category - # ========================================================================= - - # ------------------------------------------------------------------------- - # 1a. Shared primitives - # ------------------------------------------------------------------------- - - MetadataBase: - type: object - description: Minimum fields present on every metadata message. - required: - - objectType - - pointType - properties: - objectType: - type: string - description: Canonical object type. Matches the objectType MQTT topic segment. - pointType: - type: string - description: Canonical point type. Matches the pointType MQTT topic segment. - - IntegrationPublisherFields: - type: object - description: > - Fields added to metadata for integration-published points. Integrations - MUST publish values to the exact topic corresponding to `integration` — do not construct it - independently. - required: - - integration - properties: - integration: - type: string - description: Integration identifier responsible for publishing this value. - - - StateTextField: - type: object - description: Required for state/status/alarm points that carry no engineering unit. - required: - - stateText - properties: - stateText: - type: array - description: > - State label mapping. Each entry maps a numeric state value to its - human-readable label (e.g., `[{value: 0, text: "Off"}, {value: 1, text: "On"}]`). - items: - type: object - required: [value, text] - properties: - value: - type: integer - description: Numeric state value. - text: - type: string - description: Human-readable label for this state. - additionalProperties: false - - EquipmentPointEngUnit: - type: object - description: Requires a non-empty engUnit string (mutually exclusive with stateText). - required: - - engUnit - properties: - engUnit: - type: string - description: Engineering unit for the measurement. - - EquipmentMeasurementModeBase: - description: > - Base for equipment measurement metadata. Two independent XOR constraints apply: - - Identifier: named-object mode (objectName + objectId required, - associateId prohibited) XOR associate mode (associateId required, no objectName/objectId). - - Measurement: engUnit required XOR stateText required. - These two constraints are fully independent — all four combinations are valid. - Combines MetadataBase, EquipmentCommonFields, and both independent oneOf constraints. - allOf: - - $ref: '#/components/schemas/MetadataBase' - - $ref: '#/components/schemas/EquipmentCommonFields' - - oneOf: - - $ref: '#/components/schemas/EquipmentNamedObjectMode' - - $ref: '#/components/schemas/EquipmentAssociateMode' - - oneOf: - - $ref: '#/components/schemas/EquipmentPointEngUnit' - - $ref: '#/components/schemas/StateTextField' - - # ------------------------------------------------------------------------- - # 1b. Rack identifier fragment - # ------------------------------------------------------------------------- - - rackLocationIdentifiers: - type: object - description: Rack-specific identifier fields added to all Rack metadata messages. - required: - - rackLocationName - - rackLocationId - properties: - rackLocationName: - type: string - description: Human-readable rack name as defined by the BMS. - rackLocationId: - type: string - description: Stable unique identifier for the rack. - - # ------------------------------------------------------------------------- - # 1c. PowerMeter identifier fragment - # ------------------------------------------------------------------------- - - PowerMeterIdentifiers: - type: object - description: PowerMeter-specific identifier fields added to all PowerMeter metadata messages. - required: - - objectName - - objectId - - servesId - properties: - objectName: - type: string - description: Human-readable name of the electrical device. - objectId: - type: string - description: Stable unique identifier for the electrical device. - servesId: - type: array - items: - type: string - description: List of objectIds of entities served by this power meter. - - # ------------------------------------------------------------------------- - # 1d. Generic Equipment identifier fragments - # ------------------------------------------------------------------------- - - EquipmentCommonFields: - type: object - description: > - Optional fields common to all generic equipment metadata, regardless - of identifier mode. - properties: - processArea: - type: array - items: - type: string - description: > - List of process areas or sub-system locations within the - equipment - - EquipmentNamedObjectMode: - type: object - description: | - **Object Mode**: use when the object is identified directly - by name and ID. - - - `objectName` and `objectId` are **required**. - - `servesId` is **optional** in Named-object mode. - - `associateId` must **not** be present. - - Incompatible with `EquipmentAssociateMode` — validators enforce this via - the parent `oneOf`. - required: - - objectName - - objectId - properties: - objectName: - type: string - description: Human-readable equipment name. - objectId: - type: string - description: Stable unique identifier for the equipment. - servesId: - type: array - items: - type: string - description: > - Optional list of objectIds of entities this equipment serves. Only valid in Named-object mode. - Only valid in Named-object mode — must not appear in Associate mode. - not: - properties: - associateId: - type: string - required: - - associateId - - EquipmentAssociateMode: - type: object - description: | - *Associate Mode*: use when the object is referenced via an - association identifier. - - - `associateId` is **required**. - - `objectName`, `objectId`, and `servesId` must **not** be present. - - Incompatible with `EquipmentNamedObjectMode` — validators enforce this - via the parent `oneOf`. - required: - - associateId - properties: - associateId: - type: string - description: Identifier of the associated entity. - not: - anyOf: - - properties: - objectName: - type: string - required: - - objectName - - properties: - objectId: - type: string - required: - - objectId - - properties: - servesId: - type: array - items: - type: string - required: - - servesId - - EquipmentIntegrationIdentifierFields: - type: object - description: > - Extends EquipmentIntegrationMetadataBase: adds `integration` for integration-published equipment points. - required: - - integration - properties: - integration: - type: string - description: Integration responsible for publishing this value. - - - # ------------------------------------------------------------------------- - # 1e. System identifier fragments - # ------------------------------------------------------------------------- - - SystemIntegrationIdentifiers: - type: object - description: > - Required `integration` field for integration-published System - metadata messages. - required: - - integration - properties: - integration: - type: string - description: Integration identifier. - - # ========================================================================= - # SECTION 2 — Composed base schemas - # - # Each allOf element is a named $ref — no anonymous schemas. - # ========================================================================= - - RackMetadataBase: - description: > - Composed base for all BMS-published Rack metadata messages. - Combines MetadataBase with rackLocationIdentifiers. - allOf: - - $ref: '#/components/schemas/MetadataBase' - - $ref: '#/components/schemas/rackLocationIdentifiers' - - RackIntegrationMetadataBase: - description: > - Composed base for Integration-published Rack metadata messages. - Extends RackMetadataBase with IntegrationPublisherFields. - allOf: - - $ref: '#/components/schemas/RackMetadataBase' - - $ref: '#/components/schemas/IntegrationPublisherFields' - - PowerMeterMetadataBase: - description: > - Composed base for all PowerMeter metadata messages. - Combines MetadataBase with PowerMeterIdentifiers. - allOf: - - $ref: '#/components/schemas/MetadataBase' - - $ref: '#/components/schemas/PowerMeterIdentifiers' - - EquipmentMetadataBase: - description: | - Composed base for all generic equipment metadata. - - Combines MetadataBase + EquipmentCommonFields + exactly one identifier - mode from the `oneOf`: - - **ObjectMode**: objectName + objectId required, - servesId optional, associateId prohibited. - - **Associate Mode**: associateId required, all name/id/serves - fields prohibited. - allOf: - - $ref: '#/components/schemas/MetadataBase' - - $ref: '#/components/schemas/EquipmentCommonFields' - - oneOf: - - $ref: '#/components/schemas/EquipmentNamedObjectMode' - - $ref: '#/components/schemas/EquipmentAssociateMode' - - EquipmentIntegrationMetadataBase: - description: > - Extends EquipmentMetadataBase for integration-published equipment points. - Adds EquipmentIntegrationIdentifierFields (integration required). - allOf: - - $ref: '#/components/schemas/EquipmentMetadataBase' - - $ref: '#/components/schemas/EquipmentIntegrationIdentifierFields' - - SystemIntegrationMetadataBase: - description: > - Composed base for Integration-published System metadata messages. - Combines MetadataBase with SystemIntegrationIdentifiers. - allOf: - - $ref: '#/components/schemas/MetadataBase' - - $ref: '#/components/schemas/SystemIntegrationIdentifiers' - - - # ========================================================================= - # SECTION 3 — PointType enum schemas (for code generation / validation) - # ========================================================================= - - RackBmsPointType: - type: string - description: Valid pointType values for BMS-published Rack points. - enum: - - RackLiquidSupplyTemperature - - RackLiquidReturnTemperature - - RackLiquidFlow - - RackLiquidDifferentialPressure - - RackLiquidDifferentialPressureSp - - RackControlValvePosition - - RackPower - - RackLeakDetect - - RackLeakSensorFault - - RackLiquidIsolationStatus - - RackElectricalIsolationStatus - - RackIntegrationPointType: - type: string - description: Valid pointType values for Integration-published Rack points. - enum: - - RackLeakDetectTray - - RackLiquidIsolationRequest - - RackElectricalIsolationRequest - - PowerMeterPointType: - type: string - description: Valid pointType values for PowerMeter points (all BMS-published). - enum: - - Voltage - - PowerFactor - - Frequency - - ApparentPower - - ActivePower - - Current - - CurrentLimit - - PhaseCurrent - - EquipmentBmsPointType: - type: string - description: Valid pointType values for BMS-published generic equipment points. - enum: - - LiquidTemperature - - LiquidDifferentialPressure - - LiquidFlow - - LiquidPressure - - Status - - Available - - ValvePosition - - PumpSpeed - - FanSpeed - - DamperPosition - - AirTemperature - - AirDifferentialPressure - - AirFlow - - AirPressure - - Sound - - GenericPoint - - EquipmentIntegrationPointType: - type: string - description: Valid pointType values for Integration-published generic equipment points. - enum: - - LiquidTemperatureSpRequest - - GenericPoint - - EquipmentObjectType: - type: string - description: Valid objectType values for generic equipment channels. - enum: - - CDU - - CoolingTower - - HX - - CRAH - - CRAC - - AHU - - Chiller - - BESS - - UPS - - ATS - - Generator - - Shunt - - Breaker - - Valve - - Pump - - Fan - - Damper - - Sensor - - Tank - - GenericObject - - SystemBmsPointType: - type: string - description: Valid pointType values for BMS-published System points. - enum: - - HeartbeatTimestampBms - - HeartbeatEchoBms - - Status - - Available - - GenericPoint - - SystemIntegrationPointType: - type: string - description: Valid pointType values for Integration-published System points. - enum: - - HeartbeatTimestampIntegration - - HeartbeatEchoIntegration - - Status - - Available - - GenericPoint - - - # ========================================================================= - # SECTION 4 — Per-pointType field fragments - # - # Each fragment captures only what differs per pointType: - # a pointType enum constraint and (where applicable) an engUnit enum. - # These are referenced as the second allOf element in each *Metadata schema. - # ========================================================================= - - # --- Rack fields --------------------------------------------------------- - - RackLiquidSupplyTemperatureFields: - type: object - required: [engUnit] - properties: - pointType: - type: string - enum: [RackLiquidSupplyTemperature] - objectType: - type: string - enum: [Rack] - engUnit: - type: string - enum: [C] - - RackLiquidReturnTemperatureFields: - type: object - required: [engUnit] - properties: - pointType: - type: string - enum: [RackLiquidReturnTemperature] - objectType: - type: string - enum: [Rack] - engUnit: - type: string - enum: [C] - - RackLiquidFlowFields: - type: object - required: [engUnit] - properties: - pointType: - type: string - enum: [RackLiquidFlow] - objectType: - type: string - enum: [Rack] - engUnit: - type: string - enum: [LPM] - - RackLiquidDifferentialPressureFields: - type: object - required: [engUnit] - properties: - pointType: - type: string - enum: [RackLiquidDifferentialPressure] - objectType: - type: string - enum: [Rack] - engUnit: - type: string - enum: [kPa] - - RackControlValvePositionFields: - type: object - required: [engUnit] - properties: - pointType: - type: string - enum: [RackControlValvePosition] - objectType: - type: string - enum: [Rack] - engUnit: - type: string - enum: ['%'] - - RackPowerFields: - type: object - required: [engUnit] - properties: - pointType: - type: string - enum: [RackPower] - objectType: - type: string - enum: [Rack] - engUnit: - type: string - enum: [kW] - - RackLeakDetectFields: - type: object - description: '0 = No Leak, 1 = Leak. No engUnit.' - required: - - stateText - properties: - pointType: - type: string - enum: [RackLeakDetect] - objectType: - type: string - enum: [Rack] - stateText: - type: array - description: > - State label mapping for leak detection. - items: - type: object - required: [value, text] - properties: - value: - type: integer - description: Numeric state value. - text: - type: string - description: Human-readable label for this state. - additionalProperties: false - examples: - - - - value: 0 - text: "NoLeak" - - value: 1 - text: "Leak" - - RackLeakSensorFaultFields: - type: object - description: '0 = No Fault, 1 = Fault. No engUnit.' - required: - - stateText - properties: - pointType: - type: string - enum: [RackLeakSensorFault] - objectType: - type: string - enum: [Rack] - stateText: - type: array - description: > - State label mapping for leak sensor fault status. - items: - type: object - required: [value, text] - properties: - value: - type: integer - description: Numeric state value. - text: - type: string - description: Human-readable label for this state. - additionalProperties: false - examples: - - - - value: 0 - text: "NoFault" - - value: 1 - text: "Fault" - - RackLiquidIsolationStatusFields: - type: object - description: '0=NotIsolated, 1=Isolated. No engUnit.' - required: - - stateText - properties: - pointType: - type: string - enum: [RackLiquidIsolationStatus] - objectType: - type: string - enum: [Rack] - stateText: - type: array - description: > - State label mapping for liquid isolation status. - items: - type: object - required: [value, text] - properties: - value: - type: integer - description: Numeric state value. - text: - type: string - description: Human-readable label for this state. - additionalProperties: false - examples: - - - - value: 0 - text: "NotIsolated" - - value: 1 - text: "Isolated" - - RackElectricalIsolationStatusFields: - type: object - description: '0 = NotIsolated, 1 = Isolated. No engUnit.' - required: - - stateText - properties: - pointType: - type: string - enum: [RackElectricalIsolationStatus] - objectType: - type: string - enum: [Rack] - stateText: - type: array - description: > - State label mapping for electrical isolation status. - items: - type: object - required: [value, text] - properties: - value: - type: integer - description: Numeric state value. - text: - type: string - description: Human-readable label for this state. - additionalProperties: false - examples: - - - - value: 0 - text: "NotIsolated" - - value: 1 - text: "Isolated" - - RackLeakDetectTrayFields: - type: object - description: '0 = No Leak, 1 = Leak (tray sensor). No engUnit. Integration-published value.' - required: - - stateText - properties: - pointType: - type: string - enum: [RackLeakDetectTray] - objectType: - type: string - enum: [Rack] - stateText: - type: array - description: > - State label mapping for tray leak detection. - items: - type: object - required: [value, text] - properties: - value: - type: integer - description: Numeric state value. - text: - type: string - description: Human-readable label for this state. - additionalProperties: false - examples: - - - - value: 0 - text: "NoLeak" - - value: 1 - text: "Leak" - - RackLiquidIsolationRequestFields: - type: object - description: '0 = Not Requested, 1 = Requested, -1 = Unknown. Integration-published value.' - required: - - stateText - properties: - pointType: - type: string - enum: [RackLiquidIsolationRequest] - objectType: - type: string - enum: [Rack] - stateText: - type: array - description: > - State label mapping for liquid isolation request. - items: - type: object - required: [value, text] - properties: - value: - type: integer - description: Numeric state value. - text: - type: string - description: Human-readable label for this state. - additionalProperties: false - examples: - - - - value: 0 - text: "NoIsolationRequested" - - value: 1 - text: "IsolationRequested" - - RackElectricalIsolationRequestFields: - type: object - description: '0 = Not Requested, 1 = Requested. Integration-published value.' - required: - - stateText - properties: - pointType: - type: string - enum: [RackElectricalIsolationRequest] - objectType: - type: string - enum: [Rack] - stateText: - type: array - description: > - State label mapping for electrical isolation request. - items: - type: object - required: [value, text] - properties: - value: - type: integer - description: Numeric state value. - text: - type: string - description: Human-readable label for this state. - additionalProperties: false - examples: - - - - value: 0 - text: "NoIsolationRequested" - - value: 1 - text: "IsolationRequested" - - # --- PowerMeter fields --------------------------------------------------- - - VoltageFields: - type: object - required: [engUnit] - properties: - pointType: - type: string - enum: [Voltage] - objectType: - type: string - enum: [PowerMeter] - engUnit: - type: string - #enum: [V] - - PowerFactorFields: - type: object - description: Dimensionless 0–1. engUnit not required. - properties: - pointType: - type: string - enum: [PowerFactor] - objectType: - type: string - enum: [PowerMeter] - - FrequencyFields: - type: object - required: [engUnit] - properties: - pointType: - type: string - enum: [Frequency] - objectType: - type: string - enum: [PowerMeter] - engUnit: - type: string - #enum: [Hz] - - ApparentPowerFields: - type: object - required: [engUnit] - properties: - pointType: - type: string - enum: [ApparentPower] - objectType: - type: string - enum: [PowerMeter] - engUnit: - type: string - #enum: [kVA] - - ActivePowerFields: - type: object - required: [engUnit] - properties: - pointType: - type: string - enum: [ActivePower] - objectType: - type: string - enum: [PowerMeter] - engUnit: - type: string - #enum: [kW] - - CurrentFields: - type: object - required: [engUnit] - properties: - pointType: - type: string - enum: [Current] - objectType: - type: string - enum: [PowerMeter] - engUnit: - type: string - #enum: [A] - - CurrentLimitFields: - type: object - required: [engUnit] - properties: - pointType: - type: string - enum: [CurrentLimit] - objectType: - type: string - enum: [PowerMeter] - engUnit: - type: string - #enum: [A] - - PhaseCurrentFields: - type: object - description: > - PhaseCurrent additionally requires `phase` to identify which electrical - phase. Accepts letter form (A, B, C) or numeric form (1, 2, 3); both - are valid representations of the same three-phase system. - 3 metadata messages per meter, one per phase. - required: - - engUnit - - phase - properties: - pointType: - type: string - enum: [PhaseCurrent] - objectType: - type: string - enum: [PowerMeter] - engUnit: - type: string - #enum: [A] - phase: - type: string - enum: [A, B, C, "1", "2", "3"] - description: > - Electrical phase identifier. Letter form (A/B/C) and numeric form - (1/2/3) are both accepted to align with publisher conventions. - - # --- Generic Equipment fields -------------------------------------------- - # engUnit enums reflect common industry standards. - # Verify with BMS configuration for site-specific values. - - LiquidTemperatureFields: - type: object - required: [engUnit] - properties: - pointType: - type: string - enum: [LiquidTemperature] - engUnit: - type: string - enum: [C] - isSetpoint: - type: boolean - description: > - Optional. When true, indicates this point is a setpoint (a target - value written to control equipment behavior). - - LiquidDifferentialPressureFields: - type: object - description: > - Measurement fields for LiquidDifferentialPressure. Typical engUnit: kPa. - The identifier (named-object XOR associate) and measurement (engUnit XOR stateText) - constraints are independent and enforced by EquipmentMeasurementModeBase. - properties: - pointType: - type: string - enum: [LiquidDifferentialPressure] - isSetpoint: - type: boolean - description: > - Optional. When true, indicates this point is a setpoint (a target - value written to control equipment behavior). - - LiquidFlowFields: - type: object - description: > - Measurement fields for LiquidFlow. Typical engUnit: LPM. - The identifier (named-object XOR associate) and measurement (engUnit XOR stateText) - constraints are independent and enforced by EquipmentMeasurementModeBase. - properties: - pointType: - type: string - enum: [LiquidFlow] - isSetpoint: - type: boolean - description: > - Optional. When true, indicates this point is a setpoint (a target - value written to control equipment behavior). - - LiquidPressureFields: - type: object - description: > - Measurement fields for LiquidPressure. Typical engUnit: kPa. - The identifier (named-object XOR associate) and measurement (engUnit XOR stateText) - constraints are independent and enforced by EquipmentMeasurementModeBase. - properties: - pointType: - type: string - enum: [LiquidPressure] - isSetpoint: - type: boolean - description: > - Optional. When true, indicates this point is a setpoint (a target - value written to control equipment behavior). - - StatusFields: - type: object - required: - - stateText - properties: - pointType: - type: string - enum: [Status] - integration: - type: string - description: > - Optional integration identifier. When present, this integration - is responsible for publishing the value for this point. - stateText: - type: array - description: > - State label mapping for operating status (values vary by equipment vendor). - items: - type: object - required: [value, text] - properties: - value: - type: integer - description: Numeric state value. - text: - type: string - description: Human-readable label for this state. - additionalProperties: false - examples: - - - - value: 0 - text: "NotOperating" - - value: 1 - text: "Operating" - - AvailableFields: - type: object - required: - - stateText - properties: - pointType: - type: string - enum: [Available] - integration: - type: string - description: > - Optional integration identifier. When present, this integration - is responsible for publishing the value for this point. - stateText: - type: array - description: > - State label mapping for availability status (values vary by equipment vendor). - items: - type: object - required: [value, text] - properties: - value: - type: integer - description: Numeric state value. - text: - type: string - description: Human-readable label for this state. - additionalProperties: false - examples: - - - - value: 0 - text: "NotAvailable" - - value: 1 - text: "Available" - - ValvePositionFields: - type: object - description: > - Measurement fields for ValvePosition. Typical engUnit: %. - The identifier (named-object XOR associate) and measurement (engUnit XOR stateText) - constraints are independent and enforced by EquipmentMeasurementModeBase. - properties: - pointType: - type: string - enum: [ValvePosition] - - PumpSpeedFields: - type: object - description: > - Measurement fields for PumpSpeed. Typical engUnit: RPM. - The identifier (named-object XOR associate) and measurement (engUnit XOR stateText) - constraints are independent and enforced by EquipmentMeasurementModeBase. - properties: - pointType: - type: string - enum: [PumpSpeed] - - FanSpeedFields: - type: object - description: > - Measurement fields for FanSpeed. Typical engUnit: RPM. - The identifier (named-object XOR associate) and measurement (engUnit XOR stateText) - constraints are independent and enforced by EquipmentMeasurementModeBase. - properties: - pointType: - type: string - enum: [FanSpeed] - - DamperPositionFields: - type: object - description: > - Measurement fields for DamperPosition. Typical engUnit: %. - The identifier (named-object XOR associate) and measurement (engUnit XOR stateText) - constraints are independent and enforced by EquipmentMeasurementModeBase. - properties: - pointType: - type: string - enum: [DamperPosition] - - AirTemperatureFields: - type: object - required: [engUnit] - properties: - pointType: - type: string - enum: [AirTemperature] - engUnit: - type: string - enum: [C] - isSetpoint: - type: boolean - description: > - Optional. When true, indicates this point is a setpoint (a target - value written to control equipment behavior). - - AirDifferentialPressureFields: - type: object - description: > - Measurement fields for AirDifferentialPressure. Typical engUnit: Pa. - The identifier (named-object XOR associate) and measurement (engUnit XOR stateText) - constraints are independent and enforced by EquipmentMeasurementModeBase. - properties: - pointType: - type: string - enum: [AirDifferentialPressure] - isSetpoint: - type: boolean - description: > - Optional. When true, indicates this point is a setpoint (a target - value written to control equipment behavior). - - AirFlowFields: - type: object - description: > - Measurement fields for AirFlow. Typical engUnit: CFM. - The identifier (named-object XOR associate) and measurement (engUnit XOR stateText) - constraints are independent and enforced by EquipmentMeasurementModeBase. - properties: - pointType: - type: string - enum: [AirFlow] - isSetpoint: - type: boolean - description: > - Optional. When true, indicates this point is a setpoint (a target - value written to control equipment behavior). - - AirPressureFields: - type: object - description: > - Measurement fields for AirPressure. Typical engUnit: Pa. - The identifier (named-object XOR associate) and measurement (engUnit XOR stateText) - constraints are independent and enforced by EquipmentMeasurementModeBase. - properties: - pointType: - type: string - enum: [AirPressure] - isSetpoint: - type: boolean - description: > - Optional. When true, indicates this point is a setpoint (a target - value written to control equipment behavior). - LeakDetectFields: - type: object - description: '0 = No Leak, 1 = Leak. No engUnit.' - required: - - stateText - properties: - pointType: - type: string - enum: [LeakDetect] - stateText: - type: array - description: > - State label mapping for leak detection. - items: - type: object - required: [value, text] - properties: - value: - type: integer - description: Numeric state value. - text: - type: string - description: Human-readable label for this state. - additionalProperties: false - examples: - - - - value: 0 - text: "NoLeak" - - value: 1 - text: "Leak" - - AirRelativeHumidityFields: - type: object - description: > - Measurement fields for AirRelativeHumidity. Typical engUnit: %RH. - The identifier (named-object XOR associate) and measurement (engUnit XOR stateText) - constraints are independent and enforced by EquipmentMeasurementModeBase. - properties: - pointType: - type: string - enum: [AirRelativeHumidity] - isSetpoint: - type: boolean - description: > - Optional. When true, indicates this point is a setpoint (a target - value written to control equipment behavior). - - - LiquidTemperatureSpRequestFields: - type: object - description: > - Setpoint request written by an integration (e.g., MEPAI) to a CDU. - BMS publishes metadata; the integration publishes the value to respective value topic namespace identified by `integration` - required: [engUnit] - properties: - pointType: - type: string - enum: [LiquidTemperatureSpRequest] - objectType: - type: string - enum: [CDU] - engUnit: - type: string - enum: [C] - - - GenericObjectLiquidTemperatureSpRequestFields: - type: object - description: > - Setpoint request for a GenericObject. BMS publishes metadata; - the integration publishes the value to the derived topic. - required: [engUnit] - properties: - pointType: - type: string - enum: [LiquidTemperatureSpRequest] - objectType: - type: string - enum: [GenericObject] - engUnit: - type: string - enum: [C] - - SoundFields: - type: object - required: [engUnit] - properties: - pointType: - type: string - enum: [Sound] - engUnit: - type: string - # enum: [dB] - - # --- System / Heartbeat point-type fields -------------------------------- - - HeartbeatTimestampBmsFields: - type: object - description: > - BMS-published heartbeat timestamp. The BMS publishes its own - timestamp every 10 s; one instance globally, consumed by all - connected integrations. `objectName` and `objectId` identify the - BMS itself (the publisher). The `integration` field is NOT used - on this point — `integration` is reserved for value-publisher - identity, and the BMS is the publisher here. `scope` optionally - identifies which MQTT topics this heartbeat covers. - required: - - objectName - - objectId - properties: - pointType: - type: string - enum: [HeartbeatTimestampBms] - objectName: - type: string - description: Human-readable name of the BMS publishing this heartbeat. - objectId: - type: string - description: > - Stable identifier for the BMS publishing this heartbeat - (e.g., `"BMS"`). One instance per BMS. - scope: - type: string - description: Identifies which MQTT topics this heartbeat covers. - - HeartbeatEchoBmsFields: - type: object - description: > - BMS echoes back the timestamp received from a specific integration. - Published by the BMS (one instance per connected integration). The - integration whose timestamp is being echoed is identified by - `objectName` and `objectId` — by convention, `objectId` matches - the same string used as that integration's `integration` - metadata value on its other points (e.g., `objectId: "MEPAI1"`). - The `integration` field is intentionally NOT present on this - point: `integration` denotes the value-publisher elsewhere in - the spec, and the BMS is the publisher here. The integration - being echoed is encoded in `objectId`, not in `integration`. - `scope` optionally identifies which BMS MQTT-client/topic - namespace this echo is associated with — used when the BMS - runs multiple MQTT clients connected to DSX Exchange. - required: - - objectName - - objectId - properties: - pointType: - type: string - enum: [HeartbeatEchoBms] - objectName: - type: string - description: > - Human-readable name of the integration whose timestamp is - being echoed. - objectId: - type: string - description: > - Stable identifier of the integration whose timestamp is - being echoed (e.g., `"MEPAI1"`). Matches that integration's - `integration` metadata value on its other points. - scope: - type: string - description: > - Optional. Identifies which BMS MQTT-client/topic namespace - this echo is associated with. - - HeartbeatTimestampIntegrationFields: - type: object - description: > - Integration-published heartbeat timestamp. Each connected - integration publishes its own timestamp every 10 s; one instance - per integration. `objectName` and `objectId` identify the - integration publishing this heartbeat. By convention, `objectId` - matches the same string used as this integration's `integration` - metadata value on its other points (e.g., `objectId: "MEPAI1"` with - `integration: "MEPAI1"`). The `integration` field is required via - the integration metadata base and drives the value topic. - required: - - objectName - - objectId - properties: - pointType: - type: string - enum: [HeartbeatTimestampIntegration] - objectName: - type: string - description: > - Human-readable name of the integration publishing this - heartbeat. - objectId: - type: string - description: > - Stable identifier of the integration publishing this - heartbeat (e.g., `"MEPAI1"`). By convention, matches the - `integration` metadata value used by this integration on - its other points. - - HeartbeatEchoIntegrationFields: - type: object - description: > - Integration echoes the BMS timestamp. Each integration publishes - its own echo of the BMS timestamp, allowing the BMS to confirm - round-trip with that specific integration (one instance per - connected integration). The BMS whose timestamp is being echoed - is identified by `objectName` and `objectId` - (e.g., `objectId: "BMS"`). The `integration` field is required via - the integration metadata base and drives the value topic. - required: - - objectName - - objectId - properties: - pointType: - type: string - enum: [HeartbeatEchoIntegration] - objectName: - type: string - description: > - Human-readable name of the BMS whose timestamp is being echoed. - objectId: - type: string - description: > - Stable identifier of the BMS whose timestamp is being - echoed (e.g., `"BMS"`). - - - # ========================================================================= - # SECTION 5 — Fully composed per-pointType metadata schemas - # - # Each schema = base $ref + fields $ref, both named. - # No anonymous schemas anywhere in the allOf arrays. - # ========================================================================= - - # --- Rack — BMS published ------------------------------------------------ - - RackLiquidSupplyTemperatureMetadata: - allOf: - - $ref: '#/components/schemas/RackMetadataBase' - - $ref: '#/components/schemas/RackLiquidSupplyTemperatureFields' - unevaluatedProperties: false - - RackLiquidReturnTemperatureMetadata: - allOf: - - $ref: '#/components/schemas/RackMetadataBase' - - $ref: '#/components/schemas/RackLiquidReturnTemperatureFields' - unevaluatedProperties: false - - RackLiquidFlowMetadata: - allOf: - - $ref: '#/components/schemas/RackMetadataBase' - - $ref: '#/components/schemas/RackLiquidFlowFields' - unevaluatedProperties: false - - RackLiquidDifferentialPressureMetadata: - allOf: - - $ref: '#/components/schemas/RackMetadataBase' - - $ref: '#/components/schemas/RackLiquidDifferentialPressureFields' - unevaluatedProperties: false - - RackLiquidDifferentialPressureSpFields: - type: object - required: [engUnit] - properties: - pointType: - type: string - enum: [RackLiquidDifferentialPressureSp] - objectType: - type: string - enum: [Rack] - engUnit: - type: string - enum: [kPa] - isSetpoint: - type: boolean - description: > - Optional inclusion. Indicates this point is a target value written to control equipment behavior. - - RackLiquidDifferentialPressureSpMetadata: - allOf: - - $ref: '#/components/schemas/RackMetadataBase' - - $ref: '#/components/schemas/RackLiquidDifferentialPressureSpFields' - unevaluatedProperties: false - - RackControlValvePositionMetadata: - allOf: - - $ref: '#/components/schemas/RackMetadataBase' - - $ref: '#/components/schemas/RackControlValvePositionFields' - unevaluatedProperties: false - - RackPowerMetadata: - allOf: - - $ref: '#/components/schemas/RackMetadataBase' - - $ref: '#/components/schemas/RackPowerFields' - unevaluatedProperties: false - - RackLeakDetectMetadata: - allOf: - - $ref: '#/components/schemas/RackMetadataBase' - - $ref: '#/components/schemas/RackLeakDetectFields' - unevaluatedProperties: false - - RackLeakSensorFaultMetadata: - allOf: - - $ref: '#/components/schemas/RackMetadataBase' - - $ref: '#/components/schemas/RackLeakSensorFaultFields' - unevaluatedProperties: false - - RackLiquidIsolationStatusMetadata: - allOf: - - $ref: '#/components/schemas/RackMetadataBase' - - $ref: '#/components/schemas/RackLiquidIsolationStatusFields' - unevaluatedProperties: false - - RackElectricalIsolationStatusMetadata: - allOf: - - $ref: '#/components/schemas/RackMetadataBase' - - $ref: '#/components/schemas/RackElectricalIsolationStatusFields' - unevaluatedProperties: false - - # --- Rack — Integration published ---------------------------------------- - - RackLeakDetectTrayMetadata: - allOf: - - $ref: '#/components/schemas/RackIntegrationMetadataBase' - - $ref: '#/components/schemas/RackLeakDetectTrayFields' - unevaluatedProperties: false - - RackLiquidIsolationRequestMetadata: - allOf: - - $ref: '#/components/schemas/RackIntegrationMetadataBase' - - $ref: '#/components/schemas/RackLiquidIsolationRequestFields' - unevaluatedProperties: false - - RackElectricalIsolationRequestMetadata: - allOf: - - $ref: '#/components/schemas/RackIntegrationMetadataBase' - - $ref: '#/components/schemas/RackElectricalIsolationRequestFields' - unevaluatedProperties: false - - # --- PowerMeter ---------------------------------------------------------- - - PowerMeterVoltageMetadata: - allOf: - - $ref: '#/components/schemas/PowerMeterMetadataBase' - - $ref: '#/components/schemas/VoltageFields' - unevaluatedProperties: false - - PowerMeterPowerFactorMetadata: - allOf: - - $ref: '#/components/schemas/PowerMeterMetadataBase' - - $ref: '#/components/schemas/PowerFactorFields' - unevaluatedProperties: false - - PowerMeterFrequencyMetadata: - allOf: - - $ref: '#/components/schemas/PowerMeterMetadataBase' - - $ref: '#/components/schemas/FrequencyFields' - unevaluatedProperties: false - - PowerMeterApparentPowerMetadata: - allOf: - - $ref: '#/components/schemas/PowerMeterMetadataBase' - - $ref: '#/components/schemas/ApparentPowerFields' - unevaluatedProperties: false - - PowerMeterActivePowerMetadata: - allOf: - - $ref: '#/components/schemas/PowerMeterMetadataBase' - - $ref: '#/components/schemas/ActivePowerFields' - unevaluatedProperties: false - - PowerMeterCurrentMetadata: - allOf: - - $ref: '#/components/schemas/PowerMeterMetadataBase' - - $ref: '#/components/schemas/CurrentFields' - unevaluatedProperties: false - - PowerMeterCurrentLimitMetadata: - allOf: - - $ref: '#/components/schemas/PowerMeterMetadataBase' - - $ref: '#/components/schemas/CurrentLimitFields' - unevaluatedProperties: false - - PowerMeterPhaseCurrentMetadata: - allOf: - - $ref: '#/components/schemas/PowerMeterMetadataBase' - - $ref: '#/components/schemas/PhaseCurrentFields' - unevaluatedProperties: false - - # --- Generic Equipment — BMS published ----------------------------------- - - LiquidTemperatureMetadata: - allOf: - - $ref: '#/components/schemas/EquipmentMetadataBase' - - $ref: '#/components/schemas/LiquidTemperatureFields' - unevaluatedProperties: false - - LiquidDifferentialPressureMetadata: - allOf: - - $ref: '#/components/schemas/EquipmentMeasurementModeBase' - - $ref: '#/components/schemas/LiquidDifferentialPressureFields' - unevaluatedProperties: false - - LiquidFlowMetadata: - allOf: - - $ref: '#/components/schemas/EquipmentMeasurementModeBase' - - $ref: '#/components/schemas/LiquidFlowFields' - unevaluatedProperties: false - - LiquidPressureMetadata: - allOf: - - $ref: '#/components/schemas/EquipmentMeasurementModeBase' - - $ref: '#/components/schemas/LiquidPressureFields' - unevaluatedProperties: false - - StatusMetadata: - allOf: - - $ref: '#/components/schemas/EquipmentMetadataBase' - - $ref: '#/components/schemas/StatusFields' - unevaluatedProperties: false - - AvailableMetadata: - allOf: - - $ref: '#/components/schemas/EquipmentMetadataBase' - - $ref: '#/components/schemas/AvailableFields' - unevaluatedProperties: false - - ValvePositionMetadata: - allOf: - - $ref: '#/components/schemas/EquipmentMeasurementModeBase' - - $ref: '#/components/schemas/ValvePositionFields' - unevaluatedProperties: false - - PumpSpeedMetadata: - allOf: - - $ref: '#/components/schemas/EquipmentMeasurementModeBase' - - $ref: '#/components/schemas/PumpSpeedFields' - unevaluatedProperties: false - - FanSpeedMetadata: - allOf: - - $ref: '#/components/schemas/EquipmentMeasurementModeBase' - - $ref: '#/components/schemas/FanSpeedFields' - unevaluatedProperties: false - - DamperPositionMetadata: - allOf: - - $ref: '#/components/schemas/EquipmentMeasurementModeBase' - - $ref: '#/components/schemas/DamperPositionFields' - unevaluatedProperties: false - - AirTemperatureMetadata: - allOf: - - $ref: '#/components/schemas/EquipmentMetadataBase' - - $ref: '#/components/schemas/AirTemperatureFields' - unevaluatedProperties: false - - AirDifferentialPressureMetadata: - allOf: - - $ref: '#/components/schemas/EquipmentMeasurementModeBase' - - $ref: '#/components/schemas/AirDifferentialPressureFields' - unevaluatedProperties: false - - AirFlowMetadata: - allOf: - - $ref: '#/components/schemas/EquipmentMeasurementModeBase' - - $ref: '#/components/schemas/AirFlowFields' - unevaluatedProperties: false - - AirPressureMetadata: - allOf: - - $ref: '#/components/schemas/EquipmentMeasurementModeBase' - - $ref: '#/components/schemas/AirPressureFields' - unevaluatedProperties: false - - SoundMetadata: - allOf: - - $ref: '#/components/schemas/EquipmentMetadataBase' - - $ref: '#/components/schemas/SoundFields' - unevaluatedProperties: false - - # --- Generic Equipment — Integration published --------------------------- - - LiquidTemperatureSpRequestMetadata: - allOf: - - $ref: '#/components/schemas/EquipmentIntegrationMetadataBase' - - $ref: '#/components/schemas/LiquidTemperatureSpRequestFields' - unevaluatedProperties: false - - # --- System -------------------------------------------------------------- - - SystemHeartbeatTimestampBmsMetadata: - allOf: - - $ref: '#/components/schemas/MetadataBase' - - $ref: '#/components/schemas/HeartbeatTimestampBmsFields' - unevaluatedProperties: false - - SystemHeartbeatEchoBmsMetadata: - allOf: - - $ref: '#/components/schemas/MetadataBase' - - $ref: '#/components/schemas/HeartbeatEchoBmsFields' - unevaluatedProperties: false - - SystemHeartbeatTimestampIntegrationMetadata: - allOf: - - $ref: '#/components/schemas/SystemIntegrationMetadataBase' - - $ref: '#/components/schemas/HeartbeatTimestampIntegrationFields' - unevaluatedProperties: false - - SystemHeartbeatEchoIntegrationMetadata: - allOf: - - $ref: '#/components/schemas/SystemIntegrationMetadataBase' - - $ref: '#/components/schemas/HeartbeatEchoIntegrationFields' - unevaluatedProperties: false - - SystemObjectTypeFields: - type: object - properties: - objectType: - type: string - enum: [System] - - SystemStatusMetadata: - allOf: - - $ref: '#/components/schemas/EquipmentMetadataBase' - - $ref: '#/components/schemas/StatusFields' - - $ref: '#/components/schemas/SystemObjectTypeFields' - unevaluatedProperties: false - - SystemAvailableMetadata: - allOf: - - $ref: '#/components/schemas/EquipmentMetadataBase' - - $ref: '#/components/schemas/AvailableFields' - - $ref: '#/components/schemas/SystemObjectTypeFields' - unevaluatedProperties: false - - # --- Per-objectType objectType field fragments ------------------------- - BESSObjectTypeFields: - type: object - properties: - objectType: - type: string - enum: [BESS] - - UPSObjectTypeFields: - type: object - properties: - objectType: - type: string - enum: [UPS] - - ATSObjectTypeFields: - type: object - properties: - objectType: - type: string - enum: [ATS] - - GeneratorObjectTypeFields: - type: object - properties: - objectType: - type: string - enum: [Generator] - - ShuntObjectTypeFields: - type: object - properties: - objectType: - type: string - enum: [Shunt] - - BreakerObjectTypeFields: - type: object - properties: - objectType: - type: string - enum: [Breaker] - - CDUObjectTypeFields: - type: object - properties: - objectType: - type: string - enum: [CDU] - - CoolingTowerObjectTypeFields: - type: object - properties: - objectType: - type: string - enum: [CoolingTower] - - HXObjectTypeFields: - type: object - properties: - objectType: - type: string - enum: [HX] - - CRAHObjectTypeFields: - type: object - properties: - objectType: - type: string - enum: [CRAH] - - CRACObjectTypeFields: - type: object - properties: - objectType: - type: string - enum: [CRAC] - - AHUObjectTypeFields: - type: object - properties: - objectType: - type: string - enum: [AHU] - - ChillerObjectTypeFields: - type: object - properties: - objectType: - type: string - enum: [Chiller] - - ValveObjectTypeFields: - type: object - properties: - objectType: - type: string - enum: [Valve] - - PumpObjectTypeFields: - type: object - properties: - objectType: - type: string - enum: [Pump] - - FanObjectTypeFields: - type: object - properties: - objectType: - type: string - enum: [Fan] - - DamperObjectTypeFields: - type: object - properties: - objectType: - type: string - enum: [Damper] - - SensorObjectTypeFields: - type: object - properties: - objectType: - type: string - enum: [Sensor] - - TankObjectTypeFields: - type: object - properties: - objectType: - type: string - enum: [Tank] - - GenericObjectObjectTypeFields: - type: object - properties: - objectType: - type: string - enum: [GenericObject] - - - # ========================================================================= - # Per-objectType metadata schemas (objectType constraint for examples) - # ========================================================================= - - BESSStatusMetadata: - allOf: - - $ref: '#/components/schemas/EquipmentMetadataBase' - - $ref: '#/components/schemas/StatusFields' - - $ref: '#/components/schemas/BESSObjectTypeFields' - unevaluatedProperties: false - - BESSAvailableMetadata: - allOf: - - $ref: '#/components/schemas/EquipmentMetadataBase' - - $ref: '#/components/schemas/AvailableFields' - - $ref: '#/components/schemas/BESSObjectTypeFields' - unevaluatedProperties: false - - UPSStatusMetadata: - allOf: - - $ref: '#/components/schemas/EquipmentMetadataBase' - - $ref: '#/components/schemas/StatusFields' - - $ref: '#/components/schemas/UPSObjectTypeFields' - unevaluatedProperties: false - - UPSAvailableMetadata: - allOf: - - $ref: '#/components/schemas/EquipmentMetadataBase' - - $ref: '#/components/schemas/AvailableFields' - - $ref: '#/components/schemas/UPSObjectTypeFields' - unevaluatedProperties: false - - ATSStatusMetadata: - allOf: - - $ref: '#/components/schemas/EquipmentMetadataBase' - - $ref: '#/components/schemas/StatusFields' - - $ref: '#/components/schemas/ATSObjectTypeFields' - unevaluatedProperties: false - - ATSAvailableMetadata: - allOf: - - $ref: '#/components/schemas/EquipmentMetadataBase' - - $ref: '#/components/schemas/AvailableFields' - - $ref: '#/components/schemas/ATSObjectTypeFields' - unevaluatedProperties: false - - GeneratorStatusMetadata: - allOf: - - $ref: '#/components/schemas/EquipmentMetadataBase' - - $ref: '#/components/schemas/StatusFields' - - $ref: '#/components/schemas/GeneratorObjectTypeFields' - unevaluatedProperties: false - - GeneratorAvailableMetadata: - allOf: - - $ref: '#/components/schemas/EquipmentMetadataBase' - - $ref: '#/components/schemas/AvailableFields' - - $ref: '#/components/schemas/GeneratorObjectTypeFields' - unevaluatedProperties: false - - ShuntStatusMetadata: - allOf: - - $ref: '#/components/schemas/EquipmentMetadataBase' - - $ref: '#/components/schemas/StatusFields' - - $ref: '#/components/schemas/ShuntObjectTypeFields' - unevaluatedProperties: false - - ShuntAvailableMetadata: - allOf: - - $ref: '#/components/schemas/EquipmentMetadataBase' - - $ref: '#/components/schemas/AvailableFields' - - $ref: '#/components/schemas/ShuntObjectTypeFields' - unevaluatedProperties: false - - BreakerStatusMetadata: - allOf: - - $ref: '#/components/schemas/EquipmentMetadataBase' - - $ref: '#/components/schemas/StatusFields' - - $ref: '#/components/schemas/BreakerObjectTypeFields' - unevaluatedProperties: false - - BreakerAvailableMetadata: - allOf: - - $ref: '#/components/schemas/EquipmentMetadataBase' - - $ref: '#/components/schemas/AvailableFields' - - $ref: '#/components/schemas/BreakerObjectTypeFields' - unevaluatedProperties: false - - ValveValvePositionMetadata: - allOf: - - $ref: '#/components/schemas/EquipmentMeasurementModeBase' - - $ref: '#/components/schemas/ValvePositionFields' - - $ref: '#/components/schemas/ValveObjectTypeFields' - unevaluatedProperties: false - - PumpPumpSpeedMetadata: - allOf: - - $ref: '#/components/schemas/EquipmentMeasurementModeBase' - - $ref: '#/components/schemas/PumpSpeedFields' - - $ref: '#/components/schemas/PumpObjectTypeFields' - unevaluatedProperties: false - - FanFanSpeedMetadata: - allOf: - - $ref: '#/components/schemas/EquipmentMeasurementModeBase' - - $ref: '#/components/schemas/FanSpeedFields' - - $ref: '#/components/schemas/FanObjectTypeFields' - unevaluatedProperties: false - - DamperDamperPositionMetadata: - allOf: - - $ref: '#/components/schemas/EquipmentMeasurementModeBase' - - $ref: '#/components/schemas/DamperPositionFields' - - $ref: '#/components/schemas/DamperObjectTypeFields' - unevaluatedProperties: false - - ValveAvailableMetadata: - allOf: - - $ref: '#/components/schemas/EquipmentMetadataBase' - - $ref: '#/components/schemas/AvailableFields' - - $ref: '#/components/schemas/ValveObjectTypeFields' - unevaluatedProperties: false - - PumpAvailableMetadata: - allOf: - - $ref: '#/components/schemas/EquipmentMetadataBase' - - $ref: '#/components/schemas/AvailableFields' - - $ref: '#/components/schemas/PumpObjectTypeFields' - unevaluatedProperties: false - - FanAvailableMetadata: - allOf: - - $ref: '#/components/schemas/EquipmentMetadataBase' - - $ref: '#/components/schemas/AvailableFields' - - $ref: '#/components/schemas/FanObjectTypeFields' - unevaluatedProperties: false - - DamperAvailableMetadata: - allOf: - - $ref: '#/components/schemas/EquipmentMetadataBase' - - $ref: '#/components/schemas/AvailableFields' - - $ref: '#/components/schemas/DamperObjectTypeFields' - unevaluatedProperties: false - - SensorAvailableMetadata: - allOf: - - $ref: '#/components/schemas/EquipmentMetadataBase' - - $ref: '#/components/schemas/AvailableFields' - - $ref: '#/components/schemas/SensorObjectTypeFields' - unevaluatedProperties: false - - SensorLiquidTemperatureMetadata: - allOf: - - $ref: '#/components/schemas/EquipmentMetadataBase' - - $ref: '#/components/schemas/LiquidTemperatureFields' - - $ref: '#/components/schemas/SensorObjectTypeFields' - unevaluatedProperties: false - - SensorLiquidDifferentialPressureMetadata: - allOf: - - $ref: '#/components/schemas/EquipmentMeasurementModeBase' - - $ref: '#/components/schemas/LiquidDifferentialPressureFields' - - $ref: '#/components/schemas/SensorObjectTypeFields' - unevaluatedProperties: false - - SensorLiquidFlowMetadata: - allOf: - - $ref: '#/components/schemas/EquipmentMeasurementModeBase' - - $ref: '#/components/schemas/LiquidFlowFields' - - $ref: '#/components/schemas/SensorObjectTypeFields' - unevaluatedProperties: false - - SensorLiquidPressureMetadata: - allOf: - - $ref: '#/components/schemas/EquipmentMeasurementModeBase' - - $ref: '#/components/schemas/LiquidPressureFields' - - $ref: '#/components/schemas/SensorObjectTypeFields' - unevaluatedProperties: false - - SensorAirTemperatureMetadata: - allOf: - - $ref: '#/components/schemas/EquipmentMetadataBase' - - $ref: '#/components/schemas/AirTemperatureFields' - - $ref: '#/components/schemas/SensorObjectTypeFields' - unevaluatedProperties: false - - SensorAirDifferentialPressureMetadata: - allOf: - - $ref: '#/components/schemas/EquipmentMeasurementModeBase' - - $ref: '#/components/schemas/AirDifferentialPressureFields' - - $ref: '#/components/schemas/SensorObjectTypeFields' - unevaluatedProperties: false - - SensorAirFlowMetadata: - allOf: - - $ref: '#/components/schemas/EquipmentMeasurementModeBase' - - $ref: '#/components/schemas/AirFlowFields' - - $ref: '#/components/schemas/SensorObjectTypeFields' - unevaluatedProperties: false - - SensorAirPressureMetadata: - allOf: - - $ref: '#/components/schemas/EquipmentMeasurementModeBase' - - $ref: '#/components/schemas/AirPressureFields' - - $ref: '#/components/schemas/SensorObjectTypeFields' - unevaluatedProperties: false - - SensorSoundMetadata: - allOf: - - $ref: '#/components/schemas/EquipmentMetadataBase' - - $ref: '#/components/schemas/SoundFields' - - $ref: '#/components/schemas/SensorObjectTypeFields' - unevaluatedProperties: false - - SensorLeakDetectMetadata: - allOf: - - $ref: '#/components/schemas/EquipmentMetadataBase' - - $ref: '#/components/schemas/LeakDetectFields' - - $ref: '#/components/schemas/SensorObjectTypeFields' - unevaluatedProperties: false - - SensorAirRelativeHumidityMetadata: - allOf: - - $ref: '#/components/schemas/EquipmentMeasurementModeBase' - - $ref: '#/components/schemas/AirRelativeHumidityFields' - - $ref: '#/components/schemas/SensorObjectTypeFields' - unevaluatedProperties: false - - CDULiquidTemperatureMetadata: - allOf: - - $ref: '#/components/schemas/EquipmentMetadataBase' - - $ref: '#/components/schemas/LiquidTemperatureFields' - - $ref: '#/components/schemas/CDUObjectTypeFields' - unevaluatedProperties: false - - CDULiquidDifferentialPressureMetadata: - allOf: - - $ref: '#/components/schemas/EquipmentMeasurementModeBase' - - $ref: '#/components/schemas/LiquidDifferentialPressureFields' - - $ref: '#/components/schemas/CDUObjectTypeFields' - unevaluatedProperties: false - - CDULiquidFlowMetadata: - allOf: - - $ref: '#/components/schemas/EquipmentMeasurementModeBase' - - $ref: '#/components/schemas/LiquidFlowFields' - - $ref: '#/components/schemas/CDUObjectTypeFields' - unevaluatedProperties: false - - CDULiquidPressureMetadata: - allOf: - - $ref: '#/components/schemas/EquipmentMeasurementModeBase' - - $ref: '#/components/schemas/LiquidPressureFields' - - $ref: '#/components/schemas/CDUObjectTypeFields' - unevaluatedProperties: false - - CDUStatusMetadata: - allOf: - - $ref: '#/components/schemas/EquipmentMetadataBase' - - $ref: '#/components/schemas/StatusFields' - - $ref: '#/components/schemas/CDUObjectTypeFields' - unevaluatedProperties: false - - CDUAvailableMetadata: - allOf: - - $ref: '#/components/schemas/EquipmentMetadataBase' - - $ref: '#/components/schemas/AvailableFields' - - $ref: '#/components/schemas/CDUObjectTypeFields' - unevaluatedProperties: false - - CDUValvePositionMetadata: - allOf: - - $ref: '#/components/schemas/EquipmentMeasurementModeBase' - - $ref: '#/components/schemas/ValvePositionFields' - - $ref: '#/components/schemas/CDUObjectTypeFields' - unevaluatedProperties: false - - CDUPumpSpeedMetadata: - allOf: - - $ref: '#/components/schemas/EquipmentMeasurementModeBase' - - $ref: '#/components/schemas/PumpSpeedFields' - - $ref: '#/components/schemas/CDUObjectTypeFields' - unevaluatedProperties: false - - CDUFanSpeedMetadata: - allOf: - - $ref: '#/components/schemas/EquipmentMeasurementModeBase' - - $ref: '#/components/schemas/FanSpeedFields' - - $ref: '#/components/schemas/CDUObjectTypeFields' - unevaluatedProperties: false - - CDUDamperPositionMetadata: - allOf: - - $ref: '#/components/schemas/EquipmentMeasurementModeBase' - - $ref: '#/components/schemas/DamperPositionFields' - - $ref: '#/components/schemas/CDUObjectTypeFields' - unevaluatedProperties: false - - CDUAirTemperatureMetadata: - allOf: - - $ref: '#/components/schemas/EquipmentMetadataBase' - - $ref: '#/components/schemas/AirTemperatureFields' - - $ref: '#/components/schemas/CDUObjectTypeFields' - unevaluatedProperties: false - - CDUAirDifferentialPressureMetadata: - allOf: - - $ref: '#/components/schemas/EquipmentMeasurementModeBase' - - $ref: '#/components/schemas/AirDifferentialPressureFields' - - $ref: '#/components/schemas/CDUObjectTypeFields' - unevaluatedProperties: false - - CDUAirFlowMetadata: - allOf: - - $ref: '#/components/schemas/EquipmentMeasurementModeBase' - - $ref: '#/components/schemas/AirFlowFields' - - $ref: '#/components/schemas/CDUObjectTypeFields' - unevaluatedProperties: false - - CDUAirPressureMetadata: - allOf: - - $ref: '#/components/schemas/EquipmentMeasurementModeBase' - - $ref: '#/components/schemas/AirPressureFields' - - $ref: '#/components/schemas/CDUObjectTypeFields' - unevaluatedProperties: false - - CDULeakDetectMetadata: - allOf: - - $ref: '#/components/schemas/EquipmentMetadataBase' - - $ref: '#/components/schemas/LeakDetectFields' - - $ref: '#/components/schemas/CDUObjectTypeFields' - unevaluatedProperties: false - - CDUAirRelativeHumidityMetadata: - allOf: - - $ref: '#/components/schemas/EquipmentMeasurementModeBase' - - $ref: '#/components/schemas/AirRelativeHumidityFields' - - $ref: '#/components/schemas/CDUObjectTypeFields' - unevaluatedProperties: false - - CoolingTowerLiquidTemperatureMetadata: - allOf: - - $ref: '#/components/schemas/EquipmentMetadataBase' - - $ref: '#/components/schemas/LiquidTemperatureFields' - - $ref: '#/components/schemas/CoolingTowerObjectTypeFields' - unevaluatedProperties: false - - CoolingTowerLiquidDifferentialPressureMetadata: - allOf: - - $ref: '#/components/schemas/EquipmentMeasurementModeBase' - - $ref: '#/components/schemas/LiquidDifferentialPressureFields' - - $ref: '#/components/schemas/CoolingTowerObjectTypeFields' - unevaluatedProperties: false - - CoolingTowerLiquidFlowMetadata: - allOf: - - $ref: '#/components/schemas/EquipmentMeasurementModeBase' - - $ref: '#/components/schemas/LiquidFlowFields' - - $ref: '#/components/schemas/CoolingTowerObjectTypeFields' - unevaluatedProperties: false - - CoolingTowerLiquidPressureMetadata: - allOf: - - $ref: '#/components/schemas/EquipmentMeasurementModeBase' - - $ref: '#/components/schemas/LiquidPressureFields' - - $ref: '#/components/schemas/CoolingTowerObjectTypeFields' - unevaluatedProperties: false - - CoolingTowerStatusMetadata: - allOf: - - $ref: '#/components/schemas/EquipmentMetadataBase' - - $ref: '#/components/schemas/StatusFields' - - $ref: '#/components/schemas/CoolingTowerObjectTypeFields' - unevaluatedProperties: false - - CoolingTowerAvailableMetadata: - allOf: - - $ref: '#/components/schemas/EquipmentMetadataBase' - - $ref: '#/components/schemas/AvailableFields' - - $ref: '#/components/schemas/CoolingTowerObjectTypeFields' - unevaluatedProperties: false - - CoolingTowerValvePositionMetadata: - allOf: - - $ref: '#/components/schemas/EquipmentMeasurementModeBase' - - $ref: '#/components/schemas/ValvePositionFields' - - $ref: '#/components/schemas/CoolingTowerObjectTypeFields' - unevaluatedProperties: false - - CoolingTowerPumpSpeedMetadata: - allOf: - - $ref: '#/components/schemas/EquipmentMeasurementModeBase' - - $ref: '#/components/schemas/PumpSpeedFields' - - $ref: '#/components/schemas/CoolingTowerObjectTypeFields' - unevaluatedProperties: false - - CoolingTowerFanSpeedMetadata: - allOf: - - $ref: '#/components/schemas/EquipmentMeasurementModeBase' - - $ref: '#/components/schemas/FanSpeedFields' - - $ref: '#/components/schemas/CoolingTowerObjectTypeFields' - unevaluatedProperties: false - - CoolingTowerDamperPositionMetadata: - allOf: - - $ref: '#/components/schemas/EquipmentMeasurementModeBase' - - $ref: '#/components/schemas/DamperPositionFields' - - $ref: '#/components/schemas/CoolingTowerObjectTypeFields' - unevaluatedProperties: false - - CoolingTowerAirTemperatureMetadata: - allOf: - - $ref: '#/components/schemas/EquipmentMetadataBase' - - $ref: '#/components/schemas/AirTemperatureFields' - - $ref: '#/components/schemas/CoolingTowerObjectTypeFields' - unevaluatedProperties: false - - CoolingTowerAirDifferentialPressureMetadata: - allOf: - - $ref: '#/components/schemas/EquipmentMeasurementModeBase' - - $ref: '#/components/schemas/AirDifferentialPressureFields' - - $ref: '#/components/schemas/CoolingTowerObjectTypeFields' - unevaluatedProperties: false - - CoolingTowerAirFlowMetadata: - allOf: - - $ref: '#/components/schemas/EquipmentMeasurementModeBase' - - $ref: '#/components/schemas/AirFlowFields' - - $ref: '#/components/schemas/CoolingTowerObjectTypeFields' - unevaluatedProperties: false - - CoolingTowerAirPressureMetadata: - allOf: - - $ref: '#/components/schemas/EquipmentMeasurementModeBase' - - $ref: '#/components/schemas/AirPressureFields' - - $ref: '#/components/schemas/CoolingTowerObjectTypeFields' - unevaluatedProperties: false - - CoolingTowerLeakDetectMetadata: - allOf: - - $ref: '#/components/schemas/EquipmentMetadataBase' - - $ref: '#/components/schemas/LeakDetectFields' - - $ref: '#/components/schemas/CoolingTowerObjectTypeFields' - unevaluatedProperties: false - - CoolingTowerAirRelativeHumidityMetadata: - allOf: - - $ref: '#/components/schemas/EquipmentMeasurementModeBase' - - $ref: '#/components/schemas/AirRelativeHumidityFields' - - $ref: '#/components/schemas/CoolingTowerObjectTypeFields' - unevaluatedProperties: false - - HXLiquidTemperatureMetadata: - allOf: - - $ref: '#/components/schemas/EquipmentMetadataBase' - - $ref: '#/components/schemas/LiquidTemperatureFields' - - $ref: '#/components/schemas/HXObjectTypeFields' - unevaluatedProperties: false - - HXLiquidDifferentialPressureMetadata: - allOf: - - $ref: '#/components/schemas/EquipmentMeasurementModeBase' - - $ref: '#/components/schemas/LiquidDifferentialPressureFields' - - $ref: '#/components/schemas/HXObjectTypeFields' - unevaluatedProperties: false - - HXLiquidFlowMetadata: - allOf: - - $ref: '#/components/schemas/EquipmentMeasurementModeBase' - - $ref: '#/components/schemas/LiquidFlowFields' - - $ref: '#/components/schemas/HXObjectTypeFields' - unevaluatedProperties: false - - HXLiquidPressureMetadata: - allOf: - - $ref: '#/components/schemas/EquipmentMeasurementModeBase' - - $ref: '#/components/schemas/LiquidPressureFields' - - $ref: '#/components/schemas/HXObjectTypeFields' - unevaluatedProperties: false - - HXStatusMetadata: - allOf: - - $ref: '#/components/schemas/EquipmentMetadataBase' - - $ref: '#/components/schemas/StatusFields' - - $ref: '#/components/schemas/HXObjectTypeFields' - unevaluatedProperties: false - - HXAvailableMetadata: - allOf: - - $ref: '#/components/schemas/EquipmentMetadataBase' - - $ref: '#/components/schemas/AvailableFields' - - $ref: '#/components/schemas/HXObjectTypeFields' - unevaluatedProperties: false - - HXValvePositionMetadata: - allOf: - - $ref: '#/components/schemas/EquipmentMeasurementModeBase' - - $ref: '#/components/schemas/ValvePositionFields' - - $ref: '#/components/schemas/HXObjectTypeFields' - unevaluatedProperties: false - - HXPumpSpeedMetadata: - allOf: - - $ref: '#/components/schemas/EquipmentMeasurementModeBase' - - $ref: '#/components/schemas/PumpSpeedFields' - - $ref: '#/components/schemas/HXObjectTypeFields' - unevaluatedProperties: false - - HXFanSpeedMetadata: - allOf: - - $ref: '#/components/schemas/EquipmentMeasurementModeBase' - - $ref: '#/components/schemas/FanSpeedFields' - - $ref: '#/components/schemas/HXObjectTypeFields' - unevaluatedProperties: false - - HXDamperPositionMetadata: - allOf: - - $ref: '#/components/schemas/EquipmentMeasurementModeBase' - - $ref: '#/components/schemas/DamperPositionFields' - - $ref: '#/components/schemas/HXObjectTypeFields' - unevaluatedProperties: false - - HXAirTemperatureMetadata: - allOf: - - $ref: '#/components/schemas/EquipmentMetadataBase' - - $ref: '#/components/schemas/AirTemperatureFields' - - $ref: '#/components/schemas/HXObjectTypeFields' - unevaluatedProperties: false - - HXAirDifferentialPressureMetadata: - allOf: - - $ref: '#/components/schemas/EquipmentMeasurementModeBase' - - $ref: '#/components/schemas/AirDifferentialPressureFields' - - $ref: '#/components/schemas/HXObjectTypeFields' - unevaluatedProperties: false - - HXAirFlowMetadata: - allOf: - - $ref: '#/components/schemas/EquipmentMeasurementModeBase' - - $ref: '#/components/schemas/AirFlowFields' - - $ref: '#/components/schemas/HXObjectTypeFields' - unevaluatedProperties: false - - HXAirPressureMetadata: - allOf: - - $ref: '#/components/schemas/EquipmentMeasurementModeBase' - - $ref: '#/components/schemas/AirPressureFields' - - $ref: '#/components/schemas/HXObjectTypeFields' - unevaluatedProperties: false - - HXLeakDetectMetadata: - allOf: - - $ref: '#/components/schemas/EquipmentMetadataBase' - - $ref: '#/components/schemas/LeakDetectFields' - - $ref: '#/components/schemas/HXObjectTypeFields' - unevaluatedProperties: false - - HXAirRelativeHumidityMetadata: - allOf: - - $ref: '#/components/schemas/EquipmentMeasurementModeBase' - - $ref: '#/components/schemas/AirRelativeHumidityFields' - - $ref: '#/components/schemas/HXObjectTypeFields' - unevaluatedProperties: false - - CRAHLiquidTemperatureMetadata: - allOf: - - $ref: '#/components/schemas/EquipmentMetadataBase' - - $ref: '#/components/schemas/LiquidTemperatureFields' - - $ref: '#/components/schemas/CRAHObjectTypeFields' - unevaluatedProperties: false - - CRAHLiquidDifferentialPressureMetadata: - allOf: - - $ref: '#/components/schemas/EquipmentMeasurementModeBase' - - $ref: '#/components/schemas/LiquidDifferentialPressureFields' - - $ref: '#/components/schemas/CRAHObjectTypeFields' - unevaluatedProperties: false - - CRAHLiquidFlowMetadata: - allOf: - - $ref: '#/components/schemas/EquipmentMeasurementModeBase' - - $ref: '#/components/schemas/LiquidFlowFields' - - $ref: '#/components/schemas/CRAHObjectTypeFields' - unevaluatedProperties: false - - CRAHLiquidPressureMetadata: - allOf: - - $ref: '#/components/schemas/EquipmentMeasurementModeBase' - - $ref: '#/components/schemas/LiquidPressureFields' - - $ref: '#/components/schemas/CRAHObjectTypeFields' - unevaluatedProperties: false - - CRAHStatusMetadata: - allOf: - - $ref: '#/components/schemas/EquipmentMetadataBase' - - $ref: '#/components/schemas/StatusFields' - - $ref: '#/components/schemas/CRAHObjectTypeFields' - unevaluatedProperties: false - - CRAHAvailableMetadata: - allOf: - - $ref: '#/components/schemas/EquipmentMetadataBase' - - $ref: '#/components/schemas/AvailableFields' - - $ref: '#/components/schemas/CRAHObjectTypeFields' - unevaluatedProperties: false - - CRAHValvePositionMetadata: - allOf: - - $ref: '#/components/schemas/EquipmentMeasurementModeBase' - - $ref: '#/components/schemas/ValvePositionFields' - - $ref: '#/components/schemas/CRAHObjectTypeFields' - unevaluatedProperties: false - - CRAHPumpSpeedMetadata: - allOf: - - $ref: '#/components/schemas/EquipmentMeasurementModeBase' - - $ref: '#/components/schemas/PumpSpeedFields' - - $ref: '#/components/schemas/CRAHObjectTypeFields' - unevaluatedProperties: false - - CRAHFanSpeedMetadata: - allOf: - - $ref: '#/components/schemas/EquipmentMeasurementModeBase' - - $ref: '#/components/schemas/FanSpeedFields' - - $ref: '#/components/schemas/CRAHObjectTypeFields' - unevaluatedProperties: false - - CRAHDamperPositionMetadata: - allOf: - - $ref: '#/components/schemas/EquipmentMeasurementModeBase' - - $ref: '#/components/schemas/DamperPositionFields' - - $ref: '#/components/schemas/CRAHObjectTypeFields' - unevaluatedProperties: false - - CRAHAirTemperatureMetadata: - allOf: - - $ref: '#/components/schemas/EquipmentMetadataBase' - - $ref: '#/components/schemas/AirTemperatureFields' - - $ref: '#/components/schemas/CRAHObjectTypeFields' - unevaluatedProperties: false - - CRAHAirDifferentialPressureMetadata: - allOf: - - $ref: '#/components/schemas/EquipmentMeasurementModeBase' - - $ref: '#/components/schemas/AirDifferentialPressureFields' - - $ref: '#/components/schemas/CRAHObjectTypeFields' - unevaluatedProperties: false - - CRAHAirFlowMetadata: - allOf: - - $ref: '#/components/schemas/EquipmentMeasurementModeBase' - - $ref: '#/components/schemas/AirFlowFields' - - $ref: '#/components/schemas/CRAHObjectTypeFields' - unevaluatedProperties: false - - CRAHAirPressureMetadata: - allOf: - - $ref: '#/components/schemas/EquipmentMeasurementModeBase' - - $ref: '#/components/schemas/AirPressureFields' - - $ref: '#/components/schemas/CRAHObjectTypeFields' - unevaluatedProperties: false - - CRAHLeakDetectMetadata: - allOf: - - $ref: '#/components/schemas/EquipmentMetadataBase' - - $ref: '#/components/schemas/LeakDetectFields' - - $ref: '#/components/schemas/CRAHObjectTypeFields' - unevaluatedProperties: false - - CRAHAirRelativeHumidityMetadata: - allOf: - - $ref: '#/components/schemas/EquipmentMeasurementModeBase' - - $ref: '#/components/schemas/AirRelativeHumidityFields' - - $ref: '#/components/schemas/CRAHObjectTypeFields' - unevaluatedProperties: false - - CRACLiquidTemperatureMetadata: - allOf: - - $ref: '#/components/schemas/EquipmentMetadataBase' - - $ref: '#/components/schemas/LiquidTemperatureFields' - - $ref: '#/components/schemas/CRACObjectTypeFields' - unevaluatedProperties: false - - CRACLiquidDifferentialPressureMetadata: - allOf: - - $ref: '#/components/schemas/EquipmentMeasurementModeBase' - - $ref: '#/components/schemas/LiquidDifferentialPressureFields' - - $ref: '#/components/schemas/CRACObjectTypeFields' - unevaluatedProperties: false - - CRACLiquidFlowMetadata: - allOf: - - $ref: '#/components/schemas/EquipmentMeasurementModeBase' - - $ref: '#/components/schemas/LiquidFlowFields' - - $ref: '#/components/schemas/CRACObjectTypeFields' - unevaluatedProperties: false - - CRACLiquidPressureMetadata: - allOf: - - $ref: '#/components/schemas/EquipmentMeasurementModeBase' - - $ref: '#/components/schemas/LiquidPressureFields' - - $ref: '#/components/schemas/CRACObjectTypeFields' - unevaluatedProperties: false - - CRACStatusMetadata: - allOf: - - $ref: '#/components/schemas/EquipmentMetadataBase' - - $ref: '#/components/schemas/StatusFields' - - $ref: '#/components/schemas/CRACObjectTypeFields' - unevaluatedProperties: false - - CRACAvailableMetadata: - allOf: - - $ref: '#/components/schemas/EquipmentMetadataBase' - - $ref: '#/components/schemas/AvailableFields' - - $ref: '#/components/schemas/CRACObjectTypeFields' - unevaluatedProperties: false - - CRACValvePositionMetadata: - allOf: - - $ref: '#/components/schemas/EquipmentMeasurementModeBase' - - $ref: '#/components/schemas/ValvePositionFields' - - $ref: '#/components/schemas/CRACObjectTypeFields' - unevaluatedProperties: false - - CRACPumpSpeedMetadata: - allOf: - - $ref: '#/components/schemas/EquipmentMeasurementModeBase' - - $ref: '#/components/schemas/PumpSpeedFields' - - $ref: '#/components/schemas/CRACObjectTypeFields' - unevaluatedProperties: false - - CRACFanSpeedMetadata: - allOf: - - $ref: '#/components/schemas/EquipmentMeasurementModeBase' - - $ref: '#/components/schemas/FanSpeedFields' - - $ref: '#/components/schemas/CRACObjectTypeFields' - unevaluatedProperties: false - - CRACDamperPositionMetadata: - allOf: - - $ref: '#/components/schemas/EquipmentMeasurementModeBase' - - $ref: '#/components/schemas/DamperPositionFields' - - $ref: '#/components/schemas/CRACObjectTypeFields' - unevaluatedProperties: false - - CRACAirTemperatureMetadata: - allOf: - - $ref: '#/components/schemas/EquipmentMetadataBase' - - $ref: '#/components/schemas/AirTemperatureFields' - - $ref: '#/components/schemas/CRACObjectTypeFields' - unevaluatedProperties: false - - CRACAirDifferentialPressureMetadata: - allOf: - - $ref: '#/components/schemas/EquipmentMeasurementModeBase' - - $ref: '#/components/schemas/AirDifferentialPressureFields' - - $ref: '#/components/schemas/CRACObjectTypeFields' - unevaluatedProperties: false - - CRACAirFlowMetadata: - allOf: - - $ref: '#/components/schemas/EquipmentMeasurementModeBase' - - $ref: '#/components/schemas/AirFlowFields' - - $ref: '#/components/schemas/CRACObjectTypeFields' - unevaluatedProperties: false - - CRACAirPressureMetadata: - allOf: - - $ref: '#/components/schemas/EquipmentMeasurementModeBase' - - $ref: '#/components/schemas/AirPressureFields' - - $ref: '#/components/schemas/CRACObjectTypeFields' - unevaluatedProperties: false - - CRACLeakDetectMetadata: - allOf: - - $ref: '#/components/schemas/EquipmentMetadataBase' - - $ref: '#/components/schemas/LeakDetectFields' - - $ref: '#/components/schemas/CRACObjectTypeFields' - unevaluatedProperties: false - - CRACAirRelativeHumidityMetadata: - allOf: - - $ref: '#/components/schemas/EquipmentMeasurementModeBase' - - $ref: '#/components/schemas/AirRelativeHumidityFields' - - $ref: '#/components/schemas/CRACObjectTypeFields' - unevaluatedProperties: false - - AHULiquidTemperatureMetadata: - allOf: - - $ref: '#/components/schemas/EquipmentMetadataBase' - - $ref: '#/components/schemas/LiquidTemperatureFields' - - $ref: '#/components/schemas/AHUObjectTypeFields' - unevaluatedProperties: false - - AHULiquidDifferentialPressureMetadata: - allOf: - - $ref: '#/components/schemas/EquipmentMeasurementModeBase' - - $ref: '#/components/schemas/LiquidDifferentialPressureFields' - - $ref: '#/components/schemas/AHUObjectTypeFields' - unevaluatedProperties: false - - AHULiquidFlowMetadata: - allOf: - - $ref: '#/components/schemas/EquipmentMeasurementModeBase' - - $ref: '#/components/schemas/LiquidFlowFields' - - $ref: '#/components/schemas/AHUObjectTypeFields' - unevaluatedProperties: false - - AHULiquidPressureMetadata: - allOf: - - $ref: '#/components/schemas/EquipmentMeasurementModeBase' - - $ref: '#/components/schemas/LiquidPressureFields' - - $ref: '#/components/schemas/AHUObjectTypeFields' - unevaluatedProperties: false - - AHUStatusMetadata: - allOf: - - $ref: '#/components/schemas/EquipmentMetadataBase' - - $ref: '#/components/schemas/StatusFields' - - $ref: '#/components/schemas/AHUObjectTypeFields' - unevaluatedProperties: false - - AHUAvailableMetadata: - allOf: - - $ref: '#/components/schemas/EquipmentMetadataBase' - - $ref: '#/components/schemas/AvailableFields' - - $ref: '#/components/schemas/AHUObjectTypeFields' - unevaluatedProperties: false - - AHUValvePositionMetadata: - allOf: - - $ref: '#/components/schemas/EquipmentMeasurementModeBase' - - $ref: '#/components/schemas/ValvePositionFields' - - $ref: '#/components/schemas/AHUObjectTypeFields' - unevaluatedProperties: false - - AHUPumpSpeedMetadata: - allOf: - - $ref: '#/components/schemas/EquipmentMeasurementModeBase' - - $ref: '#/components/schemas/PumpSpeedFields' - - $ref: '#/components/schemas/AHUObjectTypeFields' - unevaluatedProperties: false - - AHUFanSpeedMetadata: - allOf: - - $ref: '#/components/schemas/EquipmentMeasurementModeBase' - - $ref: '#/components/schemas/FanSpeedFields' - - $ref: '#/components/schemas/AHUObjectTypeFields' - unevaluatedProperties: false - - AHUDamperPositionMetadata: - allOf: - - $ref: '#/components/schemas/EquipmentMeasurementModeBase' - - $ref: '#/components/schemas/DamperPositionFields' - - $ref: '#/components/schemas/AHUObjectTypeFields' - unevaluatedProperties: false - - AHUAirTemperatureMetadata: - allOf: - - $ref: '#/components/schemas/EquipmentMetadataBase' - - $ref: '#/components/schemas/AirTemperatureFields' - - $ref: '#/components/schemas/AHUObjectTypeFields' - unevaluatedProperties: false - - AHUAirDifferentialPressureMetadata: - allOf: - - $ref: '#/components/schemas/EquipmentMeasurementModeBase' - - $ref: '#/components/schemas/AirDifferentialPressureFields' - - $ref: '#/components/schemas/AHUObjectTypeFields' - unevaluatedProperties: false - - AHUAirFlowMetadata: - allOf: - - $ref: '#/components/schemas/EquipmentMeasurementModeBase' - - $ref: '#/components/schemas/AirFlowFields' - - $ref: '#/components/schemas/AHUObjectTypeFields' - unevaluatedProperties: false - - AHUAirPressureMetadata: - allOf: - - $ref: '#/components/schemas/EquipmentMeasurementModeBase' - - $ref: '#/components/schemas/AirPressureFields' - - $ref: '#/components/schemas/AHUObjectTypeFields' - unevaluatedProperties: false - - AHULeakDetectMetadata: - allOf: - - $ref: '#/components/schemas/EquipmentMetadataBase' - - $ref: '#/components/schemas/LeakDetectFields' - - $ref: '#/components/schemas/AHUObjectTypeFields' - unevaluatedProperties: false - - AHUAirRelativeHumidityMetadata: - allOf: - - $ref: '#/components/schemas/EquipmentMeasurementModeBase' - - $ref: '#/components/schemas/AirRelativeHumidityFields' - - $ref: '#/components/schemas/AHUObjectTypeFields' - unevaluatedProperties: false - - ChillerLiquidTemperatureMetadata: - allOf: - - $ref: '#/components/schemas/EquipmentMetadataBase' - - $ref: '#/components/schemas/LiquidTemperatureFields' - - $ref: '#/components/schemas/ChillerObjectTypeFields' - unevaluatedProperties: false - - ChillerLiquidDifferentialPressureMetadata: - allOf: - - $ref: '#/components/schemas/EquipmentMeasurementModeBase' - - $ref: '#/components/schemas/LiquidDifferentialPressureFields' - - $ref: '#/components/schemas/ChillerObjectTypeFields' - unevaluatedProperties: false - - ChillerLiquidFlowMetadata: - allOf: - - $ref: '#/components/schemas/EquipmentMeasurementModeBase' - - $ref: '#/components/schemas/LiquidFlowFields' - - $ref: '#/components/schemas/ChillerObjectTypeFields' - unevaluatedProperties: false - - ChillerLiquidPressureMetadata: - allOf: - - $ref: '#/components/schemas/EquipmentMeasurementModeBase' - - $ref: '#/components/schemas/LiquidPressureFields' - - $ref: '#/components/schemas/ChillerObjectTypeFields' - unevaluatedProperties: false - - ChillerStatusMetadata: - allOf: - - $ref: '#/components/schemas/EquipmentMetadataBase' - - $ref: '#/components/schemas/StatusFields' - - $ref: '#/components/schemas/ChillerObjectTypeFields' - unevaluatedProperties: false - - ChillerAvailableMetadata: - allOf: - - $ref: '#/components/schemas/EquipmentMetadataBase' - - $ref: '#/components/schemas/AvailableFields' - - $ref: '#/components/schemas/ChillerObjectTypeFields' - unevaluatedProperties: false - - ChillerValvePositionMetadata: - allOf: - - $ref: '#/components/schemas/EquipmentMeasurementModeBase' - - $ref: '#/components/schemas/ValvePositionFields' - - $ref: '#/components/schemas/ChillerObjectTypeFields' - unevaluatedProperties: false - - ChillerPumpSpeedMetadata: - allOf: - - $ref: '#/components/schemas/EquipmentMeasurementModeBase' - - $ref: '#/components/schemas/PumpSpeedFields' - - $ref: '#/components/schemas/ChillerObjectTypeFields' - unevaluatedProperties: false - - ChillerFanSpeedMetadata: - allOf: - - $ref: '#/components/schemas/EquipmentMeasurementModeBase' - - $ref: '#/components/schemas/FanSpeedFields' - - $ref: '#/components/schemas/ChillerObjectTypeFields' - unevaluatedProperties: false - - ChillerDamperPositionMetadata: - allOf: - - $ref: '#/components/schemas/EquipmentMeasurementModeBase' - - $ref: '#/components/schemas/DamperPositionFields' - - $ref: '#/components/schemas/ChillerObjectTypeFields' - unevaluatedProperties: false - - ChillerAirTemperatureMetadata: - allOf: - - $ref: '#/components/schemas/EquipmentMetadataBase' - - $ref: '#/components/schemas/AirTemperatureFields' - - $ref: '#/components/schemas/ChillerObjectTypeFields' - unevaluatedProperties: false - - ChillerAirDifferentialPressureMetadata: - allOf: - - $ref: '#/components/schemas/EquipmentMeasurementModeBase' - - $ref: '#/components/schemas/AirDifferentialPressureFields' - - $ref: '#/components/schemas/ChillerObjectTypeFields' - unevaluatedProperties: false - - ChillerAirFlowMetadata: - allOf: - - $ref: '#/components/schemas/EquipmentMeasurementModeBase' - - $ref: '#/components/schemas/AirFlowFields' - - $ref: '#/components/schemas/ChillerObjectTypeFields' - unevaluatedProperties: false - - ChillerAirPressureMetadata: - allOf: - - $ref: '#/components/schemas/EquipmentMeasurementModeBase' - - $ref: '#/components/schemas/AirPressureFields' - - $ref: '#/components/schemas/ChillerObjectTypeFields' - unevaluatedProperties: false - - ChillerLeakDetectMetadata: - allOf: - - $ref: '#/components/schemas/EquipmentMetadataBase' - - $ref: '#/components/schemas/LeakDetectFields' - - $ref: '#/components/schemas/ChillerObjectTypeFields' - unevaluatedProperties: false - - ChillerAirRelativeHumidityMetadata: - allOf: - - $ref: '#/components/schemas/EquipmentMeasurementModeBase' - - $ref: '#/components/schemas/AirRelativeHumidityFields' - - $ref: '#/components/schemas/ChillerObjectTypeFields' - unevaluatedProperties: false - - # --- Tank — BMS published ------------------------------------------------ - - TankLiquidTemperatureMetadata: - allOf: - - $ref: '#/components/schemas/EquipmentMetadataBase' - - $ref: '#/components/schemas/LiquidTemperatureFields' - - $ref: '#/components/schemas/TankObjectTypeFields' - unevaluatedProperties: false - - TankLiquidDifferentialPressureMetadata: - allOf: - - $ref: '#/components/schemas/EquipmentMeasurementModeBase' - - $ref: '#/components/schemas/LiquidDifferentialPressureFields' - - $ref: '#/components/schemas/TankObjectTypeFields' - unevaluatedProperties: false - - TankLiquidFlowMetadata: - allOf: - - $ref: '#/components/schemas/EquipmentMeasurementModeBase' - - $ref: '#/components/schemas/LiquidFlowFields' - - $ref: '#/components/schemas/TankObjectTypeFields' - unevaluatedProperties: false - - TankLiquidPressureMetadata: - allOf: - - $ref: '#/components/schemas/EquipmentMeasurementModeBase' - - $ref: '#/components/schemas/LiquidPressureFields' - - $ref: '#/components/schemas/TankObjectTypeFields' - unevaluatedProperties: false - - TankStatusMetadata: - allOf: - - $ref: '#/components/schemas/EquipmentMetadataBase' - - $ref: '#/components/schemas/StatusFields' - - $ref: '#/components/schemas/TankObjectTypeFields' - unevaluatedProperties: false - - TankAvailableMetadata: - allOf: - - $ref: '#/components/schemas/EquipmentMetadataBase' - - $ref: '#/components/schemas/AvailableFields' - - $ref: '#/components/schemas/TankObjectTypeFields' - unevaluatedProperties: false - - TankValvePositionMetadata: - allOf: - - $ref: '#/components/schemas/EquipmentMeasurementModeBase' - - $ref: '#/components/schemas/ValvePositionFields' - - $ref: '#/components/schemas/TankObjectTypeFields' - unevaluatedProperties: false - - TankPumpSpeedMetadata: - allOf: - - $ref: '#/components/schemas/EquipmentMeasurementModeBase' - - $ref: '#/components/schemas/PumpSpeedFields' - - $ref: '#/components/schemas/TankObjectTypeFields' - unevaluatedProperties: false - - TankFanSpeedMetadata: - allOf: - - $ref: '#/components/schemas/EquipmentMeasurementModeBase' - - $ref: '#/components/schemas/FanSpeedFields' - - $ref: '#/components/schemas/TankObjectTypeFields' - unevaluatedProperties: false - - TankDamperPositionMetadata: - allOf: - - $ref: '#/components/schemas/EquipmentMeasurementModeBase' - - $ref: '#/components/schemas/DamperPositionFields' - - $ref: '#/components/schemas/TankObjectTypeFields' - unevaluatedProperties: false - - TankAirTemperatureMetadata: - allOf: - - $ref: '#/components/schemas/EquipmentMetadataBase' - - $ref: '#/components/schemas/AirTemperatureFields' - - $ref: '#/components/schemas/TankObjectTypeFields' - unevaluatedProperties: false - - TankAirDifferentialPressureMetadata: - allOf: - - $ref: '#/components/schemas/EquipmentMeasurementModeBase' - - $ref: '#/components/schemas/AirDifferentialPressureFields' - - $ref: '#/components/schemas/TankObjectTypeFields' - unevaluatedProperties: false - - TankAirFlowMetadata: - allOf: - - $ref: '#/components/schemas/EquipmentMeasurementModeBase' - - $ref: '#/components/schemas/AirFlowFields' - - $ref: '#/components/schemas/TankObjectTypeFields' - unevaluatedProperties: false - - TankAirPressureMetadata: - allOf: - - $ref: '#/components/schemas/EquipmentMeasurementModeBase' - - $ref: '#/components/schemas/AirPressureFields' - - $ref: '#/components/schemas/TankObjectTypeFields' - unevaluatedProperties: false - - TankLeakDetectMetadata: - allOf: - - $ref: '#/components/schemas/EquipmentMetadataBase' - - $ref: '#/components/schemas/LeakDetectFields' - - $ref: '#/components/schemas/TankObjectTypeFields' - unevaluatedProperties: false - - TankAirRelativeHumidityMetadata: - allOf: - - $ref: '#/components/schemas/EquipmentMeasurementModeBase' - - $ref: '#/components/schemas/AirRelativeHumidityFields' - - $ref: '#/components/schemas/TankObjectTypeFields' - unevaluatedProperties: false - - # --- Tank — Integration published ---------------------------------------- - - # --- GenericObject — BMS published ---------------------------------------- - - GenericObjectLiquidTemperatureMetadata: - allOf: - - $ref: '#/components/schemas/EquipmentMetadataBase' - - $ref: '#/components/schemas/LiquidTemperatureFields' - - $ref: '#/components/schemas/GenericObjectObjectTypeFields' - unevaluatedProperties: false - - GenericObjectLiquidDifferentialPressureMetadata: - allOf: - - $ref: '#/components/schemas/EquipmentMeasurementModeBase' - - $ref: '#/components/schemas/LiquidDifferentialPressureFields' - - $ref: '#/components/schemas/GenericObjectObjectTypeFields' - unevaluatedProperties: false - - GenericObjectLiquidFlowMetadata: - allOf: - - $ref: '#/components/schemas/EquipmentMeasurementModeBase' - - $ref: '#/components/schemas/LiquidFlowFields' - - $ref: '#/components/schemas/GenericObjectObjectTypeFields' - unevaluatedProperties: false - - GenericObjectLiquidPressureMetadata: - allOf: - - $ref: '#/components/schemas/EquipmentMeasurementModeBase' - - $ref: '#/components/schemas/LiquidPressureFields' - - $ref: '#/components/schemas/GenericObjectObjectTypeFields' - unevaluatedProperties: false - - GenericObjectStatusMetadata: - allOf: - - $ref: '#/components/schemas/EquipmentMetadataBase' - - $ref: '#/components/schemas/StatusFields' - - $ref: '#/components/schemas/GenericObjectObjectTypeFields' - unevaluatedProperties: false - - GenericObjectAvailableMetadata: - allOf: - - $ref: '#/components/schemas/EquipmentMetadataBase' - - $ref: '#/components/schemas/AvailableFields' - - $ref: '#/components/schemas/GenericObjectObjectTypeFields' - unevaluatedProperties: false - - GenericObjectValvePositionMetadata: - allOf: - - $ref: '#/components/schemas/EquipmentMeasurementModeBase' - - $ref: '#/components/schemas/ValvePositionFields' - - $ref: '#/components/schemas/GenericObjectObjectTypeFields' - unevaluatedProperties: false - - GenericObjectPumpSpeedMetadata: - allOf: - - $ref: '#/components/schemas/EquipmentMeasurementModeBase' - - $ref: '#/components/schemas/PumpSpeedFields' - - $ref: '#/components/schemas/GenericObjectObjectTypeFields' - unevaluatedProperties: false - - GenericObjectFanSpeedMetadata: - allOf: - - $ref: '#/components/schemas/EquipmentMeasurementModeBase' - - $ref: '#/components/schemas/FanSpeedFields' - - $ref: '#/components/schemas/GenericObjectObjectTypeFields' - unevaluatedProperties: false - - GenericObjectDamperPositionMetadata: - allOf: - - $ref: '#/components/schemas/EquipmentMeasurementModeBase' - - $ref: '#/components/schemas/DamperPositionFields' - - $ref: '#/components/schemas/GenericObjectObjectTypeFields' - unevaluatedProperties: false - - GenericObjectAirTemperatureMetadata: - allOf: - - $ref: '#/components/schemas/EquipmentMetadataBase' - - $ref: '#/components/schemas/AirTemperatureFields' - - $ref: '#/components/schemas/GenericObjectObjectTypeFields' - unevaluatedProperties: false - - GenericObjectAirDifferentialPressureMetadata: - allOf: - - $ref: '#/components/schemas/EquipmentMeasurementModeBase' - - $ref: '#/components/schemas/AirDifferentialPressureFields' - - $ref: '#/components/schemas/GenericObjectObjectTypeFields' - unevaluatedProperties: false - - GenericObjectAirFlowMetadata: - allOf: - - $ref: '#/components/schemas/EquipmentMeasurementModeBase' - - $ref: '#/components/schemas/AirFlowFields' - - $ref: '#/components/schemas/GenericObjectObjectTypeFields' - unevaluatedProperties: false - - GenericObjectAirPressureMetadata: - allOf: - - $ref: '#/components/schemas/EquipmentMeasurementModeBase' - - $ref: '#/components/schemas/AirPressureFields' - - $ref: '#/components/schemas/GenericObjectObjectTypeFields' - unevaluatedProperties: false - - GenericObjectSoundMetadata: - allOf: - - $ref: '#/components/schemas/EquipmentMetadataBase' - - $ref: '#/components/schemas/SoundFields' - - $ref: '#/components/schemas/GenericObjectObjectTypeFields' - unevaluatedProperties: false - - GenericObjectLeakDetectMetadata: - allOf: - - $ref: '#/components/schemas/EquipmentMetadataBase' - - $ref: '#/components/schemas/LeakDetectFields' - - $ref: '#/components/schemas/GenericObjectObjectTypeFields' - unevaluatedProperties: false - - GenericObjectAirRelativeHumidityMetadata: - allOf: - - $ref: '#/components/schemas/EquipmentMeasurementModeBase' - - $ref: '#/components/schemas/AirRelativeHumidityFields' - - $ref: '#/components/schemas/GenericObjectObjectTypeFields' - unevaluatedProperties: false - - # --- GenericObject — Integration published -------------------------------- - - GenericObjectLiquidTemperatureSpRequestMetadata: - allOf: - - $ref: '#/components/schemas/EquipmentIntegrationMetadataBase' - - $ref: '#/components/schemas/GenericObjectLiquidTemperatureSpRequestFields' - unevaluatedProperties: false - - # ========================================================================= - # GenericPoint — field fragment and composed schemas - # ========================================================================= - - GenericPointFields: - type: object - description: > - Field fragment for a vendor-specific or unmapped GenericPoint. - `processArea` is required. - - - `engUnit` and `stateText` are both optional but **mutually exclusive** — - include at most one. See the two variants below. - required: - - processArea - properties: - pointType: - type: string - enum: [GenericPoint] - processArea: - type: array - items: - type: string - description: > - Required for GenericPoint. Describes the measurement context. - phase: - type: string - enum: [A, B, C, "1", "2", "3"] - description: > - Optional electrical phase identifier. Use for phase-specific - generic measurements on power-capable equipment. Letter form - (A/B/C) and numeric form (1/2/3) are both accepted. - integration: - type: string - description: > - Optional integration identifier. When present, this integration - is responsible for publishing the value for this point. - The value topic is derived using the standard topic derivation rule. - isSetpoint: - type: boolean - description: > - Optional. When true, indicates this point is a setpoint (a target - value written to control equipment behavior). - anyOf: - - title: Measurement - description: > - Continuous measurement with a known engineering unit. - When `engUnit` is present, `stateText` must be absent. - properties: - engUnit: - type: string - description: > - Engineering unit for the measurement. - not: - required: [stateText] - - title: State - description: > - Binary or enumerated state point. - When `stateText` is present, `engUnit` must be absent. - properties: - stateText: - type: array - description: > - State label mapping. Each entry maps a numeric state value to its - human-readable label (e.g., `[{value: 0, text: "Off"}, {value: 1, text: "On"}]`). - items: - type: object - required: [value, text] - properties: - value: - type: integer - description: Numeric state value. - text: - type: string - description: Human-readable label for this state. - additionalProperties: false - not: - required: [engUnit] - - - GenericEquipmentPointMetadata: - description: | - Metadata for a GenericPoint on any generic equipment objectType. - - Follows the standard equipment identifier modes: - - **Named-object mode**: objectName + objectId required; servesId - optional; associateId prohibited. - - **Associate mode**: associateId required; objectName/objectId/servesId - prohibited. - - Unlike typed points, `engUnit` and `stateText` are both optional. - `processArea` is required. - allOf: - - $ref: '#/components/schemas/EquipmentMetadataBase' - - $ref: '#/components/schemas/GenericPointFields' - unevaluatedProperties: false - - GenericPowerMeterPointMetadata: - description: | - Metadata for a GenericPoint on PowerMeter equipment. - Inherits the standard PowerMeter identifiers - (objectName, objectId, servesId all required). - `processArea` is required. `engUnit`, `stateText`, `phase`, and - `integration` are optional. - allOf: - - $ref: '#/components/schemas/PowerMeterMetadataBase' - - $ref: '#/components/schemas/GenericPointFields' - unevaluatedProperties: false diff --git a/mcp/dsx-exchange-mcp/schemas/asyncapi/launch-layer/notifications.yaml b/mcp/dsx-exchange-mcp/schemas/asyncapi/launch-layer/notifications.yaml deleted file mode 100644 index 4540794..0000000 --- a/mcp/dsx-exchange-mcp/schemas/asyncapi/launch-layer/notifications.yaml +++ /dev/null @@ -1,2 +0,0 @@ -# Copyright 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. -# SPDX-License-Identifier: Apache-2.0 diff --git a/mcp/dsx-exchange-mcp/schemas/asyncapi/mission-control/leak-response.yaml b/mcp/dsx-exchange-mcp/schemas/asyncapi/mission-control/leak-response.yaml deleted file mode 100644 index 4540794..0000000 --- a/mcp/dsx-exchange-mcp/schemas/asyncapi/mission-control/leak-response.yaml +++ /dev/null @@ -1,2 +0,0 @@ -# Copyright 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. -# SPDX-License-Identifier: Apache-2.0 diff --git a/mcp/dsx-exchange-mcp/schemas/asyncapi/monitoring/break-fix.yaml b/mcp/dsx-exchange-mcp/schemas/asyncapi/monitoring/break-fix.yaml deleted file mode 100644 index 4540794..0000000 --- a/mcp/dsx-exchange-mcp/schemas/asyncapi/monitoring/break-fix.yaml +++ /dev/null @@ -1,2 +0,0 @@ -# Copyright 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. -# SPDX-License-Identifier: Apache-2.0 diff --git a/mcp/dsx-exchange-mcp/schemas/asyncapi/nico/nico.yaml b/mcp/dsx-exchange-mcp/schemas/asyncapi/nico/nico.yaml deleted file mode 100644 index 0e2420f..0000000 --- a/mcp/dsx-exchange-mcp/schemas/asyncapi/nico/nico.yaml +++ /dev/null @@ -1,1796 +0,0 @@ -# Copyright 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. -# SPDX-License-Identifier: Apache-2.0 - -asyncapi: 3.1.0 -info: - title: NICo Managed Host State Event Bus - version: 1.0.0 - description: > - AsyncAPI specification for NICo Managed Host State change events. - This spec defines the message structure for publishing managed host state - transitions and related metadata including DPU states, host initialization, - validation, assignment, and reprovisioning states. - - The MQTT topic path should follow the following format: - - - **State Changes**: NICO/v1/machine/{machineId}/state - -servers: - production: - host: broker.example.com - protocol: mqtt - description: MQTT broker for NICo managed host state events - -channels: - managedHostState: - address: "NICO/v1/machine/{machineId}/state" - parameters: - machineId: - description: Unique identifier for the managed host machine. - messages: - stateChangeMessage: - $ref: "#/components/messages/ManagedHostStateChangeMessage" - -operations: - publishManagedHostStateChange: - action: send - channel: - $ref: "#/channels/managedHostState" - messages: - - $ref: "#/channels/managedHostState/messages/stateChangeMessage" - description: > - Publish managed host state change events when a host transitions - between states in the lifecycle. - - subscribeManagedHostStateChange: - action: receive - channel: - $ref: "#/channels/managedHostState" - messages: - - $ref: "#/channels/managedHostState/messages/stateChangeMessage" - description: > - Subscribe to managed host state change events for monitoring, - orchestration, and automation purposes. - -components: - messages: - ManagedHostStateChangeMessage: - name: ManagedHostStateChangeMessage - title: Managed Host State Change - payload: - type: object - required: - - machine_id - - managed_host_state - - timestamp - properties: - machine_id: - type: string - description: Unique identifier for the managed host machine. - timestamp: - type: string - format: date-time - description: ISO 8601 timestamp of the state change. - managed_host_state: - $ref: "#/components/schemas/ManagedHostState" - description: The managed host state object with state name and details. - - schemas: - ManagedHostState: - description: - Possible Machine state-machine implementation Possible ManagedHost state-machine implementation Only DPU machine - field in DB will contain state. Host will be empty. DPU state field will be used to derive state for DPU and Host both. - oneOf: - - description: Dpu was discovered by a site-explorer and is being configuring via redfish. - type: object - required: - - dpu_states - - state - properties: - dpu_states: - $ref: "#/components/schemas/DpuDiscoveringStates" - state: - type: string - enum: - - dpudiscoveringstate - - description: DPU is not yet ready. - type: object - required: - - dpu_states - - state - properties: - dpu_states: - $ref: "#/components/schemas/DpuInitStates" - state: - type: string - enum: - - dpuinit - - description: DPU is ready, Host is not yet Ready. - type: object - required: - - machine_state - - state - properties: - machine_state: - $ref: "#/components/schemas/MachineState" - state: - type: string - enum: - - hostinit - - description: Host validation state for machine and DPU validation - type: object - required: - - state - - validation_state - properties: - state: - type: string - enum: - - validation - validation_state: - $ref: "#/components/schemas/ValidationState" - - description: Host is Ready for instance creation. - type: object - required: - - state - properties: - state: - type: string - enum: - - ready - - description: Host is assigned to an Instance. - type: object - required: - - instance_state - - state - properties: - instance_state: - $ref: "#/components/schemas/InstanceState" - state: - type: string - enum: - - assigned - - description: Some cleanup is going on. - type: object - required: - - cleanup_state - - state - properties: - cleanup_state: - $ref: "#/components/schemas/CleanupState" - state: - type: string - enum: - - waitingforcleanup - - description: - A forced deletion process has been triggered by the admin CLI State controller will no longer manage the - Machine - type: object - required: - - state - properties: - state: - type: string - enum: - - forcedeletion - - description: A dummy state used to create DPU in beginning. State will sync to Init when host will be created. - type: object - required: - - state - properties: - state: - type: string - enum: - - created - - description: Machine moved to failed state. Recovery will be based on FailedCause - type: object - required: - - details - - machine_id - - state - properties: - details: - $ref: "#/components/schemas/FailureDetails" - machine_id: - $ref: "#/components/schemas/MachineId" - retry_count: - default: 0 - type: integer - format: uint32 - minimum: 0.0 - state: - type: string - enum: - - failed - - description: State used to indicate that DPU reprovisioning is going on. - type: object - required: - - dpu_states - - state - properties: - dpu_states: - $ref: "#/components/schemas/DpuReprovisionStates" - state: - type: string - enum: - - dpureprovision - - description: State used to indicate that host reprovisioning is going on - type: object - required: - - reprovision_state - - state - properties: - reprovision_state: - $ref: "#/components/schemas/HostReprovisionState" - retry_count: - default: 0 - type: integer - format: uint32 - minimum: 0.0 - state: - type: string - enum: - - hostreprovision - - description: - State used to indicate the API is currently waiting on the machine to send attestation measurements, or waiting - for measurements to match a valid/approved measurement bundle, before continuing on towards a Ready state. - type: object - required: - - measuring_state - - state - properties: - measuring_state: - $ref: "#/components/schemas/MeasuringState" - state: - type: string - enum: - - measuring - - type: object - required: - - measuring_state - - state - properties: - measuring_state: - $ref: "#/components/schemas/MeasuringState" - state: - type: string - enum: - - postassignedmeasuring - - type: object - required: - - bom_validating_state - - state - properties: - bom_validating_state: - $ref: "#/components/schemas/BomValidating" - state: - type: string - enum: - - bomvalidating - BmcFirmwareUpgradeSubstate: - oneOf: - - type: object - required: - - bmcfirmwareupdatesubstate - properties: - bmcfirmwareupdatesubstate: - type: string - enum: - - checkfwversion - - type: object - required: - - bmcfirmwareupdatesubstate - - firmware_type - - task_id - properties: - bmcfirmwareupdatesubstate: - type: string - enum: - - waitforupdatecompletion - firmware_type: - $ref: "#/components/schemas/FirmwareComponentType" - task_id: - type: string - - type: object - required: - - bmcfirmwareupdatesubstate - - count - properties: - bmcfirmwareupdatesubstate: - type: string - enum: - - reboot - count: - type: integer - format: uint32 - minimum: 0.0 - - type: object - required: - - bmcfirmwareupdatesubstate - properties: - bmcfirmwareupdatesubstate: - type: string - enum: - - waitforerotbackgroundcopytocomplete - - type: object - required: - - bmcfirmwareupdatesubstate - properties: - bmcfirmwareupdatesubstate: - type: string - enum: - - hostpowercycle - - type: object - required: - - bmcfirmwareupdatesubstate - - failure_details - properties: - bmcfirmwareupdatesubstate: - type: string - enum: - - failed - failure_details: - type: string - - type: object - required: - - bmcfirmwareupdatesubstate - properties: - bmcfirmwareupdatesubstate: - type: string - enum: - - fwupdatecompleted - BomValidating: - oneOf: - - type: object - required: - - MatchingSku - properties: - MatchingSku: - $ref: "#/components/schemas/BomValidatingContext" - additionalProperties: false - - type: object - required: - - UpdatingInventory - properties: - UpdatingInventory: - $ref: "#/components/schemas/BomValidatingContext" - additionalProperties: false - - type: object - required: - - VerifyingSku - properties: - VerifyingSku: - $ref: "#/components/schemas/BomValidatingContext" - additionalProperties: false - - type: object - required: - - SkuVerificationFailed - properties: - SkuVerificationFailed: - $ref: "#/components/schemas/BomValidatingContext" - additionalProperties: false - - type: object - required: - - WaitingForSkuAssignment - properties: - WaitingForSkuAssignment: - $ref: "#/components/schemas/BomValidatingContext" - additionalProperties: false - - type: object - required: - - SkuMissing - properties: - SkuMissing: - $ref: "#/components/schemas/BomValidatingContext" - additionalProperties: false - BomValidatingContext: - description: A context for passing information between states thoughout the BOM validation process. - type: object - properties: - machine_validation_context: - type: - - string - - "null" - reboot_retry_count: - type: - - integer - - "null" - format: int64 - CleanupState: - oneOf: - - type: object - required: - - state - properties: - state: - type: string - enum: - - init - - type: object - required: - - secure_erase_boss_context - - state - properties: - secure_erase_boss_context: - $ref: "#/components/schemas/SecureEraseBossContext" - state: - type: string - enum: - - secureeraseboss - - type: object - required: - - state - properties: - boss_controller_id: - type: - - string - - "null" - state: - type: string - enum: - - hostcleanup - - type: object - required: - - create_boss_volume_context - - state - properties: - create_boss_volume_context: - $ref: "#/components/schemas/CreateBossVolumeContext" - state: - type: string - enum: - - createbossvolume - - type: object - required: - - state - properties: - state: - type: string - enum: - - disablebiosbmclockdown - CreateBossVolumeContext: - type: object - required: - - boss_controller_id - - create_boss_volume_state - properties: - boss_controller_id: - type: string - create_boss_volume_jid: - type: - - string - - "null" - create_boss_volume_state: - $ref: "#/components/schemas/CreateBossVolumeState" - iteration: - type: - - integer - - "null" - format: uint32 - minimum: 0.0 - CreateBossVolumeState: - oneOf: - - type: object - required: - - state - properties: - state: - type: string - enum: - - createbossvolume - - type: object - required: - - state - properties: - state: - type: string - enum: - - waitforjobscheduled - - type: object - required: - - state - properties: - state: - type: string - enum: - - reboothost - - type: object - required: - - state - properties: - state: - type: string - enum: - - waitforjobcompletion - - type: object - required: - - failure - - power_state - - state - properties: - failure: - type: string - power_state: - type: string - state: - type: string - enum: - - handlejobfailure - - type: object - required: - - state - properties: - state: - type: string - enum: - - lockhost - DpuDiscoveringState: - oneOf: - - description: Dpu discovery via redfish states - type: object - required: - - dpudiscoverystate - properties: - dpudiscoverystate: - type: string - enum: - - initializing - - type: object - required: - - dpudiscoverystate - properties: - dpudiscoverystate: - type: string - enum: - - configuring - - type: object - required: - - dpudiscoverystate - properties: - dpudiscoverystate: - type: string - enum: - - rebootalldpus - - type: object - required: - - count - - dpudiscoverystate - - enable_secure_boot_state - properties: - count: - type: integer - format: uint32 - minimum: 0.0 - dpudiscoverystate: - type: string - enum: - - enablesecureboot - enable_secure_boot_state: - $ref: "#/components/schemas/SetSecureBootState" - - type: object - required: - - count - - dpudiscoverystate - properties: - count: - type: integer - format: uint32 - minimum: 0.0 - disable_secure_boot_state: - anyOf: - - $ref: "#/components/schemas/SetSecureBootState" - - type: "null" - dpudiscoverystate: - type: string - enum: - - disablesecureboot - - type: object - required: - - dpudiscoverystate - properties: - dpudiscoverystate: - type: string - enum: - - setuefihttpboot - - type: object - required: - - dpudiscoverystate - properties: - dpudiscoverystate: - type: string - enum: - - enablershim - DpuDiscoveringStates: - type: object - required: - - states - properties: - states: - type: object - additionalProperties: - $ref: "#/components/schemas/DpuDiscoveringState" - DpuInitState: - oneOf: - - type: object - required: - - dpustate - - substate - properties: - dpustate: - type: string - enum: - - installdpuos - substate: - $ref: "#/components/schemas/InstallDpuOsState" - - type: object - required: - - dpustate - properties: - dpustate: - type: string - enum: - - init - - type: object - required: - - dpustate - - substate - properties: - dpustate: - type: string - enum: - - waitingforplatformpowercycle - substate: - $ref: "#/components/schemas/PerformPowerOperation" - - type: object - required: - - dpustate - properties: - dpustate: - type: string - enum: - - waitingforplatformconfiguration - - type: object - required: - - dpustate - properties: - dpustate: - type: string - enum: - - pollingbiossetup - - type: object - required: - - dpustate - properties: - dpustate: - type: string - enum: - - waitingfornetworkconfig - - type: object - required: - - dpustate - properties: - dpustate: - type: string - enum: - - waitingfornetworkinstall - DpuInitStates: - type: object - required: - - states - properties: - states: - type: object - additionalProperties: - $ref: "#/components/schemas/DpuInitState" - DpuReprovisionStates: - type: object - required: - - states - properties: - states: - type: object - additionalProperties: - $ref: "#/components/schemas/ReprovisionState" - FailureCause: - oneOf: - - type: string - enum: - - noerror - - type: object - required: - - nvmecleanfailed - properties: - nvmecleanfailed: - type: object - required: - - err - properties: - err: - type: string - additionalProperties: false - - type: object - required: - - discovery - properties: - discovery: - type: object - required: - - err - properties: - err: - type: string - additionalProperties: false - - type: object - required: - - reprovisioning - properties: - reprovisioning: - type: object - required: - - err - properties: - err: - type: string - additionalProperties: false - - type: object - required: - - machinevalidation - properties: - machinevalidation: - type: object - required: - - err - properties: - err: - type: string - additionalProperties: false - - type: object - required: - - unhandledstate - properties: - unhandledstate: - type: object - required: - - err - properties: - err: - type: string - additionalProperties: false - - type: object - required: - - measurementsfailedsignaturecheck - properties: - measurementsfailedsignaturecheck: - type: object - required: - - err - properties: - err: - type: string - additionalProperties: false - - type: object - required: - - measurementsretired - properties: - measurementsretired: - type: object - required: - - err - properties: - err: - type: string - additionalProperties: false - - type: object - required: - - measurementsrevoked - properties: - measurementsrevoked: - type: object - required: - - err - properties: - err: - type: string - additionalProperties: false - - type: object - required: - - measurementscavalidationfailed - properties: - measurementscavalidationfailed: - type: object - required: - - err - properties: - err: - type: string - additionalProperties: false - FailureDetails: - type: object - required: - - cause - - failed_at - - source - properties: - cause: - $ref: "#/components/schemas/FailureCause" - failed_at: - type: string - format: date-time - source: - $ref: "#/components/schemas/FailureSource" - FailureSource: - oneOf: - - type: string - enum: - - noerror - - scout - - statemachine - - type: object - required: - - statemachinearea - properties: - statemachinearea: - $ref: "#/components/schemas/StateMachineArea" - additionalProperties: false - FirmwareComponentType: - type: string - enum: - - bmc - - cec - - uefi - - nic - - cpldmb - - cpldpdb - - hgxbmc - - combinedbmcuefi - - gpu - - unknown - HostPlatformConfigurationState: - oneOf: - - type: object - required: - - power_on - - state - properties: - power_on: - type: boolean - state: - type: string - enum: - - powercycle - - type: object - required: - - state - properties: - state: - type: string - enum: - - checkhostconfig - - type: object - required: - - state - properties: - state: - type: string - enum: - - unlockhost - - type: object - required: - - state - properties: - state: - type: string - enum: - - configurebios - - type: object - required: - - state - properties: - state: - type: string - enum: - - pollingbiossetup - - type: object - required: - - set_boot_order_info - - state - properties: - set_boot_order_info: - $ref: "#/components/schemas/SetBootOrderInfo" - state: - type: string - enum: - - setbootorder - - type: object - required: - - state - properties: - state: - type: string - enum: - - lockhost - HostReprovisionState: - oneOf: - - type: string - enum: - - checkingfirmware - - checkingfirmwarerepeat - - type: object - required: - - initialreset - properties: - initialreset: - type: object - required: - - last_time - - phase - properties: - last_time: - type: string - format: date-time - phase: - $ref: "#/components/schemas/InitialResetPhase" - additionalProperties: false - - type: object - required: - - waitingforscript - properties: - waitingforscript: - type: object - additionalProperties: false - - type: object - required: - - waitingforupload - properties: - waitingforupload: - type: object - required: - - final_version - - firmware_type - properties: - final_version: - type: string - firmware_number: - type: - - integer - - "null" - format: uint32 - minimum: 0.0 - firmware_type: - $ref: "#/components/schemas/FirmwareComponentType" - power_drains_needed: - type: - - integer - - "null" - format: uint32 - minimum: 0.0 - additionalProperties: false - - type: object - required: - - waitingforfirmwareupgrade - properties: - waitingforfirmwareupgrade: - type: object - required: - - final_version - - firmware_type - - task_id - properties: - final_version: - type: string - firmware_number: - type: - - integer - - "null" - format: uint32 - minimum: 0.0 - firmware_type: - $ref: "#/components/schemas/FirmwareComponentType" - power_drains_needed: - type: - - integer - - "null" - format: uint32 - minimum: 0.0 - started_waiting: - type: - - string - - "null" - format: date-time - task_id: - type: string - additionalProperties: false - - type: object - required: - - resetfornewfirmware - properties: - resetfornewfirmware: - type: object - required: - - final_version - - firmware_type - properties: - delay_until: - type: - - integer - - "null" - format: int64 - final_version: - type: string - firmware_type: - $ref: "#/components/schemas/FirmwareComponentType" - last_power_drain_operation: - anyOf: - - $ref: "#/components/schemas/PowerDrainState" - - type: "null" - power_drains_needed: - type: - - integer - - "null" - format: uint32 - minimum: 0.0 - additionalProperties: false - - type: object - required: - - newfirmwarereportedwait - properties: - newfirmwarereportedwait: - type: object - required: - - final_version - - firmware_type - properties: - final_version: - type: string - firmware_type: - $ref: "#/components/schemas/FirmwareComponentType" - previous_reset_time: - type: - - integer - - "null" - format: int64 - additionalProperties: false - - type: object - required: - - failedfirmwareupgrade - properties: - failedfirmwareupgrade: - type: object - required: - - firmware_type - properties: - firmware_type: - $ref: "#/components/schemas/FirmwareComponentType" - reason: - type: - - string - - "null" - report_time: - type: - - string - - "null" - format: date-time - additionalProperties: false - InitialResetPhase: - type: string - enum: - - start - - bmcwasreset - - waithostboot - InstallDpuOsState: - oneOf: - - type: object - required: - - installdpuosstate - properties: - installdpuosstate: - type: string - enum: - - installingbfb - - type: object - required: - - installdpuosstate - - progress - - task_id - properties: - installdpuosstate: - type: string - enum: - - waitforinstallcomplete - progress: - type: string - task_id: - type: string - - type: object - required: - - installdpuosstate - properties: - installdpuosstate: - type: string - enum: - - completed - - type: object - required: - - installdpuosstate - - msg - properties: - installdpuosstate: - type: string - enum: - - installationerror - msg: - type: string - InstanceState: - description: Possible Instance state-machine implementation, for when the machine host is assigned to a tenant - oneOf: - - type: object - required: - - state - properties: - state: - type: string - enum: - - init - - type: object - required: - - state - properties: - state: - type: string - enum: - - waitingfornetworksegmenttobeready - - type: object - required: - - state - properties: - state: - type: string - enum: - - waitingfornetworkconfig - - type: object - required: - - state - properties: - state: - type: string - enum: - - waitingforstorageconfig - - type: object - required: - - state - properties: - state: - type: string - enum: - - dpaprovisioning - - type: object - required: - - state - properties: - state: - type: string - enum: - - waitingfordpatobeready - - type: object - required: - - state - properties: - state: - type: string - enum: - - waitingforextensionservicesconfig - - type: object - required: - - state - properties: - state: - type: string - enum: - - waitingforreboottoready - - type: object - required: - - state - properties: - state: - type: string - enum: - - ready - - type: object - required: - - platform_config_state - - state - properties: - platform_config_state: - $ref: "#/components/schemas/HostPlatformConfigurationState" - state: - type: string - enum: - - hostplatformconfiguration - - type: object - required: - - state - properties: - state: - type: string - enum: - - waitingfordpustoup - - type: object - required: - - state - properties: - retry: - default: - count: 0 - allOf: - - $ref: "#/components/schemas/RetryInfo" - state: - type: string - enum: - - bootingwithdiscoveryimage - - type: object - required: - - state - properties: - state: - type: string - enum: - - switchtoadminnetwork - - type: object - required: - - state - properties: - state: - type: string - enum: - - waitingfornetworkreconfig - - type: object - required: - - dpu_states - - state - properties: - dpu_states: - $ref: "#/components/schemas/DpuReprovisionStates" - state: - type: string - enum: - - dpureprovision - - type: object - required: - - details - - machine_id - - state - properties: - details: - $ref: "#/components/schemas/FailureDetails" - machine_id: - $ref: "#/components/schemas/MachineId" - state: - type: string - enum: - - failed - - type: object - required: - - reprovision_state - - state - properties: - reprovision_state: - $ref: "#/components/schemas/HostReprovisionState" - state: - type: string - enum: - - hostreprovision - - type: object - required: - - network_config_update_state - - state - properties: - network_config_update_state: - $ref: "#/components/schemas/NetworkConfigUpdateState" - state: - type: string - enum: - - networkconfigupdate - LockdownInfo: - type: object - required: - - mode - - state - properties: - mode: - $ref: "#/components/schemas/LockdownMode" - state: - $ref: "#/components/schemas/LockdownState" - LockdownMode: - description: Whether lockdown should be enabled or disabled in an operation - type: string - enum: - - enable - - disable - LockdownState: - type: string - enum: - - setlockdown - - timewaitfordpudown - - waitfordpuup - - pollinglockdownstatus - MachineId: - type: string - MachineState: - oneOf: - - type: object - required: - - state - properties: - state: - type: string - enum: - - init - - type: object - required: - - state - properties: - state: - type: string - enum: - - enableipmioverlan - - type: object - required: - - state - properties: - state: - type: string - enum: - - waitingforplatformconfiguration - - type: object - required: - - state - properties: - state: - type: string - enum: - - pollingbiossetup - - type: object - required: - - state - properties: - set_boot_order_info: - anyOf: - - $ref: "#/components/schemas/SetBootOrderInfo" - - type: "null" - state: - type: string - enum: - - setbootorder - - type: object - required: - - state - - uefi_setup_info - properties: - state: - type: string - enum: - - uefisetup - uefi_setup_info: - $ref: "#/components/schemas/UefiSetupInfo" - - type: object - required: - - measuring_state - - state - properties: - measuring_state: - $ref: "#/components/schemas/MeasuringState" - state: - type: string - enum: - - measuring - - type: object - required: - - state - properties: - state: - type: string - enum: - - waitingfordiscovery - - type: object - required: - - state - properties: - skip_reboot_wait: - default: false - type: boolean - state: - type: string - enum: - - discovered - - description: Lockdown handling. - type: object - required: - - lockdown_info - - state - properties: - lockdown_info: - $ref: "#/components/schemas/LockdownInfo" - state: - type: string - enum: - - waitingforlockdown - MachineValidatingState: - oneOf: - - type: object - required: - - reboothost - properties: - reboothost: - type: object - required: - - validation_id - properties: - validation_id: - type: string - format: uuid - additionalProperties: false - - type: object - required: - - machinevalidating - properties: - machinevalidating: - type: object - required: - - completed - - context - - id - - total - properties: - completed: - type: integer - format: uint - minimum: 0.0 - context: - type: string - id: - type: string - format: uuid - is_enabled: - default: true - type: boolean - total: - type: integer - format: uint - minimum: 0.0 - additionalProperties: false - MeasuringState: - description: MeasuringState contains states used for host attestion (or measured boot). - oneOf: - - description: - WaitingForMeasurements is reported when the machine has reached a state where the API is now expecting measurements - from the machine, which Scout sends upon receiving an Action::Measure from the API. - type: string - enum: - - waitingformeasurements - - description: - PendingBundle is reported when the API has received measurements from the machine, but the measurements do - not match a known bundle. At this point, a matching bundle needs to be created, either via "promoting" a measurement - report from a machine (through manual interaction or trusted approval automation), or by manually creating a new bundle. - type: string - enum: - - pendingbundle - NetworkConfigUpdateState: - description: - Tenant has requested network config update for the existing instance. At this point, instance config, instance - network config version are already increased. - type: string - enum: - - waitingfornetworksegmenttobeready - - waitingforconfigsynced - - releaseoldresources - PerformPowerOperation: - oneOf: - - type: object - required: - - state - properties: - state: - type: string - enum: - - "off" - - type: object - required: - - state - properties: - state: - type: string - enum: - - "on" - PowerDrainState: - type: string - enum: - - "off" - - powercycle - - "on" - ReprovisionState: - oneOf: - - type: string - enum: - - firmwareupgrade - - waitingfornetworkinstall - - poweringoffhost - - powerdown - - buffertime - - verifyfirmareversions - - waitingfornetworkconfig - - reboothostbmc - - reboothost - - notunderreprovision - - type: object - required: - - bmcfirmwareupgrade - properties: - bmcfirmwareupgrade: - type: object - required: - - substate - properties: - substate: - $ref: "#/components/schemas/BmcFirmwareUpgradeSubstate" - additionalProperties: false - - type: object - required: - - installdpuos - properties: - installdpuos: - type: object - required: - - substate - properties: - substate: - $ref: "#/components/schemas/InstallDpuOsState" - additionalProperties: false - RetryInfo: - type: object - required: - - count - properties: - count: - type: integer - format: uint64 - minimum: 0.0 - SecureEraseBossContext: - type: object - required: - - boss_controller_id - - secure_erase_boss_state - properties: - boss_controller_id: - type: string - iteration: - type: - - integer - - "null" - format: uint32 - minimum: 0.0 - secure_erase_boss_state: - $ref: "#/components/schemas/SecureEraseBossState" - secure_erase_jid: - type: - - string - - "null" - SecureEraseBossState: - oneOf: - - type: object - required: - - state - properties: - state: - type: string - enum: - - unlockhost - - type: object - required: - - state - properties: - state: - type: string - enum: - - secureeraseboss - - type: object - required: - - state - properties: - state: - type: string - enum: - - waitforjobcompletion - - type: object - required: - - failure - - power_state - - state - properties: - failure: - type: string - power_state: - type: string - state: - type: string - enum: - - handlejobfailure - SetBootOrderInfo: - type: object - required: - - set_boot_order_state - properties: - set_boot_order_jid: - type: - - string - - "null" - set_boot_order_state: - $ref: "#/components/schemas/SetBootOrderState" - SetBootOrderState: - oneOf: - - type: object - required: - - state - properties: - state: - type: string - enum: - - setbootorder - - type: object - required: - - state - properties: - state: - type: string - enum: - - waitforsetbootorderjobscheduled - - type: object - required: - - state - properties: - state: - type: string - enum: - - reboothost - - type: object - required: - - state - properties: - state: - type: string - enum: - - waitforsetbootorderjobcompletion - SetSecureBootState: - oneOf: - - type: object - required: - - disablesecurebootstate - properties: - disablesecurebootstate: - type: string - enum: - - checksecurebootstatus - - type: object - required: - - disablesecurebootstate - properties: - disablesecurebootstate: - type: string - enum: - - disablesecureboot - - type: object - required: - - disablesecurebootstate - properties: - disablesecurebootstate: - type: string - enum: - - setsecureboot - - type: object - required: - - disablesecurebootstate - - reboot_count - properties: - disablesecurebootstate: - type: string - enum: - - rebootdpu - reboot_count: - type: integer - format: uint32 - minimum: 0.0 - - type: object - required: - - disablesecurebootstate - - task_id - properties: - disablesecurebootstate: - type: string - enum: - - waitcertificateupload - task_id: - type: string - StateMachineArea: - type: string - enum: - - default - - hostinit - - mainflow - - assignedinstance - UefiSetupInfo: - type: object - required: - - uefi_setup_state - properties: - uefi_password_jid: - type: - - string - - "null" - uefi_setup_state: - $ref: "#/components/schemas/UefiSetupState" - UefiSetupState: - description: Substates of enabling/disabling lockdown - oneOf: - - type: object - required: - - state - properties: - state: - type: string - enum: - - unlockhost - - type: object - required: - - state - properties: - state: - type: string - enum: - - setuefipassword - - type: object - required: - - state - properties: - state: - type: string - enum: - - waitforpasswordjobscheduled - - type: object - required: - - state - properties: - state: - type: string - enum: - - powercyclehost - - type: object - required: - - state - properties: - state: - type: string - enum: - - waitforpasswordjobcompletion - - type: object - required: - - state - properties: - state: - type: string - enum: - - lockdownhost - ValidationState: - oneOf: - - description: - "Host machine validation placeholder for DPU machine validation TODO: add DPU validation state SKU validatioon - can also be moved here, so that all validation done @ one place" - type: object - required: - - machine_validation - - validation_type - properties: - machine_validation: - $ref: "#/components/schemas/MachineValidatingState" - validation_type: - type: string - enum: - - machinevalidation diff --git a/mcp/dsx-exchange-mcp/schemas/asyncapi/nke/node-readiness.yaml b/mcp/dsx-exchange-mcp/schemas/asyncapi/nke/node-readiness.yaml deleted file mode 100644 index 4540794..0000000 --- a/mcp/dsx-exchange-mcp/schemas/asyncapi/nke/node-readiness.yaml +++ /dev/null @@ -1,2 +0,0 @@ -# Copyright 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. -# SPDX-License-Identifier: Apache-2.0 diff --git a/mcp/dsx-exchange-mcp/schemas/asyncapi/power-management/power-management.yaml b/mcp/dsx-exchange-mcp/schemas/asyncapi/power-management/power-management.yaml deleted file mode 100644 index 3080e8d..0000000 --- a/mcp/dsx-exchange-mcp/schemas/asyncapi/power-management/power-management.yaml +++ /dev/null @@ -1,825 +0,0 @@ -# Copyright 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. -# SPDX-License-Identifier: Apache-2.0 - -asyncapi: 3.1.0 -info: - title: Power Management - version: 1.7.0 - description: | - Real-time power management and emergency control plane for data center grid events. - - ## Protocol Binding - - This API uses the MQTT [Protocol Binding for CloudEvents v1.0.2](https://github.com/cloudevents/spec/blob/v1.0.2/cloudevents/bindings/mqtt-protocol-binding.md). - - **MQTT 3.1.1 Structured Content Mode:** - - - Content mode is always "structured" (MQTT 3.1.1 lacks custom metadata support) - - The entire CloudEvents message is in the MQTT PUBLISH payload as JSON - - Content-Type `application/cloudevents+json` is implied (no header in MQTT 3.1.1) - - All CloudEvents attributes and data are in the JSON payload - - ## CloudEvents Message Format - - All messages conform to [CloudEvents 1.0.2 specification](https://github.com/cloudevents/spec/blob/v1.0.2/cloudevents/spec.md) with W3C [Distributed Tracing Extension](https://github.com/cloudevents/spec/blob/v1.0.2/cloudevents/extensions/distributed-tracing.md). - - Each MQTT PUBLISH payload contains a JSON object with: - - - **specversion**: "1.0.2" (REQUIRED) - - **id**: UUIDv4 unique event identifier (REQUIRED) - - **source**: URI format //grid/v1// (REQUIRED) - - **type**: Event type e.g., grid.loadtarget.set.v1 (REQUIRED) - - **time**: ISO 8601 timestamp (REQUIRED) - - **datacontenttype**: "application/json" (REQUIRED) - - **subject**: Resource subject e.g., loadtarget (REQUIRED) - - **traceparent**: W3C Trace Context for distributed tracing (REQUIRED) - - **tracestate**: Optional vendor-specific trace context - - **correlationid**: UUIDv4 correlation identifier (OPTIONAL, CloudEvents extension) - - **data**: JWS-signed payload (string - see Data Field Security below) - - ## Source Format - - The source attribute is a stable URI that identifies the publisher of an event. - Format: ///// - - - namespace: grid - - version: v1 - - role: isv | poweragent | infra - - identifier: deployment-unique name assigned during registration - - Examples: - - - ISV: //grid/v1/isv/acme-energy - - Power Agent: //grid/v1/poweragent/dps-prod - - Infra Agent: //grid/v1/infra/ima-prod - - ## Event Types - - - grid.loadtarget.set.v1 — ISV sets load target (fire-and-forget command) - - grid.powerstate.status.v1 — Power Agent publishes power state snapshots - - grid.powerbreach.alert.v1 — Power Agent publishes breach alerts - - grid.powerbreach.enforcement.v1 — Infra Agent publishes enforcement outcomes - - ## Security - - - Transport: MQTT 3.1.1 with TLS (mTLS or OAuth2 SSA) - - Data Field: MUST be signed (JWS RFC 7515) — broker never sees verified plaintext - - ## Data Field Security (JWS) - - The `data` field uses cryptographic protection per RFC 7515 (JWS): - - 1. **Sign (JWS)**: The JSON payload is signed using JWS (JSON Web Signature) - - Algorithm: ES256 (ECDSA using P-256 and SHA-256) or RS256 - - Provides: Data integrity and authenticity - - The `kid` (Key ID) in JWS header identifies the signing key - - 2. **Result**: The `data` field contains a JWS compact serialization string: - `..` - - **Processing Order:** - - - Sender: JSON payload → JWS sign → data field - - Receiver: data field → JWS verify → JSON payload - - ## Distributed Tracing - - All participants MUST propagate traceparent/tracestate for end-to-end visibility. - contact: - name: Grid Platform Team - email: grid-platform@nvidia.com - -defaultContentType: application/cloudevents+json - -servers: - production: - host: broker.example.com - protocol: mqtt - description: MQTT broker for power management grid events - -channels: - loadTargetSetChannel: - address: grid/v1/isv/{identifier}/loadtarget/set - parameters: - identifier: - $ref: '#/components/parameters/identifier' - messages: - loadTargetSet: - $ref: '#/components/messages/LoadTargetSetMessage' - description: | - ISV publishes load target commands. Fire-and-forget pattern — the ISV - does not wait for a response. Confirmation comes via grid.powerstate.status.v1. - bindings: - mqtt: - qos: 1 - - powerStateStatusChannel: - address: grid/v1/poweragent/{identifier}/powerstate/status - parameters: - identifier: - $ref: '#/components/parameters/identifier' - messages: - powerStateStatus: - $ref: '#/components/messages/PowerStateStatusMessage' - description: | - Power Management Agent publishes full snapshots of current power state - for all feeds. Includes compliance, ramp state, and active/scheduled targets. - Published on state transitions and as periodic heartbeats. - bindings: - mqtt: - qos: 1 - - powerBreachAlertChannel: - address: grid/v1/poweragent/{identifier}/powerbreach - parameters: - identifier: - $ref: '#/components/parameters/identifier' - messages: - powerBreachAlert: - $ref: '#/components/messages/PowerBreachAlertMessage' - description: | - Power Management Agent publishes breach alerts when measured power exceeds - the active load target. Breach lifecycle: active → escalated → resolved. - All events in a single breach share the same breach_id. - bindings: - mqtt: - qos: 2 - - powerBreachEnforcementChannel: - address: grid/v1/infra/{infraAgentId}/powerbreach/enforcement - parameters: - infraAgentId: - $ref: '#/components/parameters/infraAgentId' - messages: - powerBreachEnforcement: - $ref: '#/components/messages/PowerBreachEnforcementMessage' - description: | - Infrastructure Management Agent publishes enforcement outcomes after - applying or reverting shed hints at the hardware level via Redfish BMC. - bindings: - mqtt: - qos: 2 - -operations: - publishLoadTargetSet: - action: send - channel: - $ref: '#/channels/loadTargetSetChannel' - summary: ISV → Power Management Agent (set load target command) - messages: - - $ref: '#/channels/loadTargetSetChannel/messages/loadTargetSet' - - subscribeLoadTargetSet: - action: receive - channel: - $ref: '#/channels/loadTargetSetChannel' - summary: Power Management Agent subscribes to load target commands - messages: - - $ref: '#/channels/loadTargetSetChannel/messages/loadTargetSet' - - publishPowerStateStatus: - action: send - channel: - $ref: '#/channels/powerStateStatusChannel' - summary: Power Management Agent → ISVs (power state snapshot) - messages: - - $ref: '#/channels/powerStateStatusChannel/messages/powerStateStatus' - - subscribePowerStateStatus: - action: receive - channel: - $ref: '#/channels/powerStateStatusChannel' - summary: ISVs subscribe to power state status updates - messages: - - $ref: '#/channels/powerStateStatusChannel/messages/powerStateStatus' - - publishPowerBreachAlert: - action: send - channel: - $ref: '#/channels/powerBreachAlertChannel' - summary: Power Management Agent → Infra Agent + ISVs (breach alert) - messages: - - $ref: '#/channels/powerBreachAlertChannel/messages/powerBreachAlert' - - subscribePowerBreachAlert: - action: receive - channel: - $ref: '#/channels/powerBreachAlertChannel' - summary: Infra Agent and ISVs subscribe to breach alerts - messages: - - $ref: '#/channels/powerBreachAlertChannel/messages/powerBreachAlert' - - publishPowerBreachEnforcement: - action: send - channel: - $ref: '#/channels/powerBreachEnforcementChannel' - summary: Infra Agent → Power Management Agent + ISVs (enforcement outcome) - messages: - - $ref: '#/channels/powerBreachEnforcementChannel/messages/powerBreachEnforcement' - - subscribePowerBreachEnforcement: - action: receive - channel: - $ref: '#/channels/powerBreachEnforcementChannel' - summary: Power Management Agent and ISVs subscribe to enforcement outcomes - messages: - - $ref: '#/channels/powerBreachEnforcementChannel/messages/powerBreachEnforcement' - -components: - schemas: - # ========================================================================= - # Reusable Primitives - # ========================================================================= - - PowerUnit: - type: object - description: Power measurement with value and unit. - required: [value, unit] - properties: - value: - type: number - format: double - minimum: 0 - description: Power value (must be >= 0). - unit: - type: string - enum: [watt, kilowatt, megawatt] - description: Unit of measurement. - - Interval: - type: object - description: Time window for load target scheduling. - properties: - start_time: - type: string - format: date-time - description: ISO 8601 UTC start time. Defaults to now if omitted. - end_time: - type: string - format: date-time - description: ISO 8601 UTC end time. If omitted, target remains in effect until replaced. - - Strategy: - type: object - description: Load shedding strategy configuration. - properties: - best_effort: - type: boolean - default: false - description: | - If true, DPS sheds load non-disruptively by adjusting DPM-enabled jobs. - If the constraint cannot be satisfied, DPS sheds as much as possible and - leaves the feed in a non-compliant state until workloads terminate. - - LoadConstraint: - type: object - description: Power limit constraint. - required: [value, unit] - properties: - value: - type: number - format: double - minimum: 0 - description: Target power limit (must be > 0). - unit: - type: string - enum: [watt, kilowatt, megawatt] - description: Unit of measurement. - - FeedTags: - type: array - description: | - List of feed identifiers this target applies to (e.g., ["main-a"]). - If omitted or empty, the target applies to all feeds. - items: - type: string - examples: - - [main-a, main-b] - - # ========================================================================= - # Breach and Enforcement Schemas - # ========================================================================= - - ShedHint: - type: object - description: | - Resource-level load shedding recommendation. Shed hints are non-binding - advisory suggestions. The Infrastructure Management Agent MAY override - or augment based on topology and operational criteria. - required: [resource_id, resource_type, action, priority] - properties: - resource_id: - type: string - description: Identifier of the resource to throttle. - resource_type: - type: string - enum: [node, gpu, chassis] - description: Type of resource. - action: - type: string - enum: [power_cap, throttle, suspend] - description: Recommended action. - priority: - type: integer - minimum: 1 - description: Shedding priority (1 = shed first, higher = shed later). - target_power: - allOf: - - $ref: '#/components/schemas/PowerUnit' - description: Suggested power cap. Present when action is power_cap. - - EnforcementResult: - type: object - description: Per-resource enforcement outcome. - required: [resource_id, resource_type, requested_action, code] - properties: - resource_id: - type: string - description: The resource identifier from the shed hint. - resource_type: - type: string - enum: [node, gpu, chassis] - description: Type of resource. - requested_action: - type: string - enum: [power_cap, throttle, suspend] - description: The action that was requested. - code: - type: string - enum: [applied, failed, skipped] - description: Per-resource enforcement status. - message: - type: string - description: Diagnostic message (e.g., "BMC unreachable after 3 retries"). - applied_power: - allOf: - - $ref: '#/components/schemas/PowerUnit' - description: The actual power cap applied, if different from the requested target_power. - - # ========================================================================= - # Feed State Schemas (for PowerStateStatus) - # ========================================================================= - - FeedMetadata: - type: object - description: Static feed configuration. Values don't change during a curtailment event. - required: [power_minimum, power_maximum, default_constraint] - properties: - power_minimum: - allOf: - - $ref: '#/components/schemas/PowerUnit' - description: Minimum power threshold for this feed. - power_maximum: - allOf: - - $ref: '#/components/schemas/PowerUnit' - description: Maximum power capacity for this feed. - default_constraint: - allOf: - - $ref: '#/components/schemas/PowerUnit' - description: Default power constraint when no load target is active. - - FeedTarget: - type: object - description: A load target for a feed — active or scheduled. - required: [active, load_constraint, interval, strategy, correlation_id] - properties: - active: - type: boolean - description: true if this target is currently being enforced. - load_constraint: - $ref: '#/components/schemas/LoadConstraint' - interval: - $ref: '#/components/schemas/Interval' - strategy: - $ref: '#/components/schemas/Strategy' - correlation_id: - type: string - format: uuid - description: The correlationid from the grid.loadtarget.set.v1 that created this target. - - FeedState: - type: object - description: Current power state for a single feed. - required: [metadata, targets, calculated_load, in_flight, event, compliant] - properties: - metadata: - $ref: '#/components/schemas/FeedMetadata' - targets: - type: array - description: All load targets for this feed — active and scheduled. Empty array if no targets exist. - items: - $ref: '#/components/schemas/FeedTarget' - calculated_load: - allOf: - - $ref: '#/components/schemas/PowerUnit' - description: | - Agent-calculated effective load after shedding strategy is applied. - May differ from actual measured power during ramp transitions. - in_flight: - type: boolean - description: true if the agent is actively transitioning between power levels. - event: - type: string - enum: - - target_set - - start_ramp_down - - end_ramp_down - - start_ramp_up - - end_ramp_up - - periodic - description: | - The trigger that caused this status to be published: - - target_set — a grid.loadtarget.set.v1 was received and schedule updated - - start_ramp_down — constraint applied or tightened, redistribution begins - - end_ramp_down — redistribution complete, load converged to target - - start_ramp_up — constraint relaxed or removed, redistribution begins - - end_ramp_up — redistribution complete - - periodic — regular heartbeat, no state change - power_event_start_time: - type: string - format: date-time - description: ISO 8601 UTC timestamp when the current power event began. null if no event is active. - compliant: - type: boolean - description: true if the calculated load is within the active target constraint. true when no target is active. - - # ========================================================================= - # Breach Detail Schemas - # ========================================================================= - - BreachDetails: - type: object - description: Breach identification and timing. - required: [breach_id, detected_at] - properties: - breach_id: - type: string - format: uuid - description: | - Unique identifier for this breach instance (UUIDv4). Remains the same - across active → escalated → resolved for the same breach. - detected_at: - type: string - format: date-time - description: ISO 8601 UTC timestamp when the breach was first detected. - resolved_at: - type: string - format: date-time - description: ISO 8601 UTC timestamp when the breach was resolved. Present only when status is resolved. - - BreachTarget: - type: object - description: The load target being breached. - required: [correlation_id, load_constraint] - properties: - correlation_id: - type: string - format: uuid - description: The correlationid from the grid.loadtarget.set.v1 that established this target. - load_constraint: - $ref: '#/components/schemas/LoadConstraint' - - # ========================================================================= - # Event Data Payloads - # ========================================================================= - - SetLoadTargetData: - type: object - description: Payload for grid.loadtarget.set.v1. - required: [targets] - properties: - targets: - type: array - description: List of load target requests. - minItems: 1 - items: - type: object - required: [interval, load_constraint] - properties: - interval: - $ref: '#/components/schemas/Interval' - feed_tags: - $ref: '#/components/schemas/FeedTags' - load_constraint: - description: | - Power constraint to apply. If null, removes any active constraint - and reverts the feed to its unconstrained default load level. - oneOf: - - $ref: '#/components/schemas/LoadConstraint' - - type: 'null' - strategy: - $ref: '#/components/schemas/Strategy' - - PowerStateStatusData: - type: object - description: Payload for grid.powerstate.status.v1. - required: [snapshot_time, feeds] - properties: - snapshot_time: - type: string - format: date-time - description: ISO 8601 UTC timestamp of this snapshot. - feeds: - type: object - description: Map of feed_tag to power state. - additionalProperties: - $ref: '#/components/schemas/FeedState' - - PowerBreachAlertData: - type: object - description: Payload for grid.powerbreach.alert.v1. - required: [feed_tag, status, severity, breach, target, measured_load, infrastructure_actions] - properties: - feed_tag: - type: string - description: The feed identifier where the breach was detected. - status: - type: string - enum: [active, escalated, resolved] - description: Breach lifecycle state. - severity: - type: string - enum: [warning, critical] - description: | - Breach severity level: - - warning — excess between threshold and 20% over target - - critical — excess exceeds 20% over target - breach: - $ref: '#/components/schemas/BreachDetails' - target: - $ref: '#/components/schemas/BreachTarget' - measured_load: - allOf: - - $ref: '#/components/schemas/PowerUnit' - description: Actual measured power at time of event. - shed_hints: - type: array - description: | - Ordered list of load shedding recommendations. Present when status - is active or escalated. Absent when resolved. - items: - $ref: '#/components/schemas/ShedHint' - infrastructure_actions: - type: array - description: | - Ordered list of escalating infrastructure-level actions. Empty array - when status is resolved. Ordered from least to most disruptive. - items: - type: string - enum: [SHUTDOWN_NODES, SHUTDOWN_RACKS, CUT_DATA_FEEDS, TRIP_BREAKERS] - - PowerBreachEnforcementData: - type: object - description: Payload for grid.powerbreach.enforcement.v1. - required: [breach_id, feed_tag, action, code, results, timestamp] - properties: - breach_id: - type: string - format: uuid - description: The breach_id from the originating grid.powerbreach.alert.v1 event. - feed_tag: - type: string - description: The feed identifier this enforcement applies to. - action: - type: string - enum: [applied, reverted] - description: What phase of enforcement this represents. - code: - type: string - enum: [success, partial, error] - description: Overall enforcement outcome. - diag_msg: - type: string - description: Human-readable diagnostic message. - results: - type: array - description: Per-resource enforcement outcome. - items: - $ref: '#/components/schemas/EnforcementResult' - timestamp: - type: string - format: date-time - description: ISO 8601 UTC timestamp when enforcement completed. - - # ========================================================================= - # JWS Data Field Security - # ========================================================================= - - JwsCompactSerialization: - type: string - description: | - JWS Compact Serialization (RFC 7515) containing a signed payload. - This is the WIRE FORMAT for the "data" field in CloudEvents messages. - - Structure: BASE64URL(JWS Header).BASE64URL(Payload).BASE64URL(Signature) - - JWS Header (example): - { - "alg": "ES256", - "kid": "" - } - - The payload, when verified, contains the JSON event data - as defined by the message type schema. - pattern: ^[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+$ - - # ========================================================================= - # Base CloudEvents Schema - # ========================================================================= - - BaseCloudEvent: - type: object - description: | - Common CloudEvents 1.0.2 attributes shared by all events. - Each event type extends this with a specific "data" field. - - The "data" field is transmitted as a JWS compact serialization string. - The plaintext JSON payload is signed with JWS (RFC 7515). - required: - - specversion - - id - - source - - type - - time - - datacontenttype - - subject - - traceparent - properties: - specversion: - type: string - const: '1.0.2' - description: MUST be "1.0.2" — version of the CloudEvents specification. - id: - type: string - format: uuid - description: MUST be a UUIDv4 — globally unique identifier for this event instance. - source: - type: string - pattern: ^//grid/v1/(isv|poweragent|infra)/[^/]+$ - description: | - Stable URI identifying the publisher. Format: //grid/v1//. - Examples: - - //grid/v1/isv/acme-energy - - //grid/v1/poweragent/dps-prod - - //grid/v1/infra/ima-prod - type: - type: string - description: Event type following grid...v1 or grid..status.v1. - time: - type: string - format: date-time - description: MUST be an ISO 8601 timestamp — time the event was created. - datacontenttype: - type: string - const: application/json - description: MUST be "application/json" — media type of the data payload. - subject: - type: string - description: Resource subject identifying the domain object (e.g., loadtarget, powerstate, powerbreach). - correlationid: - type: string - format: uuid - description: | - CloudEvents extension attribute. UUIDv4 correlation identifier. - Generated by the ISV on grid.loadtarget.set.v1. Echoed by the - Power Management Agent in status events for correlation. - traceparent: - type: string - pattern: ^[0-9a-f]{2}-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$ - description: | - REQUIRED per W3C Trace Context and CloudEvents Distributed Tracing Extension. - Format: version-traceid-parentid-flags. - tracestate: - type: string - description: | - OPTIONAL per W3C Trace Context. Carries vendor-specific contextual - information across tracing systems. - - # ========================================================================= - # Event-Specific CloudEvents Envelope Schemas - # ========================================================================= - - LoadTargetSetEvent: - description: CloudEvents envelope for grid.loadtarget.set.v1. - allOf: - - $ref: '#/components/schemas/BaseCloudEvent' - - type: object - required: [data] - properties: - type: - const: grid.loadtarget.set.v1 - data: - description: | - WIRE FORMAT: JWS compact serialization string (type: string). - PLAINTEXT CONTENT: After JWS verification, the payload conforms - to SetLoadTargetData schema (type: object). - oneOf: - - $ref: '#/components/schemas/JwsCompactSerialization' - - $ref: '#/components/schemas/SetLoadTargetData' - - PowerStateStatusEvent: - description: CloudEvents envelope for grid.powerstate.status.v1. - allOf: - - $ref: '#/components/schemas/BaseCloudEvent' - - type: object - required: [data] - properties: - type: - const: grid.powerstate.status.v1 - data: - description: | - WIRE FORMAT: JWS compact serialization string (type: string). - PLAINTEXT CONTENT: After JWS verification, the payload conforms - to PowerStateStatusData schema (type: object). - oneOf: - - $ref: '#/components/schemas/JwsCompactSerialization' - - $ref: '#/components/schemas/PowerStateStatusData' - - PowerBreachAlertEvent: - description: CloudEvents envelope for grid.powerbreach.alert.v1. - allOf: - - $ref: '#/components/schemas/BaseCloudEvent' - - type: object - required: [data] - properties: - type: - const: grid.powerbreach.alert.v1 - data: - description: | - WIRE FORMAT: JWS compact serialization string (type: string). - PLAINTEXT CONTENT: After JWS verification, the payload conforms - to PowerBreachAlertData schema (type: object). - oneOf: - - $ref: '#/components/schemas/JwsCompactSerialization' - - $ref: '#/components/schemas/PowerBreachAlertData' - - PowerBreachEnforcementEvent: - description: CloudEvents envelope for grid.powerbreach.enforcement.v1. - allOf: - - $ref: '#/components/schemas/BaseCloudEvent' - - type: object - required: [data] - properties: - type: - const: grid.powerbreach.enforcement.v1 - data: - description: | - WIRE FORMAT: JWS compact serialization string (type: string). - PLAINTEXT CONTENT: After JWS verification, the payload conforms - to PowerBreachEnforcementData schema (type: object). - oneOf: - - $ref: '#/components/schemas/JwsCompactSerialization' - - $ref: '#/components/schemas/PowerBreachEnforcementData' - - messages: - LoadTargetSetMessage: - name: LoadTargetSetMessage - title: Set Load Target - summary: ISV publishes load target command to the event bus. - contentType: application/cloudevents+json - payload: - $ref: '#/components/schemas/LoadTargetSetEvent' - - PowerStateStatusMessage: - name: PowerStateStatusMessage - title: Power State Status - summary: Power Management Agent publishes full power state snapshot. - contentType: application/cloudevents+json - payload: - $ref: '#/components/schemas/PowerStateStatusEvent' - - PowerBreachAlertMessage: - name: PowerBreachAlertMessage - title: Power Breach Alert - summary: Power Management Agent publishes breach alert with shed hints. - contentType: application/cloudevents+json - payload: - $ref: '#/components/schemas/PowerBreachAlertEvent' - - PowerBreachEnforcementMessage: - name: PowerBreachEnforcementMessage - title: Power Breach Enforcement - summary: Infrastructure Management Agent publishes enforcement outcome. - contentType: application/cloudevents+json - payload: - $ref: '#/components/schemas/PowerBreachEnforcementEvent' - - parameters: - identifier: - description: Unique identifier of the ISV, power agent, or infrastructure agent. - infraAgentId: - description: Unique identifier of the Infrastructure Management Agent. - - securitySchemes: - oauth2Ssa: - type: oauth2 - description: OAuth2 SSA token in MQTT password field (username "oauthtoken"). - flows: - clientCredentials: - tokenUrl: https://auth.example.com/token - availableScopes: - mqtt: Access to MQTT topics - - mTLS: - type: X509 - description: Mutual TLS with client certificate signed by cluster CA. diff --git a/mcp/dsx-exchange-mcp/schemas/asyncapi/spiffe-exchange/pub-keysets.yaml b/mcp/dsx-exchange-mcp/schemas/asyncapi/spiffe-exchange/pub-keysets.yaml deleted file mode 100644 index 8803dcc..0000000 --- a/mcp/dsx-exchange-mcp/schemas/asyncapi/spiffe-exchange/pub-keysets.yaml +++ /dev/null @@ -1,117 +0,0 @@ -# Copyright 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. -# SPDX-License-Identifier: Apache-2.0 - -asyncapi: 3.1.0 -info: - title: SPIRE SPIFFE Exchange - Public Keysets - version: 1.0.0 - description: | - AsyncAPI specification for publishing JWK (JSON Web Key) public keys on the - SPIFFE/SPIRE exchange topic. One JWK per message. Used to distribute public - keys for a given tenant and key identifier so consumers can verify JWS or - use keys for encryption. - - **Topic format:** `spiffe-exchange/v1/pub-keysets/tenant/{tenant_domain}/kid/{kid}` - - Payloads conform to RFC 7517 (JSON Web Key). Only public key material is - published on this channel. - -servers: - production: - host: broker.example.com - protocol: mqtt - description: MQTT broker for SPIFFE exchange public key distribution - -channels: - pubKeysets: - address: "spiffe-exchange/v1/pub-keysets/tenant/{tenant_domain}/kid/{kid}" - parameters: - tenant_domain: - description: Tenant domain identifier (e.g. tenant namespace or domain name). - kid: - description: Key ID (kid) for this key; aligns with JWS/JWE header kid. - messages: - jwk: - $ref: "#/components/messages/JwkMessage" - -operations: - publishPubKeyset: - action: send - channel: - $ref: "#/channels/pubKeysets" - messages: - - $ref: "#/channels/pubKeysets/messages/jwk" - description: > - Publish one JWK for the given tenant and kid. Publishers (e.g. SPIRE) - use this to advertise a public key for verification or encryption. - - subscribePubKeyset: - action: receive - channel: - $ref: "#/channels/pubKeysets" - messages: - - $ref: "#/channels/pubKeysets/messages/jwk" - description: > - Subscribe to public key updates for a tenant and kid. Each message - carries one JWK. Consumers use the key to verify signatures or encrypt. - -components: - messages: - JwkMessage: - name: JwkMessage - title: JWK (RFC 7517) - contentType: application/json - payload: - $ref: "#/components/schemas/Jwk" - - schemas: - Jwk: - type: object - required: - - kty - description: > - Single JSON Web Key per RFC 7517. Only public key parameters are included - on this channel. Key type (kty) determines which additional members are present. - properties: - kty: - type: string - description: Key type (e.g. RSA, EC, OKP). - enum: - - RSA - - EC - - OKP - use: - type: string - description: Public key use (sig, enc, or omitted). - enum: - - sig - - enc - key_ops: - type: array - items: - type: string - description: Key operations (e.g. verify, encrypt). - alg: - type: string - description: Algorithm (e.g. ES256, RS256, EdDSA). - kid: - type: string - description: Key ID; should match the topic kid when present. - # RSA public key parameters (when kty is RSA) - n: - type: string - description: RSA modulus (Base64url). - e: - type: string - description: RSA public exponent (Base64url). - # EC public key parameters (when kty is EC) - crv: - type: string - description: Elliptic curve (e.g. P-256, P-384). - x: - type: string - description: EC x coordinate (Base64url). - y: - type: string - description: EC y coordinate (Base64url). - # OKP (e.g. Ed25519): public key is in 'x'. Private key (d) MUST NOT be published here. diff --git a/mcp/dsx-exchange-mcp/schemas/asyncapi/tenant/scheduler-events.yaml b/mcp/dsx-exchange-mcp/schemas/asyncapi/tenant/scheduler-events.yaml deleted file mode 100644 index 4540794..0000000 --- a/mcp/dsx-exchange-mcp/schemas/asyncapi/tenant/scheduler-events.yaml +++ /dev/null @@ -1,2 +0,0 @@ -# Copyright 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. -# SPDX-License-Identifier: Apache-2.0 diff --git a/mcp/dsx-exchange-mcp/schemas/cloud-events-example.yaml b/mcp/dsx-exchange-mcp/schemas/cloud-events-example.yaml deleted file mode 100644 index 1d1b337..0000000 --- a/mcp/dsx-exchange-mcp/schemas/cloud-events-example.yaml +++ /dev/null @@ -1,107 +0,0 @@ -# Copyright 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. -# SPDX-License-Identifier: Apache-2.0 - -asyncapi: 3.1.0 -info: - title: Example CloudEvent AsyncAPI - version: 1.0.0 - description: > - AsyncAPI 3.0 document describing publication of a single CloudEvent - (`com.example.someevent`) on the `mytopic` channel. -defaultContentType: application/cloudevents+json -servers: - mqttBroker: - host: broker.example.com:1883 - protocol: mqtt - protocolVersion: 3.1.1 - description: Example MQTT broker transporting CloudEvents. -channels: - mytopic: - address: mytopic - description: CloudEvents describing `com.example.someevent`. - messages: - cloudEventMessage: - $ref: '#/components/messages/cloudEventMessage' -operations: - publishSomeEvent: - action: send - channel: - $ref: '#/channels/mytopic' - summary: Publish CloudEvents of type `com.example.someevent`. - messages: - - $ref: '#/channels/mytopic/messages/cloudEventMessage' -components: - schemas: - BaseCloudEvent: - type: object - description: Common CloudEvent attributes shared by all events. - required: - - specversion - - type - - source - - id - - time - - datacontenttype - properties: - specversion: - type: string - enum: ['1.0'] - description: Version of the CloudEvents specification. - type: - type: string - description: Application-defined event type. - source: - type: string - description: Identifies the context in which an event happened. - subject: - type: string - description: Optional subject within the source. - id: - type: string - description: Unique identifier for the event. - time: - type: string - format: date-time - description: Timestamp for when the event occurred. - datacontenttype: - type: string - description: Content type of the `data` attribute. - dataschema: - type: string - format: uri - description: URI identifying the schema of the `data` attribute. - ExampleEvent: - allOf: - - $ref: '#/components/schemas/BaseCloudEvent' - - type: object - required: - - data - properties: - data: - $ref: '#/components/schemas/ExampleEventData' - ExampleEventData: - type: object - description: Application-specific payload for `com.example.someevent`. - additionalProperties: true - properties: - exampleField: - type: string - description: Placeholder attribute for domain-specific content. - messages: - cloudEventMessage: - name: CloudEventMessage - title: CloudEvent Wrapper - summary: CloudEvent published to `mytopic`. - contentType: application/cloudevents+json; charset=utf-8 - payload: - $ref: '#/components/schemas/ExampleEvent' - examples: - - payload: - specversion: '1.0' - type: com.example.someevent - time: '2018-04-05T03:56:24Z' - id: '1234-1234-1234' - source: /mycontext/subcontext - datacontenttype: application/json; charset=utf-8 - data: - exampleField: sample value diff --git a/mcp/dsx-exchange-mcp/skaffold.yaml b/mcp/dsx-exchange-mcp/skaffold.yaml index 68bfe8f..65feb8b 100644 --- a/mcp/dsx-exchange-mcp/skaffold.yaml +++ b/mcp/dsx-exchange-mcp/skaffold.yaml @@ -14,9 +14,9 @@ build: useBuildkit: true artifacts: - image: localhost:5001/dsx-exchange-mcp - context: . + context: ../.. docker: - dockerfile: Dockerfile + dockerfile: mcp/dsx-exchange-mcp/Dockerfile cliFlags: - --provenance=false deploy: From dcc377ffd5481ae1e2ee453c862d16fbfbcb0b2d Mon Sep 17 00:00:00 2001 From: Daniyal Rana Date: Thu, 25 Jun 2026 16:06:42 -0500 Subject: [PATCH 24/27] docs(mcp): clarify preview validation status Signed-off-by: Daniyal Rana --- local/README.md | 2 +- mcp/dsx-exchange-mcp/README.md | 9 +++++++++ 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/local/README.md b/local/README.md index d8dba63..e808dde 100644 --- a/local/README.md +++ b/local/README.md @@ -170,7 +170,7 @@ CSC broker at `tcp://172.18.200.1:1883` unless `CSC_BROKER_URL` is overridden. ### DSX Exchange MCP The local stack also deploys `dsx-exchange-mcp` into the CSC Kind cluster. This -is a direct backend deployment, not an MCP gateway deployment. It is intended +is a direct backend deployment; it does not install an MCP gateway. It is intended for manual MCP client checks against the same local Event Bus services used by the e2e tests. diff --git a/mcp/dsx-exchange-mcp/README.md b/mcp/dsx-exchange-mcp/README.md index 63c2b39..e29dd3a 100644 --- a/mcp/dsx-exchange-mcp/README.md +++ b/mcp/dsx-exchange-mcp/README.md @@ -4,6 +4,15 @@ MCP server for DSX Exchange schemas, topic discovery, and read-only MQTT access to the DSX Event Bus. It runs standalone over Streamable HTTP and serves one MCP endpoint for all synced DSX Exchange domains. +## Status + +> **Developer preview / experimental.** This MCP server is an early-access +> component of the [DSX Exchange developer preview](https://docs.nvidia.com/dsx-exchange). +> +> CI covers unit tests, lint, schema sync, Docker build, and local Kind +> deployment. Deployed-broker and LLM prompt-eval checks remain opt-in; see +> [Validation](#validation). + ## What It Exposes | Surface | Name | Purpose | From 9d91291e39407e220c0a6883b1f6d3c104958d01 Mon Sep 17 00:00:00 2001 From: Daniyal Rana Date: Thu, 25 Jun 2026 18:29:02 -0500 Subject: [PATCH 25/27] ci(mcp): align local lint with CI Signed-off-by: Daniyal Rana --- mcp/dsx-exchange-mcp/Makefile | 3 ++- mcp/dsx-exchange-mcp/internal/server/e2e_test.go | 4 +++- mcp/dsx-exchange-mcp/internal/server/llm_eval_test.go | 6 ++++-- mcp/dsx-exchange-mcp/internal/server/transport_test.go | 8 ++++++-- 4 files changed, 15 insertions(+), 6 deletions(-) diff --git a/mcp/dsx-exchange-mcp/Makefile b/mcp/dsx-exchange-mcp/Makefile index eb56f7b..cfe4a9f 100644 --- a/mcp/dsx-exchange-mcp/Makefile +++ b/mcp/dsx-exchange-mcp/Makefile @@ -21,7 +21,7 @@ help: @printf ' build sync specs and build bin/%s\n' "$(BINARY)" @printf ' run sync specs, build, and run the MCP server\n' @printf ' test sync specs and run Go tests with $(GOFLAGS)\n' - @printf ' lint sync specs and run go vet\n' + @printf ' lint sync specs and run Go linters\n' @printf ' sync-specs copy schemas from $(SCHEMA_SRC) into ./schemas\n' @printf ' verify-specs sync specs and verify schema inputs exist\n' @printf ' image build $(IMAGE_REPOSITORY):$(IMAGE_TAG)\n' @@ -53,6 +53,7 @@ vendor: tidy lint: sync-specs go vet $(GOFLAGS) $$(go list $(GOFLAGS) ./...) + @command -v golangci-lint >/dev/null 2>&1 && golangci-lint run || echo "golangci-lint not installed, skipping..." sync-specs: @test -d $(SCHEMA_SRC) || (echo "schema tree not at $(SCHEMA_SRC); set SCHEMA_SRC"; exit 1) diff --git a/mcp/dsx-exchange-mcp/internal/server/e2e_test.go b/mcp/dsx-exchange-mcp/internal/server/e2e_test.go index 10138a1..c2d7a2d 100644 --- a/mcp/dsx-exchange-mcp/internal/server/e2e_test.go +++ b/mcp/dsx-exchange-mcp/internal/server/e2e_test.go @@ -392,7 +392,9 @@ func (c *mcpHTTPClient) post(ctx context.Context, sessionID string, payload map[ if err != nil { return rpcResponse{}, "", err } - defer res.Body.Close() + defer func() { + _ = res.Body.Close() + }() raw, err := io.ReadAll(res.Body) if err != nil { diff --git a/mcp/dsx-exchange-mcp/internal/server/llm_eval_test.go b/mcp/dsx-exchange-mcp/internal/server/llm_eval_test.go index 61d8ff0..c26dc23 100644 --- a/mcp/dsx-exchange-mcp/internal/server/llm_eval_test.go +++ b/mcp/dsx-exchange-mcp/internal/server/llm_eval_test.go @@ -313,7 +313,9 @@ func (c localLLMClient) complete(ctx context.Context, req chatCompletionRequest) if err != nil { return chatCompletionResponse{}, fmt.Errorf("call local LLM API at %s: %w", c.baseURL, err) } - defer resp.Body.Close() + defer func() { + _ = resp.Body.Close() + }() raw, err := io.ReadAll(resp.Body) if err != nil { @@ -336,7 +338,7 @@ func llmToolDefinitions(tools []mcpToolDefinition, allowLiveTools bool) []chatTo out := make([]chatTool, 0, len(tools)) for _, tool := range tools { normalized := normalizeToolName(tool.Name) - if normalized != toolDescribeTopic && !(allowLiveTools && (normalized == toolReadRetained || normalized == toolSubscribe)) { + if normalized != toolDescribeTopic && (!allowLiveTools || (normalized != toolReadRetained && normalized != toolSubscribe)) { continue } parameters := tool.InputSchema diff --git a/mcp/dsx-exchange-mcp/internal/server/transport_test.go b/mcp/dsx-exchange-mcp/internal/server/transport_test.go index b240591..ba478ed 100644 --- a/mcp/dsx-exchange-mcp/internal/server/transport_test.go +++ b/mcp/dsx-exchange-mcp/internal/server/transport_test.go @@ -19,7 +19,9 @@ func TestNewHandlerToolsListJSONResponse(t *testing.T) { defer httpServer.Close() resp := postJSONRPC(t, httpServer.URL, jsonRPCRequest(1, "tools/list", map[string]any{})) - defer resp.Body.Close() + defer func() { + _ = resp.Body.Close() + }() if resp.StatusCode != http.StatusOK { t.Fatalf("tools/list status = %d, want 200", resp.StatusCode) @@ -67,7 +69,9 @@ func TestNewHandlerRejectsLongPollGET(t *testing.T) { if err != nil { t.Fatalf("long-poll GET failed: %v", err) } - defer resp.Body.Close() + defer func() { + _ = resp.Body.Close() + }() if resp.StatusCode < http.StatusBadRequest { t.Fatalf("long-poll GET status = %d, want non-success", resp.StatusCode) From 0867548fbfd032e6a9f87a29fd27574eb795d102 Mon Sep 17 00:00:00 2001 From: Daniyal Rana Date: Thu, 25 Jun 2026 18:53:52 -0500 Subject: [PATCH 26/27] ci(mcp): use dockerhub mirror for skaffold build Signed-off-by: Daniyal Rana --- mcp/dsx-exchange-mcp/skaffold.yaml | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/mcp/dsx-exchange-mcp/skaffold.yaml b/mcp/dsx-exchange-mcp/skaffold.yaml index 65feb8b..0dcdb53 100644 --- a/mcp/dsx-exchange-mcp/skaffold.yaml +++ b/mcp/dsx-exchange-mcp/skaffold.yaml @@ -19,6 +19,15 @@ build: dockerfile: mcp/dsx-exchange-mcp/Dockerfile cliFlags: - --provenance=false +profiles: + - name: ci-dockerhub-mirror + activation: + - env: SKAFFOLD_DOCKERHUB_MIRROR_REGISTRY=.+ + patches: + - op: add + path: /build/artifacts/0/docker/buildArgs + value: + BUILDER_IMG: "{{.SKAFFOLD_DOCKERHUB_MIRROR_REGISTRY}}/library/golang" deploy: kubeContext: kind-csc statusCheck: true From 0042317b7203935d85da78169a82b6cd037a4e9f Mon Sep 17 00:00:00 2001 From: Daniyal Rana Date: Thu, 25 Jun 2026 19:36:29 -0500 Subject: [PATCH 27/27] docs(mcp): remove skill markdown license block Signed-off-by: Daniyal Rana --- mcp/dsx-exchange-mcp/skills/dsx-exchange-mcp/SKILL.md | 5 ----- 1 file changed, 5 deletions(-) diff --git a/mcp/dsx-exchange-mcp/skills/dsx-exchange-mcp/SKILL.md b/mcp/dsx-exchange-mcp/skills/dsx-exchange-mcp/SKILL.md index 702253e..b2431ed 100644 --- a/mcp/dsx-exchange-mcp/skills/dsx-exchange-mcp/SKILL.md +++ b/mcp/dsx-exchange-mcp/skills/dsx-exchange-mcp/SKILL.md @@ -9,11 +9,6 @@ description: >- active chat stays responsive. --- - - # DSX Exchange MCP ## Core Workflow