This is an automated email from the ASF dual-hosted git repository.

spetz pushed a commit to branch sdk_raw_command
in repository https://gitbox.apache.org/repos/asf/iggy.git

commit 9eb2c524d9cd414dfd66412c252f16e61e1ed74d
Author: spetz <[email protected]>
AuthorDate: Fri Jul 17 15:19:50 2026 +0200

    feat(sdk): expose raw command API across foreign clients
---
 .../step_definitions/raw_command_steps.cpp         |  65 +++
 bdd/cpp/features/step_definitions/world.hpp        |   2 +
 bdd/cpp/scripts/entrypoint.sh                      |   8 +-
 bdd/docker-compose.yml                             |  56 ++-
 bdd/go/tests/raw_command.go                        | 126 ++++++
 bdd/go/tests/suite_test.go                         |  10 +
 .../org/apache/iggy/bdd/BasicMessagingSteps.java   |  36 ++
 .../test/java/org/apache/iggy/bdd/TestContext.java |   2 +
 bdd/node/Dockerfile                                |   1 +
 bdd/php/tests/BasicMessagingFeatureTest.php        |  88 +---
 bdd/php/tests/RawCommandFeatureTest.php            | 102 +++++
 bdd/php/tests/SharedFeatureParser.php              |  94 +++++
 bdd/python/tests/conftest.py                       |   2 +
 bdd/python/tests/test_raw_command.py               |  82 ++++
 bdd/rust/Cargo.toml                                |   5 +
 bdd/rust/tests/common/global_context.rs            |   4 +-
 .../version.go => bdd/rust/tests/raw_command.rs    |  12 +-
 bdd/rust/tests/steps/mod.rs                        |   1 +
 bdd/rust/tests/steps/raw_command.rs                |  60 +++
 bdd/scenarios/raw_command.feature                  |  41 ++
 foreign/cpp/Cargo.toml                             |   2 +-
 foreign/cpp/MODULE.bazel                           |   2 +-
 foreign/cpp/src/client.rs                          |  14 +
 foreign/cpp/src/lib.rs                             |   1 +
 foreign/cpp/tests/client/low_level_e2e.cpp         |  42 ++
 .../Iggy_SDK.Tests.BDD/Context/TestContext.cs      |   2 +
 .../csharp/Iggy_SDK.Tests.BDD/Context/TestHooks.cs |   2 +
 .../Iggy_SDK.Tests.BDD/Iggy_SDK.Tests.BDD.csproj   |   3 +
 .../StepDefinitions/RawCommandSteps.cs             |  71 ++++
 .../Iggy_SDK.Tests.Integration/RawCommandTests.cs  |  83 ++++
 foreign/csharp/Iggy_SDK/IggyClient/IIggyClient.cs  |  13 +
 .../Implementations/HttpMessageStream.cs           |  13 +
 .../IggyClient/Implementations/TcpMessageStream.cs |  28 ++
 foreign/csharp/Iggy_SDK/Iggy_SDK.csproj            |   2 +-
 foreign/csharp/Iggy_SDK/Utils/CommandCodes.cs      |   2 +
 foreign/go/client/tcp/tcp_core.go                  |  32 ++
 foreign/go/client/tcp/tcp_core_test.go             | 104 +++++
 foreign/go/contracts/client.go                     |   4 +
 foreign/go/contracts/version.go                    |   2 +-
 foreign/go/internal/command/code.go                |   2 +
 foreign/java/gradle.properties                     |   2 +-
 .../iggy/client/async/tcp/AsyncIggyTcpClient.java  |  45 ++
 .../iggy/client/blocking/IggyBaseClient.java       |  12 +
 .../iggy/client/blocking/http/IggyHttpClient.java  |   7 +
 .../iggy/client/blocking/tcp/IggyTcpClient.java    |   6 +
 .../java/org/apache/iggy/serde/CommandCode.java    |   6 +-
 .../blocking/http/RawCommandHttpClientTest.java}   |  27 +-
 .../blocking/tcp/RawCommandTcpClientTest.java      |  67 +++
 foreign/node/package-lock.json                     |   4 +-
 foreign/node/package.json                          |   2 +-
 foreign/node/src/bdd/raw.ts                        |  48 +++
 foreign/node/src/bdd/world.ts                      |   4 +-
 foreign/node/src/e2e/tcp.raw.e2e.ts                |  53 +++
 foreign/node/src/wire/command-set.test.ts          |  98 +++++
 foreign/node/src/wire/command-set.ts               |  30 +-
 foreign/node/src/wire/command.code.ts              |   2 +
 foreign/php/Cargo.toml                             |   2 +-
 foreign/php/README.md                              |   2 +
 foreign/php/iggy-php.stubs.php                     |  10 +
 foreign/php/src/client.rs                          |  17 +-
 foreign/php/tests/RawCommandTest.php               |  77 ++++
 foreign/python/Cargo.toml                          |   2 +-
 foreign/python/apache_iggy.pyi                     |  19 +
 foreign/python/pyproject.toml                      |   2 +-
 foreign/python/src/client.rs                       |  34 +-
 foreign/python/tests/test_raw_command.py           |  47 +++
 foreign/python/uv.lock                             |   2 +-
 raw-command-iggy-sdk.md                            | 461 +++++++++++++++++++++
 scripts/run-bdd-tests.sh                           |   6 +-
 69 files changed, 2186 insertions(+), 129 deletions(-)

diff --git a/bdd/cpp/features/step_definitions/raw_command_steps.cpp 
b/bdd/cpp/features/step_definitions/raw_command_steps.cpp
new file mode 100644
index 000000000..d960d0f11
--- /dev/null
+++ b/bdd/cpp/features/step_definitions/raw_command_steps.cpp
@@ -0,0 +1,65 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you 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.
+ */
+
+#define CUKE_OBJECT_PREFIX IggyBddRawCommand
+
+#include <gtest/gtest.h>
+
+#include <cucumber-cpp/autodetect.hpp>
+
+#include <cstdint>
+#include <exception>
+#include <string>
+
+#include "world.hpp"
+
+WHEN("^I send a raw command with code ([0-9]+) and an empty payload$") {
+    REGEX_PARAM(std::uint32_t, code);
+    cucumber::ScenarioScope<bdd::GlobalContext> context;
+
+    context->raw_response.clear();
+    context->raw_error.clear();
+    try {
+        const auto response = context->client->send_raw_with_response(code, 
rust::Vec<std::uint8_t>());
+        context->raw_response.assign(response.begin(), response.end());
+    } catch (const std::exception &error) {
+        context->raw_error = error.what();
+    }
+}
+
+THEN("^the raw command should succeed with an empty response$") {
+    cucumber::ScenarioScope<bdd::GlobalContext> context;
+
+    EXPECT_TRUE(context->raw_error.empty());
+    EXPECT_TRUE(context->raw_response.empty());
+}
+
+THEN("^the raw command should succeed with a non-empty response$") {
+    cucumber::ScenarioScope<bdd::GlobalContext> context;
+
+    EXPECT_TRUE(context->raw_error.empty());
+    EXPECT_FALSE(context->raw_response.empty());
+}
+
+THEN("^the raw command should fail with an invalid command error$") {
+    cucumber::ScenarioScope<bdd::GlobalContext> context;
+
+    EXPECT_TRUE(context->raw_response.empty());
+    EXPECT_NE(context->raw_error.find("Invalid command"), std::string::npos);
+}
diff --git a/bdd/cpp/features/step_definitions/world.hpp 
b/bdd/cpp/features/step_definitions/world.hpp
index 470c0661e..7b8fdc487 100644
--- a/bdd/cpp/features/step_definitions/world.hpp
+++ b/bdd/cpp/features/step_definitions/world.hpp
@@ -45,6 +45,8 @@ struct GlobalContext {
     std::uint64_t last_sent_id_lo = 0;
     std::string last_sent_payload;
     PolledData polled;
+    std::vector<std::uint8_t> raw_response;
+    std::string raw_error;
 
     GlobalContext()                                 = default;
     GlobalContext(const GlobalContext &)            = delete;
diff --git a/bdd/cpp/scripts/entrypoint.sh b/bdd/cpp/scripts/entrypoint.sh
index b41b1b592..b9cf1b794 100755
--- a/bdd/cpp/scripts/entrypoint.sh
+++ b/bdd/cpp/scripts/entrypoint.sh
@@ -54,10 +54,16 @@ fi
 
 cd /workspace/bdd/cpp || exit 1
 
+tags=()
+case "${BDD_FEATURE:-all}" in
+    basic_messaging) tags=(--tags @basic-messaging) ;;
+    raw_command) tags=(--tags @raw-command) ;;
+esac
+
 # --strict fails the run on undefined steps; without it a feature step with no 
matching C++
 # definition would be reported and still exit 0.
 set +e
-bundle exec cucumber --strict
+bundle exec cucumber --strict "${tags[@]}"
 status=$?
 set -e
 
diff --git a/bdd/docker-compose.yml b/bdd/docker-compose.yml
index 0c6076634..2b9c7b23f 100644
--- a/bdd/docker-compose.yml
+++ b/bdd/docker-compose.yml
@@ -30,6 +30,7 @@ services:
     volumes:
       - 
./scenarios/basic_messaging.feature:/app/features/basic_messaging.feature
       - 
./scenarios/leader_redirection.feature:/app/features/leader_redirection.feature
+      - ./scenarios/raw_command.feature:/app/features/raw_command.feature
     command:
       - sh
       - -c
@@ -37,6 +38,7 @@ services:
         case "$$BDD_FEATURE" in
           basic_messaging)    cargo test -p bdd --features bdd --test 
basic_messaging ;;
           leader_redirection) cargo test -p bdd --features bdd --test 
leader_redirection ;;
+          raw_command)        cargo test -p bdd --features bdd --test 
raw_command ;;
           *)                  cargo test -p bdd --features bdd ;;
         esac
     networks:
@@ -50,10 +52,21 @@ services:
     environment:
       - IGGY_ROOT_USERNAME=iggy
       - IGGY_ROOT_PASSWORD=iggy
+      - BDD_FEATURE=${BDD_FEATURE:-all}
     volumes:
       - 
./scenarios/basic_messaging.feature:/app/features/basic_messaging.feature
+      - ./scenarios/raw_command.feature:/app/features/raw_command.feature
     working_dir: /app
-    command: [ "uv", "run", "--project", "/workspace/bdd/python", "pytest", 
"tests/", "-v" ]
+    command:
+      - sh
+      - -c
+      - |
+        case "$$BDD_FEATURE" in
+          basic_messaging) TEST_PATH='tests/test_basic_messaging.py' ;;
+          raw_command)     TEST_PATH='tests/test_raw_command.py' ;;
+          *)               TEST_PATH='tests/' ;;
+        esac
+        uv run --project /workspace/bdd/python pytest $$TEST_PATH -v
     networks:
       - iggy-bdd-network
 
@@ -69,10 +82,20 @@ services:
       - IGGY_PORT=8090
       - IGGY_USERNAME=iggy
       - IGGY_PASSWORD=iggy
-      - BDD_FEATURE_FILE=/app/features/basic_messaging.feature
+      - BDD_FEATURE=${BDD_FEATURE:-all}
     volumes:
       - 
./scenarios/basic_messaging.feature:/app/features/basic_messaging.feature
-    command: [ "./scripts/test.sh", "--configuration", 
"/workspace/bdd/php/phpunit.xml.dist" ]
+      - ./scenarios/raw_command.feature:/app/features/raw_command.feature
+    command:
+      - sh
+      - -c
+      - |
+        case "$$BDD_FEATURE" in
+          basic_messaging) GROUP='--group basic-messaging' ;;
+          raw_command)     GROUP='--group raw-command' ;;
+          *)               GROUP='' ;;
+        esac
+        ./scripts/test.sh --configuration /workspace/bdd/php/phpunit.xml.dist 
$$GROUP
     networks:
       - iggy-bdd-network
 
@@ -98,7 +121,17 @@ services:
     environment:
       - IGGY_ROOT_USERNAME=iggy
       - IGGY_ROOT_PASSWORD=iggy
-    command: [ "npm", "run", "test:bdd" ]
+      - BDD_FEATURE=${BDD_FEATURE:-all}
+    command:
+      - sh
+      - -c
+      - |
+        case "$$BDD_FEATURE" in
+          basic_messaging) TAGS='--tags @basic-messaging' ;;
+          raw_command)     TAGS='--tags @raw-command' ;;
+          *)               TAGS='' ;;
+        esac
+        npm run test:bdd -- $$TAGS
     networks:
       - iggy-bdd-network
 
@@ -119,6 +152,7 @@ services:
         case "$$BDD_FEATURE" in
           basic_messaging)    FILTER='--filter-trait Category=basic-messaging' 
;;
           leader_redirection) FILTER='--filter-trait 
Category=requires-leader-awareness' ;;
+          raw_command)        FILTER='--filter-trait Category=raw-command' ;;
         esac
         dotnet test $$FILTER
     networks:
@@ -132,9 +166,19 @@ services:
     environment:
       - IGGY_ROOT_USERNAME=iggy
       - IGGY_ROOT_PASSWORD=iggy
+      - BDD_FEATURE=${BDD_FEATURE:-all}
     volumes:
       - 
./scenarios/basic_messaging.feature:/app/features/basic_messaging.feature
-    command: [ "gradle", "--no-daemon", "test" ]
+      - ./scenarios/raw_command.feature:/app/features/raw_command.feature
+    command:
+      - sh
+      - -c
+      - |
+        case "$$BDD_FEATURE" in
+          basic_messaging) export CUCUMBER_FILTER_TAGS='@basic-messaging' ;;
+          raw_command)     export CUCUMBER_FILTER_TAGS='@raw-command' ;;
+        esac
+        gradle --no-daemon test
     networks:
       - iggy-bdd-network
 
@@ -146,8 +190,10 @@ services:
     environment:
       - IGGY_ROOT_USERNAME=iggy
       - IGGY_ROOT_PASSWORD=iggy
+      - BDD_FEATURE=${BDD_FEATURE:-all}
     volumes:
       - 
./scenarios/basic_messaging.feature:/app/features/basic_messaging.feature
+      - ./scenarios/raw_command.feature:/app/features/raw_command.feature
     networks:
       - iggy-bdd-network
 
diff --git a/bdd/go/tests/raw_command.go b/bdd/go/tests/raw_command.go
new file mode 100644
index 000000000..78e251be8
--- /dev/null
+++ b/bdd/go/tests/raw_command.go
@@ -0,0 +1,126 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements.  See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership.  The ASF licenses this file
+// to you 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 tests
+
+import (
+       "context"
+       "errors"
+       "fmt"
+       "os"
+
+       "github.com/apache/iggy/foreign/go/client"
+       "github.com/apache/iggy/foreign/go/client/tcp"
+       iggcon "github.com/apache/iggy/foreign/go/contracts"
+       ierror "github.com/apache/iggy/foreign/go/errors"
+       "github.com/cucumber/godog"
+)
+
+type rawCommandCtxKey struct{}
+
+type rawCommandCtx struct {
+       client       iggcon.Client
+       serverAddr   string
+       lastResponse []byte
+       lastError    error
+}
+
+func getRawCommandCtx(ctx context.Context) *rawCommandCtx {
+       return ctx.Value(rawCommandCtxKey{}).(*rawCommandCtx)
+}
+
+type rawCommandSteps struct{}
+
+func (rawCommandSteps) givenRunningServer(ctx context.Context) error {
+       address := os.Getenv("IGGY_TCP_ADDRESS")
+       if address == "" {
+               address = "127.0.0.1:8090"
+       }
+       getRawCommandCtx(ctx).serverAddr = address
+       return nil
+}
+
+func (rawCommandSteps) givenAuthenticationAsRoot(ctx context.Context) error {
+       state := getRawCommandCtx(ctx)
+       iggyClient, err := 
client.NewIggyClient(client.WithTcp(tcp.WithServerAddress(state.serverAddr)))
+       if err != nil {
+               return fmt.Errorf("create client: %w", err)
+       }
+       if err = iggyClient.Connect(ctx); err != nil {
+               return fmt.Errorf("connect client: %w", err)
+       }
+       if _, err = iggyClient.LoginUser(ctx, "iggy", "iggy"); err != nil {
+               return fmt.Errorf("authenticate client: %w", err)
+       }
+       state.client = iggyClient
+       return nil
+}
+
+func (rawCommandSteps) whenSendRawCommand(ctx context.Context, code uint32) 
error {
+       state := getRawCommandCtx(ctx)
+       state.lastResponse, state.lastError = 
state.client.SendRawWithResponse(ctx, code, nil)
+       return nil
+}
+
+func (rawCommandSteps) thenEmptyResponse(ctx context.Context) error {
+       state := getRawCommandCtx(ctx)
+       if state.lastError != nil {
+               return fmt.Errorf("raw command failed: %w", state.lastError)
+       }
+       if len(state.lastResponse) != 0 {
+               return fmt.Errorf("expected empty response, got %d bytes", 
len(state.lastResponse))
+       }
+       return nil
+}
+
+func (rawCommandSteps) thenNonEmptyResponse(ctx context.Context) error {
+       state := getRawCommandCtx(ctx)
+       if state.lastError != nil {
+               return fmt.Errorf("raw command failed: %w", state.lastError)
+       }
+       if len(state.lastResponse) == 0 {
+               return errors.New("expected non-empty response")
+       }
+       return nil
+}
+
+func (rawCommandSteps) thenInvalidCommand(ctx context.Context) error {
+       if !errors.Is(getRawCommandCtx(ctx).lastError, 
ierror.ErrInvalidCommand) {
+               return fmt.Errorf("expected invalid command error, got %v", 
getRawCommandCtx(ctx).lastError)
+       }
+       return nil
+}
+
+func initRawCommandScenario(sc *godog.ScenarioContext) {
+       sc.Before(func(context.Context, *godog.Scenario) (context.Context, 
error) {
+               return context.WithValue(context.Background(), 
rawCommandCtxKey{}, &rawCommandCtx{}), nil
+       })
+       steps := rawCommandSteps{}
+       sc.Step(`I have a running Iggy server`, steps.givenRunningServer)
+       sc.Step(`I am authenticated as the root user`, 
steps.givenAuthenticationAsRoot)
+       sc.Step(`^I send a raw command with code (\d+) and an empty payload$`, 
steps.whenSendRawCommand)
+       sc.Step(`the raw command should succeed with an empty response`, 
steps.thenEmptyResponse)
+       sc.Step(`the raw command should succeed with a non-empty response`, 
steps.thenNonEmptyResponse)
+       sc.Step(`the raw command should fail with an invalid command error`, 
steps.thenInvalidCommand)
+       sc.After(func(ctx context.Context, _ *godog.Scenario, scenarioError 
error) (context.Context, error) {
+               state := getRawCommandCtx(ctx)
+               if state.client != nil {
+                       scenarioError = errors.Join(scenarioError, 
state.client.Close())
+               }
+               return ctx, scenarioError
+       })
+}
diff --git a/bdd/go/tests/suite_test.go b/bdd/go/tests/suite_test.go
index 15d951b44..3aca7b078 100644
--- a/bdd/go/tests/suite_test.go
+++ b/bdd/go/tests/suite_test.go
@@ -51,6 +51,16 @@ func TestFeatures(t *testing.T) {
                        },
                })
        }
+       if feature == "all" || feature == "raw_command" {
+               suites = append(suites, godog.TestSuite{
+                       ScenarioInitializer: initRawCommandScenario,
+                       Options: &godog.Options{
+                               Format:   "pretty",
+                               Paths:    
[]string{"../../scenarios/raw_command.feature"},
+                               TestingT: t,
+                       },
+               })
+       }
 
        if len(suites) == 0 {
                t.Fatalf("unknown BDD_FEATURE=%q", feature)
diff --git 
a/bdd/java/src/test/java/org/apache/iggy/bdd/BasicMessagingSteps.java 
b/bdd/java/src/test/java/org/apache/iggy/bdd/BasicMessagingSteps.java
index 0ad53dad0..fde21676b 100644
--- a/bdd/java/src/test/java/org/apache/iggy/bdd/BasicMessagingSteps.java
+++ b/bdd/java/src/test/java/org/apache/iggy/bdd/BasicMessagingSteps.java
@@ -24,6 +24,8 @@ import io.cucumber.java.en.Then;
 import io.cucumber.java.en.When;
 import org.apache.iggy.client.blocking.IggyBaseClient;
 import org.apache.iggy.client.blocking.tcp.IggyTcpClient;
+import org.apache.iggy.exception.IggyErrorCode;
+import org.apache.iggy.exception.IggyServerException;
 import org.apache.iggy.message.Message;
 import org.apache.iggy.message.Partitioning;
 import org.apache.iggy.message.PollingStrategy;
@@ -43,6 +45,7 @@ import java.util.Optional;
 import static org.junit.jupiter.api.Assertions.assertEquals;
 import static org.junit.jupiter.api.Assertions.assertFalse;
 import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertNull;
 import static org.junit.jupiter.api.Assertions.assertTrue;
 
 public class BasicMessagingSteps {
@@ -209,6 +212,39 @@ public class BasicMessagingSteps {
         assertEquals(context.lastSentMessage, lastPayload, "Last message 
should match sent message");
     }
 
+    @When("I send a raw command with code {int} and an empty payload")
+    public void sendRawCommand(int code) {
+        try {
+            context.lastRawResponse = getClient().sendRawWithResponse(code, 
new byte[0]);
+            context.lastRawError = null;
+        } catch (RuntimeException error) {
+            context.lastRawResponse = null;
+            context.lastRawError = error;
+        }
+    }
+
+    @Then("the raw command should succeed with an empty response")
+    public void rawCommandSucceedsWithEmptyResponse() {
+        assertNull(context.lastRawError, "Raw command should succeed");
+        assertNotNull(context.lastRawResponse, "Raw response should be 
present");
+        assertEquals(0, context.lastRawResponse.length, "Raw response should 
be empty");
+    }
+
+    @Then("the raw command should succeed with a non-empty response")
+    public void rawCommandSucceedsWithNonEmptyResponse() {
+        assertNull(context.lastRawError, "Raw command should succeed");
+        assertNotNull(context.lastRawResponse, "Raw response should be 
present");
+        assertTrue(context.lastRawResponse.length > 0, "Raw response should 
not be empty");
+    }
+
+    @Then("the raw command should fail with an invalid command error")
+    public void rawCommandFailsWithInvalidCommand() {
+        assertNull(context.lastRawResponse, "Raw response should be absent");
+        assertTrue(context.lastRawError instanceof IggyServerException, 
"Expected an Iggy server error");
+        IggyServerException error = (IggyServerException) context.lastRawError;
+        assertEquals(IggyErrorCode.INVALID_COMMAND, error.getErrorCode());
+    }
+
     private IggyBaseClient getClient() {
         if (context.client == null) {
             throw new IllegalStateException("Iggy client not initialized");
diff --git a/bdd/java/src/test/java/org/apache/iggy/bdd/TestContext.java 
b/bdd/java/src/test/java/org/apache/iggy/bdd/TestContext.java
index e87983250..13ea24b94 100644
--- a/bdd/java/src/test/java/org/apache/iggy/bdd/TestContext.java
+++ b/bdd/java/src/test/java/org/apache/iggy/bdd/TestContext.java
@@ -32,4 +32,6 @@ class TestContext {
     Long lastTopicPartitions;
     PolledMessages lastPolledMessages;
     String lastSentMessage;
+    byte[] lastRawResponse;
+    RuntimeException lastRawError;
 }
diff --git a/bdd/node/Dockerfile b/bdd/node/Dockerfile
index 4c83e8c02..26eb5043e 100644
--- a/bdd/node/Dockerfile
+++ b/bdd/node/Dockerfile
@@ -31,6 +31,7 @@ FROM node:26-slim
 
 COPY ./foreign/node .
 COPY ./bdd/scenarios/basic_messaging.feature ./bdd/
+COPY ./bdd/scenarios/raw_command.feature ./bdd/
 
 RUN npm ci
 
diff --git a/bdd/php/tests/BasicMessagingFeatureTest.php 
b/bdd/php/tests/BasicMessagingFeatureTest.php
index b4d4c84bc..d47b09f60 100644
--- a/bdd/php/tests/BasicMessagingFeatureTest.php
+++ b/bdd/php/tests/BasicMessagingFeatureTest.php
@@ -18,10 +18,13 @@
 
 declare(strict_types=1);
 
+require_once __DIR__ . '/SharedFeatureParser.php';
+
 use Iggy\Client as IggyClient;
 use Iggy\PollingStrategy;
 use Iggy\SendMessage;
 use PHPUnit\Framework\Attributes\DataProvider;
+use PHPUnit\Framework\Attributes\Group;
 use PHPUnit\Framework\Attributes\TestDox;
 use PHPUnit\Framework\TestCase;
 
@@ -36,6 +39,7 @@ final class BasicMessagingFeatureTest extends TestCase
     private ?int $lastSentMessageCount = null;
 
     #[DataProvider('scenarioCases')]
+    #[Group('basic-messaging')]
     #[TestDox('Basic messaging shared BDD scenario passes for the PHP SDK')]
     public function testBasicMessagingScenario(string $scenarioName, array 
$steps): void
     {
@@ -61,89 +65,7 @@ final class BasicMessagingFeatureTest extends TestCase
 
     public static function scenarioCases(): array
     {
-        $featureFile = getenv('BDD_FEATURE_FILE') ?: __DIR__ . 
'/../../scenarios/basic_messaging.feature';
-        if (!is_file($featureFile)) {
-            throw new RuntimeException("feature file not found at 
{$featureFile}");
-        }
-
-        $lines = file($featureFile, FILE_IGNORE_NEW_LINES);
-        if ($lines === false) {
-            throw new RuntimeException("failed to read feature file at 
{$featureFile}");
-        }
-
-        $backgroundSteps = [];
-        $scenarios = [];
-        $currentScenario = null;
-        $currentSteps = [];
-        $section = null;
-        $stepPattern = '/^(Given|When|Then|And|But|\*) (.+)$/';
-        $unsupportedStructurePattern = '/^(Scenario 
Outline|Rule|Examples):|^\|/';
-        foreach ($lines as $line) {
-            $line = trim($line);
-            if ($line === '' || str_starts_with($line, '#') || 
str_starts_with($line, '@')) {
-                continue;
-            }
-
-            if ($line === 'Background:') {
-                $section = 'background';
-                continue;
-            }
-
-            if (str_starts_with($line, 'Scenario:')) {
-                if ($currentScenario !== null) {
-                    $scenarios[$currentScenario] = $currentSteps;
-                }
-
-                $currentScenario = trim(substr($line, strlen('Scenario:')));
-                $currentSteps = [];
-                $section = 'scenario';
-                continue;
-            }
-
-            if (preg_match($unsupportedStructurePattern, $line) === 1) {
-                throw new RuntimeException("Unsupported BDD structure: 
{$line}");
-            }
-
-            if (preg_match($stepPattern, $line, $matches) !== 1) {
-                continue;
-            }
-
-            if ($section === null) {
-                throw new RuntimeException("BDD step appears before Background 
or Scenario: {$line}");
-            }
-
-            if ($section === 'background') {
-                $backgroundSteps[] = $matches[2];
-                continue;
-            }
-
-            if ($section === 'scenario' && $currentScenario !== null) {
-                $currentSteps[] = $matches[2];
-            }
-        }
-
-        if ($currentScenario !== null) {
-            $scenarios[$currentScenario] = $currentSteps;
-        }
-
-        if ($backgroundSteps === []) {
-            throw new RuntimeException('no BDD background steps were loaded 
from the feature');
-        }
-
-        if ($scenarios === []) {
-            throw new RuntimeException('no BDD scenarios were loaded from the 
feature');
-        }
-
-        $cases = [];
-        foreach ($scenarios as $scenarioName => $scenarioSteps) {
-            if ($scenarioSteps === []) {
-                throw new RuntimeException("scenario has no steps: 
{$scenarioName}");
-            }
-
-            $cases[$scenarioName] = [$scenarioName, [...$backgroundSteps, 
...$scenarioSteps]];
-        }
-
-        return $cases;
+        return SharedFeatureParser::load(__DIR__ . 
'/../../scenarios/basic_messaging.feature');
     }
 
     private function runStep(string $step): void
diff --git a/bdd/php/tests/RawCommandFeatureTest.php 
b/bdd/php/tests/RawCommandFeatureTest.php
new file mode 100644
index 000000000..a6dd1f3eb
--- /dev/null
+++ b/bdd/php/tests/RawCommandFeatureTest.php
@@ -0,0 +1,102 @@
+<?php
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements.  See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership.  The ASF licenses this file
+// to you 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.
+
+declare(strict_types=1);
+
+require_once __DIR__ . '/SharedFeatureParser.php';
+
+use Iggy\Client as IggyClient;
+use Iggy\Exception\IggyException;
+use PHPUnit\Framework\Attributes\DataProvider;
+use PHPUnit\Framework\Attributes\Group;
+use PHPUnit\Framework\Attributes\TestDox;
+use PHPUnit\Framework\TestCase;
+
+final class RawCommandFeatureTest extends TestCase
+{
+    private ?IggyClient $client = null;
+    private ?string $lastResponse = null;
+    private ?Throwable $lastError = null;
+
+    #[DataProvider('scenarioCases')]
+    #[Group('raw-command')]
+    #[TestDox('Raw command shared BDD scenario passes for the PHP SDK')]
+    public function testRawCommandScenario(string $scenarioName, array 
$steps): void
+    {
+        assert_true($scenarioName !== '', 'scenario name must not be empty');
+        foreach ($steps as $step) {
+            $this->runStep($step);
+        }
+    }
+
+    public static function scenarioCases(): array
+    {
+        return SharedFeatureParser::load(__DIR__ . 
'/../../scenarios/raw_command.feature');
+    }
+
+    private function runStep(string $step): void
+    {
+        if ($step === 'I have a running Iggy server') {
+            $this->client = new IggyClient(server_host() . ':' . 
server_port());
+            $this->client->connect();
+            $this->client->ping();
+            return;
+        }
+        if ($step === 'I am authenticated as the root user') {
+            $this->requireClient()->loginUser(
+                env_or_default('IGGY_USERNAME', 'iggy'),
+                env_or_default('IGGY_PASSWORD', 'iggy'),
+            );
+            return;
+        }
+        if (preg_match('/^I send a raw command with code (\d+) and an empty 
payload$/', $step, $matches) === 1) {
+            try {
+                $this->lastResponse = 
$this->requireClient()->sendRawWithResponse((int) $matches[1], '');
+                $this->lastError = null;
+            } catch (Throwable $error) {
+                $this->lastResponse = null;
+                $this->lastError = $error;
+            }
+            return;
+        }
+        if ($step === 'the raw command should succeed with an empty response') 
{
+            assert_same(null, $this->lastError);
+            assert_same('', $this->lastResponse);
+            return;
+        }
+        if ($step === 'the raw command should succeed with a non-empty 
response') {
+            assert_same(null, $this->lastError);
+            assert_true($this->lastResponse !== null && $this->lastResponse 
!== '');
+            return;
+        }
+        if ($step === 'the raw command should fail with an invalid command 
error') {
+            assert_same(null, $this->lastResponse);
+            assert_instance_of(IggyException::class, $this->lastError);
+            assert_true(str_contains(strtolower((string) 
$this->lastError?->getMessage()), 'invalid command'));
+            return;
+        }
+
+        self::fail("Unsupported BDD step: {$step}");
+    }
+
+    private function requireClient(): IggyClient
+    {
+        assert_not_null($this->client, 'BDD client has not been initialized');
+        return $this->client;
+    }
+}
diff --git a/bdd/php/tests/SharedFeatureParser.php 
b/bdd/php/tests/SharedFeatureParser.php
new file mode 100644
index 000000000..38a8d8c5e
--- /dev/null
+++ b/bdd/php/tests/SharedFeatureParser.php
@@ -0,0 +1,94 @@
+<?php
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements.  See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership.  The ASF licenses this file
+// to you 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.
+
+declare(strict_types=1);
+
+final class SharedFeatureParser
+{
+    public static function load(string $featureFile): array
+    {
+        if (!is_file($featureFile)) {
+            throw new RuntimeException("feature file not found at 
{$featureFile}");
+        }
+
+        $lines = file($featureFile, FILE_IGNORE_NEW_LINES);
+        if ($lines === false) {
+            throw new RuntimeException("failed to read feature file at 
{$featureFile}");
+        }
+
+        $backgroundSteps = [];
+        $scenarios = [];
+        $currentScenario = null;
+        $currentSteps = [];
+        $section = null;
+        foreach ($lines as $line) {
+            $line = trim($line);
+            if ($line === '' || str_starts_with($line, '#') || 
str_starts_with($line, '@')) {
+                continue;
+            }
+
+            if ($line === 'Background:') {
+                $section = 'background';
+                continue;
+            }
+
+            if (str_starts_with($line, 'Scenario:')) {
+                if ($currentScenario !== null) {
+                    $scenarios[$currentScenario] = $currentSteps;
+                }
+                $currentScenario = trim(substr($line, strlen('Scenario:')));
+                $currentSteps = [];
+                $section = 'scenario';
+                continue;
+            }
+
+            if (preg_match('/^(Scenario Outline|Rule|Examples):|^\|/', $line) 
=== 1) {
+                throw new RuntimeException("Unsupported BDD structure: 
{$line}");
+            }
+
+            if (preg_match('/^(Given|When|Then|And|But|\*) (.+)$/', $line, 
$matches) !== 1) {
+                continue;
+            }
+            if ($section === null) {
+                throw new RuntimeException("BDD step appears before Background 
or Scenario: {$line}");
+            }
+            if ($section === 'background') {
+                $backgroundSteps[] = $matches[2];
+            } elseif ($currentScenario !== null) {
+                $currentSteps[] = $matches[2];
+            }
+        }
+
+        if ($currentScenario !== null) {
+            $scenarios[$currentScenario] = $currentSteps;
+        }
+        if ($backgroundSteps === [] || $scenarios === []) {
+            throw new RuntimeException("feature must contain a background and 
at least one scenario: {$featureFile}");
+        }
+
+        $cases = [];
+        foreach ($scenarios as $scenarioName => $scenarioSteps) {
+            if ($scenarioSteps === []) {
+                throw new RuntimeException("scenario has no steps: 
{$scenarioName}");
+            }
+            $cases[$scenarioName] = [$scenarioName, [...$backgroundSteps, 
...$scenarioSteps]];
+        }
+
+        return $cases;
+    }
+}
diff --git a/bdd/python/tests/conftest.py b/bdd/python/tests/conftest.py
index 390ada907..4c968eea1 100644
--- a/bdd/python/tests/conftest.py
+++ b/bdd/python/tests/conftest.py
@@ -40,6 +40,8 @@ class GlobalContext:
     last_topic_partitions: int | None = None
     last_polled_messages: list[ReceiveMessage] | None = None
     last_sent_message: str | None = None  # Store message payload as string
+    last_raw_response: bytes | None = None
+    last_raw_error: RuntimeError | None = None
 
 
 @pytest.fixture(scope="session")
diff --git a/bdd/python/tests/test_raw_command.py 
b/bdd/python/tests/test_raw_command.py
new file mode 100644
index 000000000..37e2939d5
--- /dev/null
+++ b/bdd/python/tests/test_raw_command.py
@@ -0,0 +1,82 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements.  See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership.  The ASF licenses this file
+# to you 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.
+
+import asyncio
+import socket
+
+from apache_iggy import IggyClient
+from pytest_bdd import given, parsers, scenarios, then, when
+
+scenarios("/app/features/raw_command.feature")
+
+
+@given("I have a running Iggy server")
+def running_server(context):
+    async def connect():
+        host, port = context.server_addr.split(":")
+        try:
+            address = f"{socket.gethostbyname(host)}:{port}"
+        except socket.gaierror:
+            address = context.server_addr
+
+        context.client = IggyClient(address)
+        await context.client.connect()
+        await context.client.ping()
+
+    asyncio.run(connect())
+
+
+@given("I am authenticated as the root user")
+def authenticated_root_user(context):
+    async def login():
+        await context.client.login_user("iggy", "iggy")
+
+    asyncio.run(login())
+
+
+@when(parsers.parse("I send a raw command with code {code:d} and an empty 
payload"))
+def send_raw_command(context, code):
+    async def send():
+        try:
+            context.last_raw_response = await 
context.client.send_raw_with_response(
+                code, b""
+            )
+            context.last_raw_error = None
+        except RuntimeError as error:
+            context.last_raw_response = None
+            context.last_raw_error = error
+
+    asyncio.run(send())
+
+
+@then("the raw command should succeed with an empty response")
+def raw_command_succeeds_with_empty_response(context):
+    assert context.last_raw_error is None
+    assert context.last_raw_response == b""
+
+
+@then("the raw command should succeed with a non-empty response")
+def raw_command_succeeds_with_non_empty_response(context):
+    assert context.last_raw_error is None
+    assert context.last_raw_response
+
+
+@then("the raw command should fail with an invalid command error")
+def raw_command_fails_with_invalid_command(context):
+    assert context.last_raw_response is None
+    assert context.last_raw_error is not None
+    assert "invalid command" in str(context.last_raw_error).lower()
diff --git a/bdd/rust/Cargo.toml b/bdd/rust/Cargo.toml
index ebedb19d2..0b761fb42 100644
--- a/bdd/rust/Cargo.toml
+++ b/bdd/rust/Cargo.toml
@@ -44,3 +44,8 @@ required-features = ["bdd"]
 name = "leader_redirection"
 harness = false
 required-features = ["bdd"]
+
+[[test]]
+name = "raw_command"
+harness = false
+required-features = ["bdd"]
diff --git a/bdd/rust/tests/common/global_context.rs 
b/bdd/rust/tests/common/global_context.rs
index b18d56388..977c715e2 100644
--- a/bdd/rust/tests/common/global_context.rs
+++ b/bdd/rust/tests/common/global_context.rs
@@ -15,9 +15,10 @@
 // specific language governing permissions and limitations
 // under the License.
 
+use bytes::Bytes;
 use cucumber::World;
 use iggy::clients::client::IggyClient;
-use iggy::prelude::{IggyMessage, PolledMessages};
+use iggy::prelude::{IggyError, IggyMessage, PolledMessages};
 
 #[derive(Debug, World, Default)]
 pub struct GlobalContext {
@@ -30,4 +31,5 @@ pub struct GlobalContext {
     pub last_topic_partitions: Option<u32>,
     pub last_polled_messages: Option<PolledMessages>,
     pub last_sent_message: Option<IggyMessage>,
+    pub last_raw_result: Option<Result<Bytes, IggyError>>,
 }
diff --git a/foreign/go/contracts/version.go b/bdd/rust/tests/raw_command.rs
similarity index 76%
copy from foreign/go/contracts/version.go
copy to bdd/rust/tests/raw_command.rs
index 5733ff7a5..d5eae32de 100644
--- a/foreign/go/contracts/version.go
+++ b/bdd/rust/tests/raw_command.rs
@@ -15,6 +15,14 @@
 // specific language governing permissions and limitations
 // under the License.
 
-package iggcon
+pub(crate) mod common;
+pub(crate) mod helpers;
+pub(crate) mod steps;
 
-const Version = "0.8.1-edge.1"
+use crate::common::global_context::GlobalContext;
+use cucumber::World;
+
+#[tokio::main]
+async fn main() {
+    GlobalContext::run("../../bdd/scenarios/raw_command.feature").await;
+}
diff --git a/bdd/rust/tests/steps/mod.rs b/bdd/rust/tests/steps/mod.rs
index 907084ec3..51b40fe99 100644
--- a/bdd/rust/tests/steps/mod.rs
+++ b/bdd/rust/tests/steps/mod.rs
@@ -18,6 +18,7 @@
 pub mod auth;
 pub mod leader_redirection;
 pub mod messages;
+pub mod raw_command;
 pub mod server;
 pub mod streams;
 pub mod topics;
diff --git a/bdd/rust/tests/steps/raw_command.rs 
b/bdd/rust/tests/steps/raw_command.rs
new file mode 100644
index 000000000..72676cdec
--- /dev/null
+++ b/bdd/rust/tests/steps/raw_command.rs
@@ -0,0 +1,60 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements.  See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership.  The ASF licenses this file
+// to you 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.
+
+use crate::common::global_context::GlobalContext;
+use bytes::Bytes;
+use cucumber::{then, when};
+use iggy::prelude::IggyError;
+
+#[when(regex = r"^I send a raw command with code (\d+) and an empty payload$")]
+pub async fn when_send_raw_command(world: &mut GlobalContext, code: u32) {
+    let client = world.client.as_ref().expect("Client should be available");
+    world.last_raw_result = Some(client.send_binary_request(code, 
Bytes::new()).await);
+}
+
+#[then("the raw command should succeed with an empty response")]
+pub async fn then_raw_command_succeeds_with_empty_response(world: &mut 
GlobalContext) {
+    let response = world
+        .last_raw_result
+        .as_ref()
+        .expect("Should have sent a raw command")
+        .as_ref()
+        .expect("Raw command should have succeeded");
+    assert!(response.is_empty(), "Response should be empty");
+}
+
+#[then("the raw command should succeed with a non-empty response")]
+pub async fn then_raw_command_succeeds_with_non_empty_response(world: &mut 
GlobalContext) {
+    let response = world
+        .last_raw_result
+        .as_ref()
+        .expect("Should have sent a raw command")
+        .as_ref()
+        .expect("Raw command should have succeeded");
+    assert!(!response.is_empty(), "Response should not be empty");
+}
+
+#[then("the raw command should fail with an invalid command error")]
+pub async fn then_raw_command_fails_with_invalid_command_error(world: &mut 
GlobalContext) {
+    let error = world
+        .last_raw_result
+        .as_ref()
+        .expect("Should have sent a raw command")
+        .as_ref()
+        .expect_err("Raw command should have failed");
+    assert_eq!(*error, IggyError::InvalidCommand);
+}
diff --git a/bdd/scenarios/raw_command.feature 
b/bdd/scenarios/raw_command.feature
new file mode 100644
index 000000000..158c35d97
--- /dev/null
+++ b/bdd/scenarios/raw_command.feature
@@ -0,0 +1,41 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements.  See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership.  The ASF licenses this file
+# to you 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.
+
+@raw-command
+Feature: Raw Command API
+  As a developer using Apache Iggy
+  I want to send a command code with a payload directly
+  So that I can call commands that have no typed SDK method
+
+  Background:
+    Given I have a running Iggy server
+    And I am authenticated as the root user
+
+  Scenario: Known command codes round-trip
+    When I send a raw command with code 1 and an empty payload
+    Then the raw command should succeed with an empty response
+
+    When I send a raw command with code 10 and an empty payload
+    Then the raw command should succeed with a non-empty response
+
+  Scenario: Unknown command code is rejected
+    When I send a raw command with code 60000 and an empty payload
+    Then the raw command should fail with an invalid command error
+
+  Scenario: Session control command code is rejected
+    When I send a raw command with code 38 and an empty payload
+    Then the raw command should fail with an invalid command error
diff --git a/foreign/cpp/Cargo.toml b/foreign/cpp/Cargo.toml
index ca0e8ad6e..96add1213 100644
--- a/foreign/cpp/Cargo.toml
+++ b/foreign/cpp/Cargo.toml
@@ -17,7 +17,7 @@
 
 [package]
 name = "iggy-cpp"
-version = "0.1.0"
+version = "0.1.1"
 edition = "2024"
 
 [package.metadata.cargo-machete]
diff --git a/foreign/cpp/MODULE.bazel b/foreign/cpp/MODULE.bazel
index 4e28a2851..985713492 100644
--- a/foreign/cpp/MODULE.bazel
+++ b/foreign/cpp/MODULE.bazel
@@ -17,7 +17,7 @@
 
 module(
     name = "iggy_cpp",
-    version = "0.1.0",
+    version = "0.1.1",
 )
 
 bazel_dep(name = "rules_cc", version = "0.2.20")
diff --git a/foreign/cpp/src/client.rs b/foreign/cpp/src/client.rs
index 4051d40fb..898f2fab9 100644
--- a/foreign/cpp/src/client.rs
+++ b/foreign/cpp/src/client.rs
@@ -16,6 +16,7 @@
 // under the License.
 
 use crate::{RUNTIME, ffi};
+use bytes::Bytes;
 use iggy::prelude::{
     Client as IggyConnectionClient, ClusterClient,
     CompressionAlgorithm as RustCompressionAlgorithm, Consumer, 
ConsumerGroupClient,
@@ -1129,6 +1130,19 @@ impl Client {
             Ok(())
         })
     }
+
+    /// Sends a command code and payload and returns the raw response bytes.
+    /// Session-control codes return an invalid-command error.
+    pub fn send_raw_with_response(&self, code: u32, payload: Vec<u8>) -> 
Result<Vec<u8>, String> {
+        RUNTIME.block_on(async {
+            let response = self
+                .inner
+                .send_binary_request(code, Bytes::from(payload))
+                .await
+                .map_err(|error| format!("Could not send raw command '{code}': 
{error}"))?;
+            Ok(response.to_vec())
+        })
+    }
 }
 
 pub unsafe fn delete_connection(client: *mut Client) -> Result<(), String> {
diff --git a/foreign/cpp/src/lib.rs b/foreign/cpp/src/lib.rs
index 458e280b6..bc3afa29d 100644
--- a/foreign/cpp/src/lib.rs
+++ b/foreign/cpp/src/lib.rs
@@ -480,6 +480,7 @@ mod ffi {
             snapshot_compression: String,
             snapshot_types: Vec<String>,
         ) -> Result<Vec<u8>>;
+        fn send_raw_with_response(self: &Client, code: u32, payload: Vec<u8>) 
-> Result<Vec<u8>>;
 
         // Future functions
         fn disconnect(self: &Client) -> Result<()>;
diff --git a/foreign/cpp/tests/client/low_level_e2e.cpp 
b/foreign/cpp/tests/client/low_level_e2e.cpp
index 590c8f597..4ae17d354 100644
--- a/foreign/cpp/tests/client/low_level_e2e.cpp
+++ b/foreign/cpp/tests/client/low_level_e2e.cpp
@@ -1703,3 +1703,45 @@ TEST_F(LowLevelE2E_Client, 
SnapshotWithInvalidSnapshotTypeThrows) {
 
     ASSERT_THROW(client->snapshot("deflated", 
make_snapshot_types({"not-a-real-type"})), std::exception);
 }
+
+TEST_F(LowLevelE2E_Client, SendRawWithResponsePingReturnsEmptyBytes) {
+    RecordProperty("description", "Returns an empty response body for a raw 
ping command with an empty payload.");
+    constexpr std::uint32_t ping_command_code = 1;
+    iggy::ffi::Client *client                 = GetLoggedInClient();
+
+    rust::Vec<std::uint8_t> empty_payload;
+    rust::Vec<std::uint8_t> response;
+    ASSERT_NO_THROW({ response = 
client->send_raw_with_response(ping_command_code, empty_payload); });
+    EXPECT_TRUE(response.empty());
+}
+
+TEST_F(LowLevelE2E_Client, SendRawWithResponseGetStatsReturnsNonEmptyBytes) {
+    RecordProperty("description",
+                   "Returns a non-empty response body for a raw get-stats 
command with an empty payload.");
+    constexpr std::uint32_t get_stats_command_code = 10;
+    iggy::ffi::Client *client                      = GetLoggedInClient();
+
+    rust::Vec<std::uint8_t> empty_payload;
+    rust::Vec<std::uint8_t> response;
+    ASSERT_NO_THROW({ response = 
client->send_raw_with_response(get_stats_command_code, empty_payload); });
+    EXPECT_FALSE(response.empty());
+}
+
+TEST_F(LowLevelE2E_Client, SendRawWithResponseLoginUserCodeThrows) {
+    RecordProperty("description",
+                   "Rejects the login-user session-control code client-side 
before it reaches the server.");
+    constexpr std::uint32_t login_user_command_code = 38;
+    iggy::ffi::Client *client                       = GetLoggedInClient();
+
+    rust::Vec<std::uint8_t> empty_payload;
+    ASSERT_THROW(client->send_raw_with_response(login_user_command_code, 
empty_payload), std::exception);
+}
+
+TEST_F(LowLevelE2E_Client, SendRawWithResponseUnknownCommandCodeThrows) {
+    RecordProperty("description", "Rejects an unknown command code with an 
invalid-command error from the server.");
+    constexpr std::uint32_t unknown_command_code = 60000;
+    iggy::ffi::Client *client                    = GetLoggedInClient();
+
+    rust::Vec<std::uint8_t> empty_payload;
+    ASSERT_THROW(client->send_raw_with_response(unknown_command_code, 
empty_payload), std::exception);
+}
diff --git a/foreign/csharp/Iggy_SDK.Tests.BDD/Context/TestContext.cs 
b/foreign/csharp/Iggy_SDK.Tests.BDD/Context/TestContext.cs
index 8edd5df70..81c12f2e8 100644
--- a/foreign/csharp/Iggy_SDK.Tests.BDD/Context/TestContext.cs
+++ b/foreign/csharp/Iggy_SDK.Tests.BDD/Context/TestContext.cs
@@ -34,4 +34,6 @@ public class TestContext
     public Message? LastSendMessage { get; set; }
     public bool RedirectionOccurred { get; set; }
     public uint? LastStreamId { get; set; }
+    public byte[]? LastRawResponse { get; set; }
+    public Exception? LastRawError { get; set; }
 }
diff --git a/foreign/csharp/Iggy_SDK.Tests.BDD/Context/TestHooks.cs 
b/foreign/csharp/Iggy_SDK.Tests.BDD/Context/TestHooks.cs
index 3e7c55095..58c5debdb 100644
--- a/foreign/csharp/Iggy_SDK.Tests.BDD/Context/TestHooks.cs
+++ b/foreign/csharp/Iggy_SDK.Tests.BDD/Context/TestHooks.cs
@@ -39,6 +39,8 @@ public class TestHooks
         _context.CreatedStream = null;
         _context.RedirectionOccurred = false;
         _context.LastStreamId = null;
+        _context.LastRawResponse = null;
+        _context.LastRawError = null;
     }
 
     [AfterScenario]
diff --git a/foreign/csharp/Iggy_SDK.Tests.BDD/Iggy_SDK.Tests.BDD.csproj 
b/foreign/csharp/Iggy_SDK.Tests.BDD/Iggy_SDK.Tests.BDD.csproj
index c97aac998..489a43fc6 100644
--- a/foreign/csharp/Iggy_SDK.Tests.BDD/Iggy_SDK.Tests.BDD.csproj
+++ b/foreign/csharp/Iggy_SDK.Tests.BDD/Iggy_SDK.Tests.BDD.csproj
@@ -61,6 +61,9 @@
         <ReqnrollFeatureFile 
Include="..\..\..\bdd\scenarios\leader_redirection.feature">
             <Link>Features\leader_redirection.feature</Link>
         </ReqnrollFeatureFile>
+        <ReqnrollFeatureFile 
Include="..\..\..\bdd\scenarios\raw_command.feature">
+            <Link>Features\raw_command.feature</Link>
+        </ReqnrollFeatureFile>
     </ItemGroup>
 
     <ItemGroup>
diff --git 
a/foreign/csharp/Iggy_SDK.Tests.BDD/StepDefinitions/RawCommandSteps.cs 
b/foreign/csharp/Iggy_SDK.Tests.BDD/StepDefinitions/RawCommandSteps.cs
new file mode 100644
index 000000000..9515147e2
--- /dev/null
+++ b/foreign/csharp/Iggy_SDK.Tests.BDD/StepDefinitions/RawCommandSteps.cs
@@ -0,0 +1,71 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements.  See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership.  The ASF licenses this file
+// to you 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.
+
+using Apache.Iggy.Exceptions;
+using Reqnroll;
+using Shouldly;
+using TestContext = Apache.Iggy.Tests.BDD.Context.TestContext;
+
+namespace Apache.Iggy.Tests.BDD.StepDefinitions;
+
+[Binding]
+public class RawCommandSteps
+{
+    private readonly TestContext _context;
+
+    public RawCommandSteps(TestContext context)
+    {
+        _context = context;
+    }
+
+    [When(@"I send a raw command with code (\d+) and an empty payload")]
+    public async Task WhenISendARawCommand(uint code)
+    {
+        try
+        {
+            _context.LastRawResponse = await 
_context.IggyClient.SendRawWithResponseAsync(code, []);
+            _context.LastRawError = null;
+        }
+        catch (Exception error)
+        {
+            _context.LastRawResponse = null;
+            _context.LastRawError = error;
+        }
+    }
+
+    [Then(@"the raw command should succeed with an empty response")]
+    public void ThenTheRawCommandShouldSucceedWithAnEmptyResponse()
+    {
+        _context.LastRawError.ShouldBeNull();
+        _context.LastRawResponse.ShouldBeEmpty();
+    }
+
+    [Then(@"the raw command should succeed with a non-empty response")]
+    public void ThenTheRawCommandShouldSucceedWithANonEmptyResponse()
+    {
+        _context.LastRawError.ShouldBeNull();
+        _context.LastRawResponse.ShouldNotBeEmpty();
+    }
+
+    [Then(@"the raw command should fail with an invalid command error")]
+    public void ThenTheRawCommandShouldFailWithAnInvalidCommandError()
+    {
+        _context.LastRawResponse.ShouldBeNull();
+        var error = 
_context.LastRawError.ShouldBeOfType<IggyInvalidStatusCodeException>();
+        error.StatusCode.ShouldBe(3);
+    }
+}
diff --git a/foreign/csharp/Iggy_SDK.Tests.Integration/RawCommandTests.cs 
b/foreign/csharp/Iggy_SDK.Tests.Integration/RawCommandTests.cs
new file mode 100644
index 000000000..18add9f5e
--- /dev/null
+++ b/foreign/csharp/Iggy_SDK.Tests.Integration/RawCommandTests.cs
@@ -0,0 +1,83 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements.  See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership.  The ASF licenses this file
+// to you 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.
+
+using Apache.Iggy.Enums;
+using Apache.Iggy.Exceptions;
+using Apache.Iggy.Tests.Integrations.Attributes;
+using Apache.Iggy.Tests.Integrations.Fixtures;
+using Shouldly;
+
+namespace Apache.Iggy.Tests.Integrations;
+
+public class RawCommandTests
+{
+    [ClassDataSource<IggyServerFixture>(Shared = SharedType.PerAssembly)]
+    public required IggyServerFixture Fixture { get; init; }
+
+    [Test]
+    [SkipHttp]
+    
[MethodDataSource<IggyServerFixture>(nameof(IggyServerFixture.ProtocolData))]
+    public async Task SendRawWithResponse_Tcp_ShouldReturnRawPayloads(Protocol 
protocol)
+    {
+        var client = await Fixture.CreateAuthenticatedClient(protocol);
+
+        var pingResponse = await client.SendRawWithResponseAsync(1, []);
+        var statsResponse = await client.SendRawWithResponseAsync(10, []);
+
+        pingResponse.ShouldBeEmpty();
+        statsResponse.ShouldNotBeEmpty();
+    }
+
+    [Test]
+    [SkipHttp]
+    
[MethodDataSource<IggyServerFixture>(nameof(IggyServerFixture.ProtocolData))]
+    public async Task 
SendRawWithResponse_Tcp_ShouldRejectSessionControlCodes(Protocol protocol)
+    {
+        var client = await Fixture.CreateAuthenticatedClient(protocol);
+
+        foreach (var code in new uint[] { 38, 39, 40, 44, 45 })
+        {
+            var exception = await 
Should.ThrowAsync<IggyInvalidStatusCodeException>(
+                () => client.SendRawWithResponseAsync(code, []));
+            exception.StatusCode.ShouldBe(3);
+        }
+    }
+
+    [Test]
+    [SkipHttp]
+    
[MethodDataSource<IggyServerFixture>(nameof(IggyServerFixture.ProtocolData))]
+    public async Task 
SendRawWithResponse_Tcp_ShouldPropagateServerError(Protocol protocol)
+    {
+        var client = await Fixture.CreateAuthenticatedClient(protocol);
+
+        var exception = await 
Should.ThrowAsync<IggyInvalidStatusCodeException>(
+            () => client.SendRawWithResponseAsync(60_000, []));
+
+        exception.StatusCode.ShouldBe(3);
+    }
+
+    [Test]
+    [SkipTcp]
+    
[MethodDataSource<IggyServerFixture>(nameof(IggyServerFixture.ProtocolData))]
+    public async Task 
SendRawWithResponse_Http_ShouldThrowFeatureUnavailable(Protocol protocol)
+    {
+        var client = await Fixture.CreateAuthenticatedClient(protocol);
+
+        await Should.ThrowAsync<FeatureUnavailableException>(
+            () => client.SendRawWithResponseAsync(1, []));
+    }
+}
diff --git a/foreign/csharp/Iggy_SDK/IggyClient/IIggyClient.cs 
b/foreign/csharp/Iggy_SDK/IggyClient/IIggyClient.cs
index bcc7a0a26..0cf6cf276 100644
--- a/foreign/csharp/Iggy_SDK/IggyClient/IIggyClient.cs
+++ b/foreign/csharp/Iggy_SDK/IggyClient/IIggyClient.cs
@@ -58,4 +58,17 @@ public interface IIggyClient : IIggyPublisher, IIggyStream, 
IIggyTopic, IIggyCon
     /// </summary>
     /// <returns>The current address of the client.</returns>
     string GetCurrentAddress();
+
+    /// <summary>
+    ///     Sends a command code with a payload and returns the raw response 
bytes.
+    /// </summary>
+    /// <remarks>
+    ///     Session-control codes are rejected with an invalid-command error.
+    ///     HTTP clients report that this operation is unavailable.
+    /// </remarks>
+    /// <param name="code">The numeric command code to send.</param>
+    /// <param name="payload">The request payload.</param>
+    /// <param name="token">The cancellation token to cancel the 
operation.</param>
+    /// <returns>A task that represents the asynchronous operation and returns 
the raw response payload bytes.</returns>
+    Task<byte[]> SendRawWithResponseAsync(uint code, byte[] payload, 
CancellationToken token = default);
 }
diff --git 
a/foreign/csharp/Iggy_SDK/IggyClient/Implementations/HttpMessageStream.cs 
b/foreign/csharp/Iggy_SDK/IggyClient/Implementations/HttpMessageStream.cs
index 96bd38520..75a806e90 100644
--- a/foreign/csharp/Iggy_SDK/IggyClient/Implementations/HttpMessageStream.cs
+++ b/foreign/csharp/Iggy_SDK/IggyClient/Implementations/HttpMessageStream.cs
@@ -525,6 +525,19 @@ public class HttpMessageStream : IIggyClient
         return [];
     }
 
+    /// <summary>
+    ///     This method is only supported in TCP protocol
+    /// </summary>
+    /// <param name="code">The numeric command code to send.</param>
+    /// <param name="payload">The opaque request payload.</param>
+    /// <param name="token">The cancellation token to cancel the 
operation.</param>
+    /// <returns>A task representing the asynchronous operation.</returns>
+    /// <exception cref="FeatureUnavailableException"></exception>
+    public Task<byte[]> SendRawWithResponseAsync(uint code, byte[] payload, 
CancellationToken token = default)
+    {
+        throw new FeatureUnavailableException();
+    }
+
     /// <inheritdoc />
     public Task ConnectAsync(CancellationToken token = default)
     {
diff --git 
a/foreign/csharp/Iggy_SDK/IggyClient/Implementations/TcpMessageStream.cs 
b/foreign/csharp/Iggy_SDK/IggyClient/Implementations/TcpMessageStream.cs
index 373488ec1..227532d2e 100644
--- a/foreign/csharp/Iggy_SDK/IggyClient/Implementations/TcpMessageStream.cs
+++ b/foreign/csharp/Iggy_SDK/IggyClient/Implementations/TcpMessageStream.cs
@@ -45,6 +45,17 @@ namespace Apache.Iggy.IggyClient.Implementations;
 /// </summary>
 public sealed class TcpMessageStream : IIggyClient
 {
+    private const int InvalidCommandStatus = 3;
+
+    private static readonly HashSet<uint> SessionControlCodes =
+    [
+        CommandCodes.LOGIN_USER_CODE,
+        CommandCodes.LOGOUT_USER_CODE,
+        CommandCodes.LOGIN_REGISTER_CODE,
+        CommandCodes.LOGIN_WITH_PERSONAL_ACCESS_TOKEN_CODE,
+        CommandCodes.LOGIN_REGISTER_WITH_PAT_CODE
+    ];
+
     private readonly IggyClientConfigurator _configuration;
     private readonly EventAggregator<ConnectionStateChangedEventArgs> 
_connectionEvents;
     private readonly SemaphoreSlim _connectionSemaphore;
@@ -582,6 +593,23 @@ public sealed class TcpMessageStream : IIggyClient
         return result.Memory.Span.ToArray();
     }
 
+    /// <inheritdoc />
+    public async Task<byte[]> SendRawWithResponseAsync(uint code, byte[] 
payload, CancellationToken token = default)
+    {
+        if (SessionControlCodes.Contains(code))
+        {
+            throw new IggyInvalidStatusCodeException(InvalidCommandStatus,
+                $"Invalid response status code: {InvalidCommandStatus}");
+        }
+
+        var buffer = new byte[4 + BufferSizes.INITIAL_BYTES_LENGTH + 
payload.Length];
+        TcpMessageStreamHelpers.CreatePayload(buffer, payload, (int)code);
+
+        using IMemoryOwner<byte> result = await SendWithResponseAsync(buffer, 
token);
+
+        return result.Memory.Length <= 1 ? [] : result.Memory.Span.ToArray();
+    }
+
     /// <inheritdoc />
     public async Task ConnectAsync(CancellationToken token = default)
     {
diff --git a/foreign/csharp/Iggy_SDK/Iggy_SDK.csproj 
b/foreign/csharp/Iggy_SDK/Iggy_SDK.csproj
index e4b821d99..776883aac 100644
--- a/foreign/csharp/Iggy_SDK/Iggy_SDK.csproj
+++ b/foreign/csharp/Iggy_SDK/Iggy_SDK.csproj
@@ -27,7 +27,7 @@
         <TargetFrameworks>net8.0;net10.0</TargetFrameworks>
         <AssemblyName>Apache.Iggy</AssemblyName>
         <RootNamespace>Apache.Iggy</RootNamespace>
-        <Version>0.8.1-edge.3</Version>
+        <Version>0.8.1-edge.4</Version>
         <GenerateDocumentationFile>true</GenerateDocumentationFile>
     </PropertyGroup>
 
diff --git a/foreign/csharp/Iggy_SDK/Utils/CommandCodes.cs 
b/foreign/csharp/Iggy_SDK/Utils/CommandCodes.cs
index 2b2beeb0e..8922d37d6 100644
--- a/foreign/csharp/Iggy_SDK/Utils/CommandCodes.cs
+++ b/foreign/csharp/Iggy_SDK/Utils/CommandCodes.cs
@@ -35,10 +35,12 @@ internal static class CommandCodes
     internal const int CHANGE_PASSWORD_CODE = 37;
     internal const int LOGIN_USER_CODE = 38;
     internal const int LOGOUT_USER_CODE = 39;
+    internal const int LOGIN_REGISTER_CODE = 40;
     internal const int GET_PERSONAL_ACCESS_TOKENS_CODE = 41;
     internal const int CREATE_PERSONAL_ACCESS_TOKEN_CODE = 42;
     internal const int DELETE_PERSONAL_ACCESS_TOKEN_CODE = 43;
     internal const int LOGIN_WITH_PERSONAL_ACCESS_TOKEN_CODE = 44;
+    internal const int LOGIN_REGISTER_WITH_PAT_CODE = 45;
     internal const int POLL_MESSAGES_CODE = 100;
     internal const int SEND_MESSAGES_CODE = 101;
     internal const int FLUSH_UNSAVED_BUFFER_CODE = 102;
diff --git a/foreign/go/client/tcp/tcp_core.go 
b/foreign/go/client/tcp/tcp_core.go
index 8952fce1a..425976cd0 100644
--- a/foreign/go/client/tcp/tcp_core.go
+++ b/foreign/go/client/tcp/tcp_core.go
@@ -305,6 +305,38 @@ func (c *IggyTcpClient) do(ctx context.Context, cmd 
command.Command) ([]byte, er
        return resp, err
 }
 
+// SendRawWithResponse sends a command code and payload and returns the raw 
response body.
+// Session-control codes return ierror.ErrInvalidCommand without writing to 
the connection.
+func (c *IggyTcpClient) SendRawWithResponse(ctx context.Context, code uint32, 
payload []byte) ([]byte, error) {
+       if isSessionControlCode(code) {
+               return nil, ierror.ErrInvalidCommand
+       }
+
+       bp := acquireRequestBuf()
+       defer releaseRequestBuf(bp)
+
+       buf := append((*bp)[:0], 0, 0, 0, 0, 0, 0, 0, 0)
+       binary.LittleEndian.PutUint32(buf[4:8], code)
+       buf = append(buf, payload...)
+       binary.LittleEndian.PutUint32(buf[0:4], uint32(len(buf)-4))
+       *bp = buf
+
+       return c.sendWireAndFetchResponse(ctx, buf)
+}
+
+func isSessionControlCode(code uint32) bool {
+       switch code {
+       case uint32(command.LoginUserCode),
+               uint32(command.LogoutUserCode),
+               uint32(command.LoginRegisterCode),
+               uint32(command.LoginWithAccessTokenCode),
+               uint32(command.LoginRegisterWithPATCode):
+               return true
+       default:
+               return false
+       }
+}
+
 // encodeWireRequest writes the wire-format request (4-byte length, 4-byte
 // code, then body) into buf, growing it as needed. The length prefix is
 // written from the realized body length, so a buggy or unimplemented
diff --git a/foreign/go/client/tcp/tcp_core_test.go 
b/foreign/go/client/tcp/tcp_core_test.go
index f996f0298..0264fb1ee 100644
--- a/foreign/go/client/tcp/tcp_core_test.go
+++ b/foreign/go/client/tcp/tcp_core_test.go
@@ -30,6 +30,7 @@ import (
 
        iggcon "github.com/apache/iggy/foreign/go/contracts"
        ierror "github.com/apache/iggy/foreign/go/errors"
+       "github.com/apache/iggy/foreign/go/internal/command"
 )
 
 // emptyWireReq is an 8-byte wire payload for a zero-code request with empty 
body:
@@ -383,3 +384,106 @@ func TestDisconnect_ShutdownClientIsNotResurrected(t 
*testing.T) {
                t.Errorf("got %v, want %v", err, ierror.ErrClientShutdown)
        }
 }
+
+func TestSendRawWithResponse_FrameLayoutAndSuccess(t *testing.T) {
+       c, serverConn := newTestClient(t)
+       const code = uint32(60_000)
+       payload := []byte{0xAA, 0xBB, 0xCC}
+       body := []byte("opaque response")
+
+       type capturedFrame struct {
+               length uint32
+               code   uint32
+               body   []byte
+       }
+       captured := make(chan capturedFrame, 1)
+       go func() {
+               var lengthBuf [RequestInitialBytesLength]byte
+               if _, err := serverConn.Read(lengthBuf[:]); err != nil {
+                       t.Errorf("server: read request length: %v", err)
+                       return
+               }
+               length := binary.LittleEndian.Uint32(lengthBuf[:])
+               req := make([]byte, length)
+               if _, err := serverConn.Read(req); err != nil {
+                       t.Errorf("server: read request body: %v", err)
+                       return
+               }
+               captured <- capturedFrame{
+                       length: length,
+                       code:   binary.LittleEndian.Uint32(req[:4]),
+                       body:   req[4:],
+               }
+
+               resp := make([]byte, 8+len(body))
+               binary.LittleEndian.PutUint32(resp[0:4], 0)
+               binary.LittleEndian.PutUint32(resp[4:8], uint32(len(body)))
+               copy(resp[8:], body)
+               if _, err := serverConn.Write(resp); err != nil {
+                       t.Errorf("server: write response: %v", err)
+               }
+       }()
+
+       result, err := c.SendRawWithResponse(context.Background(), code, 
payload)
+       if err != nil {
+               t.Fatalf("unexpected error: %v", err)
+       }
+       if string(result) != string(body) {
+               t.Errorf("got response %q, want %q", result, body)
+       }
+
+       frame := <-captured
+       wantLength := uint32(4 + len(payload))
+       if frame.length != wantLength {
+               t.Errorf("got length prefix=%d, want %d", frame.length, 
wantLength)
+       }
+       if frame.code != code {
+               t.Errorf("got code=%d, want %d", frame.code, code)
+       }
+       if !bytes.Equal(frame.body, payload) {
+               t.Errorf("got payload=%v, want %v", frame.body, payload)
+       }
+}
+
+func TestSendRawWithResponse_ErrorStatus(t *testing.T) {
+       c, serverConn := newTestClient(t)
+
+       go serverRespond(t, serverConn, uint32(ierror.InvalidCommandCode), nil)
+
+       _, err := c.SendRawWithResponse(context.Background(), 60_000, nil)
+       if !errors.Is(err, ierror.ErrInvalidCommand) {
+               t.Errorf("got %v, want %v", err, ierror.ErrInvalidCommand)
+       }
+}
+
+func TestSendRawWithResponse_SessionControlGuard(t *testing.T) {
+       c, serverConn := newTestClient(t)
+
+       wrote := make(chan struct{})
+       go func() {
+               buf := make([]byte, 1)
+               if _, err := serverConn.Read(buf); err == nil {
+                       close(wrote)
+               }
+       }()
+
+       guardedCodes := []uint32{
+               uint32(command.LoginUserCode),
+               uint32(command.LogoutUserCode),
+               uint32(command.LoginRegisterCode),
+               uint32(command.LoginWithAccessTokenCode),
+               uint32(command.LoginRegisterWithPATCode),
+       }
+       for _, guardedCode := range guardedCodes {
+               _, err := c.SendRawWithResponse(context.Background(), 
guardedCode, nil)
+               if !errors.Is(err, ierror.ErrInvalidCommand) {
+                       t.Errorf("code %d: got %v, want %v", guardedCode, err, 
ierror.ErrInvalidCommand)
+               }
+       }
+
+       select {
+       case <-wrote:
+               t.Fatal("expected no bytes to be written to the wire for 
session-control codes")
+       case <-time.After(50 * time.Millisecond):
+       }
+}
diff --git a/foreign/go/contracts/client.go b/foreign/go/contracts/client.go
index 144cc17e3..68efe8d1a 100644
--- a/foreign/go/contracts/client.go
+++ b/foreign/go/contracts/client.go
@@ -300,4 +300,8 @@ type Client interface {
        // GetClient get the info about a specific client by unique ID (not to 
be confused with the user).
        // Authentication is required, and the permission to read the server 
info.
        GetClient(ctx context.Context, clientId uint32) (*ClientInfoDetails, 
error)
+
+       // SendRawWithResponse sends a command code and payload and returns the 
raw response body.
+       // Session-control codes return ierror.ErrInvalidCommand without 
writing to the connection.
+       SendRawWithResponse(ctx context.Context, code uint32, payload []byte) 
([]byte, error)
 }
diff --git a/foreign/go/contracts/version.go b/foreign/go/contracts/version.go
index 5733ff7a5..f6d9eb82a 100644
--- a/foreign/go/contracts/version.go
+++ b/foreign/go/contracts/version.go
@@ -17,4 +17,4 @@
 
 package iggcon
 
-const Version = "0.8.1-edge.1"
+const Version = "0.8.1-edge.2"
diff --git a/foreign/go/internal/command/code.go 
b/foreign/go/internal/command/code.go
index 644abf2dd..a2648af5a 100644
--- a/foreign/go/internal/command/code.go
+++ b/foreign/go/internal/command/code.go
@@ -36,10 +36,12 @@ const (
        ChangePasswordCode       Code = 37
        LoginUserCode            Code = 38
        LogoutUserCode           Code = 39
+       LoginRegisterCode        Code = 40
        GetAccessTokensCode      Code = 41
        CreateAccessTokenCode    Code = 42
        DeleteAccessTokenCode    Code = 43
        LoginWithAccessTokenCode Code = 44
+       LoginRegisterWithPATCode Code = 45
        PollMessagesCode         Code = 100
        SendMessagesCode         Code = 101
        GetOffsetCode            Code = 120
diff --git a/foreign/java/gradle.properties b/foreign/java/gradle.properties
index a6847759f..5339adf06 100644
--- a/foreign/java/gradle.properties
+++ b/foreign/java/gradle.properties
@@ -15,5 +15,5 @@
 # specific language governing permissions and limitations
 # under the License.
 
-version=0.8.1-SNAPSHOT
+version=0.8.2-SNAPSHOT
 group=org.apache.iggy
diff --git 
a/foreign/java/java-sdk/src/main/java/org/apache/iggy/client/async/tcp/AsyncIggyTcpClient.java
 
b/foreign/java/java-sdk/src/main/java/org/apache/iggy/client/async/tcp/AsyncIggyTcpClient.java
index 9a9738db5..1e6625280 100644
--- 
a/foreign/java/java-sdk/src/main/java/org/apache/iggy/client/async/tcp/AsyncIggyTcpClient.java
+++ 
b/foreign/java/java-sdk/src/main/java/org/apache/iggy/client/async/tcp/AsyncIggyTcpClient.java
@@ -19,6 +19,7 @@
 
 package org.apache.iggy.client.async.tcp;
 
+import io.netty.buffer.Unpooled;
 import org.apache.iggy.IggyVersion;
 import org.apache.iggy.client.async.ConsumerGroupsClient;
 import org.apache.iggy.client.async.ConsumerOffsetsClient;
@@ -33,6 +34,8 @@ import 
org.apache.iggy.client.async.tcp.AsyncTcpConnection.TCPConnectionPoolConf
 import org.apache.iggy.config.RetryPolicy;
 import org.apache.iggy.exception.IggyMissingCredentialsException;
 import org.apache.iggy.exception.IggyNotConnectedException;
+import org.apache.iggy.exception.IggyServerException;
+import org.apache.iggy.serde.CommandCode;
 import org.apache.iggy.user.IdentityInfo;
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
@@ -94,6 +97,7 @@ import java.util.concurrent.CompletableFuture;
  */
 public class AsyncIggyTcpClient {
 
+    private static final int INVALID_COMMAND_ERROR_CODE = 3;
     private static final Logger log = 
LoggerFactory.getLogger(AsyncIggyTcpClient.class);
 
     private final String host;
@@ -217,6 +221,39 @@ public class AsyncIggyTcpClient {
         return usersClient.login(username.get(), password.get());
     }
 
+    /**
+     * Sends a command code and payload and returns the raw response payload.
+     *
+     * <p>Session-control codes complete the returned future with an 
invalid-command error.
+     *
+     * @param code the command code
+     * @param payload the command payload
+     * @return a future containing the raw response payload
+     * @throws IggyNotConnectedException if {@link #connect()} has not been 
called
+     */
+    public CompletableFuture<byte[]> sendRawWithResponse(int code, byte[] 
payload) {
+        if (isSessionControlCode(code)) {
+            return CompletableFuture.failedFuture(
+                    
IggyServerException.fromTcpResponse(INVALID_COMMAND_ERROR_CODE, new byte[0]));
+        }
+        if (connection == null) {
+            throw new IggyNotConnectedException();
+        }
+
+        return connection.send(code, 
Unpooled.wrappedBuffer(payload)).thenApply(response -> {
+            try {
+                if (response.readableBytes() <= 1) {
+                    return new byte[0];
+                }
+                byte[] responsePayload = new byte[response.readableBytes()];
+                response.readBytes(responsePayload);
+                return responsePayload;
+            } finally {
+                response.release();
+            }
+        });
+    }
+
     /**
      * Returns the async users client for authentication operations.
      *
@@ -348,4 +385,12 @@ public class AsyncIggyTcpClient {
         }
         return CompletableFuture.completedFuture(null);
     }
+
+    private static boolean isSessionControlCode(int code) {
+        return code == CommandCode.User.LOGIN.getValue()
+                || code == CommandCode.User.LOGOUT.getValue()
+                || code == CommandCode.User.LOGIN_REGISTER.getValue()
+                || code == CommandCode.PersonalAccessToken.LOGIN.getValue()
+                || code == 
CommandCode.PersonalAccessToken.LOGIN_REGISTER.getValue();
+    }
 }
diff --git 
a/foreign/java/java-sdk/src/main/java/org/apache/iggy/client/blocking/IggyBaseClient.java
 
b/foreign/java/java-sdk/src/main/java/org/apache/iggy/client/blocking/IggyBaseClient.java
index b0cf1cd65..9aaa971d2 100644
--- 
a/foreign/java/java-sdk/src/main/java/org/apache/iggy/client/blocking/IggyBaseClient.java
+++ 
b/foreign/java/java-sdk/src/main/java/org/apache/iggy/client/blocking/IggyBaseClient.java
@@ -21,6 +21,18 @@ package org.apache.iggy.client.blocking;
 
 public interface IggyBaseClient {
 
+    /**
+     * Sends a command code and payload and returns the raw response payload.
+     *
+     * <p>Session-control codes are rejected with an invalid-command error. 
HTTP clients report
+     * that this operation is unsupported.
+     *
+     * @param code the command code
+     * @param payload the command payload
+     * @return the raw response payload
+     */
+    byte[] sendRawWithResponse(int code, byte[] payload);
+
     SystemClient system();
 
     StreamsClient streams();
diff --git 
a/foreign/java/java-sdk/src/main/java/org/apache/iggy/client/blocking/http/IggyHttpClient.java
 
b/foreign/java/java-sdk/src/main/java/org/apache/iggy/client/blocking/http/IggyHttpClient.java
index bb532394f..bdb22f210 100644
--- 
a/foreign/java/java-sdk/src/main/java/org/apache/iggy/client/blocking/http/IggyHttpClient.java
+++ 
b/foreign/java/java-sdk/src/main/java/org/apache/iggy/client/blocking/http/IggyHttpClient.java
@@ -31,6 +31,7 @@ import org.apache.iggy.client.blocking.SystemClient;
 import org.apache.iggy.client.blocking.TopicsClient;
 import org.apache.iggy.client.blocking.UsersClient;
 import org.apache.iggy.exception.IggyMissingCredentialsException;
+import org.apache.iggy.exception.IggyOperationNotSupportedException;
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
 
@@ -110,6 +111,12 @@ public class IggyHttpClient implements IggyBaseClient, 
Closeable {
         usersClient.login(username.get(), password.get());
     }
 
+    /** {@inheritDoc} */
+    @Override
+    public byte[] sendRawWithResponse(int code, byte[] payload) {
+        throw new IggyOperationNotSupportedException("sendRawWithResponse", 
"HTTP");
+    }
+
     @Override
     public SystemClient system() {
         return systemClient;
diff --git 
a/foreign/java/java-sdk/src/main/java/org/apache/iggy/client/blocking/tcp/IggyTcpClient.java
 
b/foreign/java/java-sdk/src/main/java/org/apache/iggy/client/blocking/tcp/IggyTcpClient.java
index e3b8c03d8..6816fe42e 100644
--- 
a/foreign/java/java-sdk/src/main/java/org/apache/iggy/client/blocking/tcp/IggyTcpClient.java
+++ 
b/foreign/java/java-sdk/src/main/java/org/apache/iggy/client/blocking/tcp/IggyTcpClient.java
@@ -93,6 +93,12 @@ public class IggyTcpClient implements IggyBaseClient, 
Closeable {
         return FutureUtil.resolve(asyncClient.login());
     }
 
+    /** {@inheritDoc} */
+    @Override
+    public byte[] sendRawWithResponse(int code, byte[] payload) {
+        return FutureUtil.resolve(asyncClient.sendRawWithResponse(code, 
payload));
+    }
+
     @Override
     public void close() {
         FutureUtil.resolve(asyncClient.close());
diff --git 
a/foreign/java/java-sdk/src/main/java/org/apache/iggy/serde/CommandCode.java 
b/foreign/java/java-sdk/src/main/java/org/apache/iggy/serde/CommandCode.java
index e9f1f7905..236394707 100644
--- a/foreign/java/java-sdk/src/main/java/org/apache/iggy/serde/CommandCode.java
+++ b/foreign/java/java-sdk/src/main/java/org/apache/iggy/serde/CommandCode.java
@@ -55,7 +55,8 @@ public interface CommandCode {
         UPDATE_PERMISSIONS(36),
         CHANGE_PASSWORD(37),
         LOGIN(38),
-        LOGOUT(39);
+        LOGOUT(39),
+        LOGIN_REGISTER(40);
 
         private final int value;
 
@@ -73,7 +74,8 @@ public interface CommandCode {
         GET_ALL(41),
         CREATE(42),
         DELETE(43),
-        LOGIN(44);
+        LOGIN(44),
+        LOGIN_REGISTER(45);
 
         private final int value;
 
diff --git 
a/foreign/java/java-sdk/src/main/java/org/apache/iggy/client/blocking/IggyBaseClient.java
 
b/foreign/java/java-sdk/src/test/java/org/apache/iggy/client/blocking/http/RawCommandHttpClientTest.java
similarity index 61%
copy from 
foreign/java/java-sdk/src/main/java/org/apache/iggy/client/blocking/IggyBaseClient.java
copy to 
foreign/java/java-sdk/src/test/java/org/apache/iggy/client/blocking/http/RawCommandHttpClientTest.java
index b0cf1cd65..06551ad16 100644
--- 
a/foreign/java/java-sdk/src/main/java/org/apache/iggy/client/blocking/IggyBaseClient.java
+++ 
b/foreign/java/java-sdk/src/test/java/org/apache/iggy/client/blocking/http/RawCommandHttpClientTest.java
@@ -17,25 +17,20 @@
  * under the License.
  */
 
-package org.apache.iggy.client.blocking;
+package org.apache.iggy.client.blocking.http;
 
-public interface IggyBaseClient {
+import org.apache.iggy.exception.IggyOperationNotSupportedException;
+import org.junit.jupiter.api.Test;
 
-    SystemClient system();
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
 
-    StreamsClient streams();
+class RawCommandHttpClientTest {
 
-    UsersClient users();
+    @Test
+    void shouldRejectRawCommands() {
+        var client = new IggyHttpClient("http://127.0.0.1:3000";);
 
-    TopicsClient topics();
-
-    PartitionsClient partitions();
-
-    ConsumerGroupsClient consumerGroups();
-
-    ConsumerOffsetsClient consumerOffsets();
-
-    MessagesClient messages();
-
-    PersonalAccessTokensClient personalAccessTokens();
+        assertThatThrownBy(() -> client.sendRawWithResponse(1, new byte[0]))
+                .isInstanceOf(IggyOperationNotSupportedException.class);
+    }
 }
diff --git 
a/foreign/java/java-sdk/src/test/java/org/apache/iggy/client/blocking/tcp/RawCommandTcpClientTest.java
 
b/foreign/java/java-sdk/src/test/java/org/apache/iggy/client/blocking/tcp/RawCommandTcpClientTest.java
new file mode 100644
index 000000000..eceb57e78
--- /dev/null
+++ 
b/foreign/java/java-sdk/src/test/java/org/apache/iggy/client/blocking/tcp/RawCommandTcpClientTest.java
@@ -0,0 +1,67 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you 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 org.apache.iggy.client.blocking.tcp;
+
+import org.apache.iggy.client.blocking.IggyBaseClient;
+import org.apache.iggy.client.blocking.IntegrationTest;
+import org.apache.iggy.exception.IggyErrorCode;
+import org.apache.iggy.exception.IggyServerException;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+
+class RawCommandTcpClientTest extends IntegrationTest {
+
+    @Override
+    protected IggyBaseClient getClient() {
+        return TcpClientFactory.create(serverHost(), serverTcpPort());
+    }
+
+    @BeforeEach
+    void authenticate() {
+        login();
+    }
+
+    @Test
+    void shouldReturnRawResponsePayloads() {
+        assertThat(client.sendRawWithResponse(1, new byte[0])).isEmpty();
+        assertThat(client.sendRawWithResponse(10, new byte[0])).isNotEmpty();
+    }
+
+    @Test
+    void shouldRejectAllSessionControlCodes() {
+        for (int code : new int[] {38, 39, 40, 44, 45}) {
+            assertThatThrownBy(() -> client.sendRawWithResponse(code, new 
byte[0]))
+                    .isInstanceOf(IggyServerException.class)
+                    .extracting(exception -> ((IggyServerException) 
exception).getErrorCode())
+                    .isEqualTo(IggyErrorCode.INVALID_COMMAND);
+        }
+    }
+
+    @Test
+    void shouldPropagateUnknownCommandError() {
+        assertThatThrownBy(() -> client.sendRawWithResponse(60_000, new 
byte[0]))
+                .isInstanceOf(IggyServerException.class)
+                .extracting(exception -> ((IggyServerException) 
exception).getErrorCode())
+                .isEqualTo(IggyErrorCode.INVALID_COMMAND);
+    }
+}
diff --git a/foreign/node/package-lock.json b/foreign/node/package-lock.json
index 67fb8a3e2..afe1087ed 100644
--- a/foreign/node/package-lock.json
+++ b/foreign/node/package-lock.json
@@ -1,12 +1,12 @@
 {
   "name": "apache-iggy",
-  "version": "0.8.1-edge.1",
+  "version": "0.8.1-edge.2",
   "lockfileVersion": 3,
   "requires": true,
   "packages": {
     "": {
       "name": "apache-iggy",
-      "version": "0.8.1-edge.1",
+      "version": "0.8.1-edge.2",
       "license": "Apache-2.0",
       "dependencies": {
         "debug": "4.4.3",
diff --git a/foreign/node/package.json b/foreign/node/package.json
index 8cc59ca73..339cec683 100644
--- a/foreign/node/package.json
+++ b/foreign/node/package.json
@@ -1,7 +1,7 @@
 {
   "name": "apache-iggy",
   "type": "module",
-  "version": "0.8.1-edge.1",
+  "version": "0.8.1-edge.2",
   "description": "Official Apache Iggy NodeJS SDK",
   "keywords": [
     "iggy",
diff --git a/foreign/node/src/bdd/raw.ts b/foreign/node/src/bdd/raw.ts
new file mode 100644
index 000000000..9eaa96e14
--- /dev/null
+++ b/foreign/node/src/bdd/raw.ts
@@ -0,0 +1,48 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements.  See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership.  The ASF licenses this file
+// to you 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.
+
+import assert from 'node:assert/strict';
+import { Then, When } from '@cucumber/cucumber';
+import type { TestWorld } from './world.js';
+
+When(
+  'I send a raw command with code {int} and an empty payload',
+  async function (this: TestWorld, code: number) {
+    try {
+      this.rawResponse = await this.client.sendRawWithResponse(code, 
Buffer.alloc(0));
+      this.rawError = undefined;
+    } catch (error) {
+      this.rawResponse = undefined;
+      this.rawError = error instanceof Error ? error : new 
Error(String(error));
+    }
+  }
+);
+
+Then('the raw command should succeed with an empty response', function (this: 
TestWorld) {
+  assert.equal(this.rawError, undefined);
+  assert.deepEqual(this.rawResponse, Buffer.alloc(0));
+});
+
+Then('the raw command should succeed with a non-empty response', function 
(this: TestWorld) {
+  assert.equal(this.rawError, undefined);
+  assert.ok(this.rawResponse && this.rawResponse.length > 0);
+});
+
+Then('the raw command should fail with an invalid command error', function 
(this: TestWorld) {
+  assert.equal(this.rawResponse, undefined);
+  assert.match(this.rawError?.message ?? '', /code: 3, message: Invalid 
command/);
+});
diff --git a/foreign/node/src/bdd/world.ts b/foreign/node/src/bdd/world.ts
index 8d84f773e..aeade3c04 100644
--- a/foreign/node/src/bdd/world.ts
+++ b/foreign/node/src/bdd/world.ts
@@ -26,5 +26,7 @@ export interface TestWorld {
   stream: Stream,
   topic: Topic,
   sendMessages: CreateMessage[],
-  polledMessages: Message[]
+  polledMessages: Message[],
+  rawResponse?: Buffer,
+  rawError?: Error
 };
diff --git a/foreign/node/src/e2e/tcp.raw.e2e.ts 
b/foreign/node/src/e2e/tcp.raw.e2e.ts
new file mode 100644
index 000000000..4f140c489
--- /dev/null
+++ b/foreign/node/src/e2e/tcp.raw.e2e.ts
@@ -0,0 +1,53 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements.  See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership.  The ASF licenses this file
+// to you 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.
+//
+
+import { after, describe, it } from 'node:test';
+import assert from 'node:assert/strict';
+import { getTestClient } from './test-client.utils.js';
+import { COMMAND_CODE } from '../wire/command.code.js';
+
+describe('e2e -> raw', async () => {
+
+  const c = getTestClient();
+
+  it('e2e -> raw::ping', async () => {
+    const response = await c.sendRawWithResponse(COMMAND_CODE.Ping, 
Buffer.alloc(0));
+    assert.deepEqual(response, Buffer.alloc(0));
+  });
+
+  it('e2e -> raw::getStats', async () => {
+    const response = await c.sendRawWithResponse(COMMAND_CODE.GetStats, 
Buffer.alloc(0));
+    assert.ok(response.length > 0);
+  });
+
+  it('e2e -> raw::sessionControlCodeRejectedClientSide', async () => {
+    await assert.rejects(
+      () => c.sendRawWithResponse(COMMAND_CODE.LoginUser, Buffer.alloc(0))
+    );
+  });
+
+  it('e2e -> raw::unknownCodeRejectedByServer', async () => {
+    await assert.rejects(
+      () => c.sendRawWithResponse(60000, Buffer.alloc(0))
+    );
+  });
+
+  after(() => {
+    c.destroy();
+  });
+});
diff --git a/foreign/node/src/wire/command-set.test.ts 
b/foreign/node/src/wire/command-set.test.ts
new file mode 100644
index 000000000..7cca4ea84
--- /dev/null
+++ b/foreign/node/src/wire/command-set.test.ts
@@ -0,0 +1,98 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements.  See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership.  The ASF licenses this file
+// to you 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.
+//
+
+import { describe, it } from 'node:test';
+import assert from 'node:assert/strict';
+import { SimpleClient } from '../client/client.js';
+import type { RawClient } from '../client/client.type.js';
+import { COMMAND_CODE } from './command.code.js';
+
+const mockRawClient = (): RawClient => ({
+  sendCommand: async () => {
+    throw new Error('sendCommand should not be called by the session-control 
guard');
+  },
+  isAuthenticated: true,
+  authenticate: async () => {
+    throw new Error('authenticate should not be called by the session-control 
guard');
+  },
+  destroy: () => {},
+  on: () => {},
+  once: () => {},
+  getReadStream: () => {
+    throw new Error('getReadStream should not be called by the session-control 
guard');
+  },
+});
+
+describe('CommandAPI.sendRawWithResponse', () => {
+
+  describe('session-control guard', () => {
+
+    [
+      COMMAND_CODE.LoginUser,
+      COMMAND_CODE.LogoutUser,
+      COMMAND_CODE.LoginRegister,
+      COMMAND_CODE.LoginWithAccessToken,
+      COMMAND_CODE.LoginRegisterWithAccessToken,
+    ].forEach((code) => {
+      it(`rejects code ${code} before reaching the client provider`, async () 
=> {
+        const client = new SimpleClient(mockRawClient());
+        await assert.rejects(
+          () => client.sendRawWithResponse(code, Buffer.alloc(0)),
+          /code: 3, message: Invalid command/
+        );
+      });
+    });
+
+  });
+
+  it('forwards a custom code and opaque payload to sendCommand', async () => {
+    const customCode = 60_000;
+    const payload = Buffer.from([0xAA, 0xBB, 0xCC]);
+    const expectedResponse = Buffer.from('opaque response');
+    const raw = mockRawClient();
+    raw.sendCommand = async (code, sentPayload) => {
+      assert.equal(code, customCode);
+      assert.deepEqual(sentPayload, payload);
+      return {
+        status: 0,
+        length: expectedResponse.length,
+        data: expectedResponse,
+      };
+    };
+    const client = new SimpleClient(raw);
+    const response = await client.sendRawWithResponse(customCode, payload);
+    assert.deepEqual(response, expectedResponse);
+  });
+
+  it('normalizes a one-byte response to an empty buffer', async () => {
+    const raw = mockRawClient();
+    raw.sendCommand = async () => ({
+      status: 0,
+      length: 1,
+      data: Buffer.from([1]),
+    });
+
+    const response = await new SimpleClient(raw).sendRawWithResponse(
+      COMMAND_CODE.Ping,
+      Buffer.alloc(0)
+    );
+
+    assert.deepEqual(response, Buffer.alloc(0));
+  });
+
+});
diff --git a/foreign/node/src/wire/command-set.ts 
b/foreign/node/src/wire/command-set.ts
index 0388ecfc4..b3c3cc8fd 100644
--- a/foreign/node/src/wire/command-set.ts
+++ b/foreign/node/src/wire/command-set.ts
@@ -18,6 +18,9 @@
 
 import type { ClientProvider } from '../client/client.type.js';
 
+import { COMMAND_CODE } from './command.code.js';
+import { responseError } from './error.utils.js';
+
 import { login } from './session/login.command.js';
 import { logout } from './session/logout.command.js';
 import { loginWithToken } from './session/login-with-token.command.js';
@@ -198,6 +201,15 @@ const clusterAPI = (c: ClientProvider) => ({
 
 type ClusterAPI = ReturnType<typeof clusterAPI>;
 
+const SESSION_CONTROL_CODES = new Set([
+  COMMAND_CODE.LoginUser,
+  COMMAND_CODE.LogoutUser,
+  COMMAND_CODE.LoginRegister,
+  COMMAND_CODE.LoginWithAccessToken,
+  COMMAND_CODE.LoginRegisterWithAccessToken,
+]);
+
+const INVALID_COMMAND_ERROR_CODE = 3;
 
 export abstract class AbstractAPI {
   clientProvider: ClientProvider;
@@ -225,4 +237,20 @@ export abstract class CommandAPI extends AbstractAPI {
   constructor(c: ClientProvider) {
     super(c);
   }
-};
+
+  /**
+   * Sends a command code with a payload and returns the raw response payload.
+   * Session-control codes are rejected with an invalid-command error.
+   *
+   * @param code - Command code to send
+   * @param payload - Raw command payload
+   * @returns Raw response payload
+   */
+  async sendRawWithResponse(code: number, payload: Buffer): Promise<Buffer> {
+    if (SESSION_CONTROL_CODES.has(code))
+      throw responseError(code, INVALID_COMMAND_ERROR_CODE);
+
+    const response = await (await this.clientProvider()).sendCommand(code, 
payload);
+    return response.length <= 1 ? Buffer.alloc(0) : response.data;
+  }
+}
diff --git a/foreign/node/src/wire/command.code.ts 
b/foreign/node/src/wire/command.code.ts
index e61d4a1d1..bfdcafcba 100644
--- a/foreign/node/src/wire/command.code.ts
+++ b/foreign/node/src/wire/command.code.ts
@@ -35,10 +35,12 @@ export const COMMAND_CODE = {
   ChangePassword: 37,
   LoginUser: 38,
   LogoutUser: 39,
+  LoginRegister: 40,
   GetAccessTokens: 41,
   CreateAccessToken: 42,
   DeleteAccessToken: 43,
   LoginWithAccessToken: 44,
+  LoginRegisterWithAccessToken: 45,
   PollMessages: 100,
   SendMessages: 101,
   FlushUnsavedBuffers: 102,
diff --git a/foreign/php/Cargo.toml b/foreign/php/Cargo.toml
index 0384b6563..1c8245e3e 100644
--- a/foreign/php/Cargo.toml
+++ b/foreign/php/Cargo.toml
@@ -17,7 +17,7 @@
 
 [package]
 name = "iggy-php"
-version = "0.1.0"
+version = "0.1.1"
 edition = "2024"
 authors = ["Iggy Committers <[email protected]>"]
 license = "Apache-2.0"
diff --git a/foreign/php/README.md b/foreign/php/README.md
index ff6e4c6e0..a339d1f3c 100644
--- a/foreign/php/README.md
+++ b/foreign/php/README.md
@@ -214,6 +214,8 @@ 
iggy+tcp://iggy:[email protected]:8090?tls=true&tls_domain=localhost&tls_ca_file=/p
   be read repeatedly.
 - Large unsigned values that can overflow PHP integers, such as message 
checksums,
   are returned as decimal strings.
+- `Iggy\Client::sendRawWithResponse(int $code, string $payload): string` sends 
a
+  command code and payload and returns the raw response body.
 - `Iggy\Client` is synchronous and blocks the current PHP thread.
 - The extension owns a lazy global Tokio runtime. Do not call `pcntl_fork()` 
after
   the first Iggy SDK call; the child process inherits file descriptors but not
diff --git a/foreign/php/iggy-php.stubs.php b/foreign/php/iggy-php.stubs.php
index b06d3ec92..f4be69725 100644
--- a/foreign/php/iggy-php.stubs.php
+++ b/foreign/php/iggy-php.stubs.php
@@ -217,6 +217,16 @@ namespace Iggy {
          * @return void
          */
         public function sendMessages(mixed $stream, mixed $topic, int 
$partition_id, array $messages): void {}
+
+        /**
+         * Sends a command code with a payload and returns the raw response 
bytes.
+         * Session-control codes return an invalid-command exception.
+         *
+         * @param int $code
+         * @param string $payload
+         * @return string
+         */
+        public function sendRawWithResponse(int $code, string $payload): 
string {}
     }
 
     /**
diff --git a/foreign/php/src/client.rs b/foreign/php/src/client.rs
index 1f32e97d1..730f949d9 100644
--- a/foreign/php/src/client.rs
+++ b/foreign/php/src/client.rs
@@ -17,7 +17,8 @@
 
 use std::{str::FromStr, sync::Arc};
 
-use ext_php_rs::{exception::PhpResult, php_class, php_impl};
+use bytes::Bytes;
+use ext_php_rs::{binary::Binary, exception::PhpResult, php_class, php_impl};
 use iggy::prelude::{
     CompressionAlgorithm, Consumer as RustConsumer, IggyClient as 
RustIggyClient,
     IggyClientBuilder, IggyDuration, IggyExpiry, IggyMessage as RustMessage, 
MaxTopicSize,
@@ -376,6 +377,20 @@ impl IggyClient {
             })
         })
     }
+
+    /// Sends a command code with a payload and returns the raw response bytes.
+    /// Session-control codes return an invalid-command exception.
+    pub fn send_raw_with_response(&self, code: u32, payload: Binary<u8>) -> 
PhpResult<Binary<u8>> {
+        let inner = self.inner.clone();
+
+        runtime().block_on(async move {
+            inner
+                .send_binary_request(code, 
Bytes::from(Vec::<u8>::from(payload)))
+                .await
+                .map(|response| Binary::new(response.to_vec()))
+                .map_err(to_php_exception)
+        })
+    }
 }
 
 fn non_zero_duration_micros(field: &str, micros: u64) -> 
PhpResult<IggyDuration> {
diff --git a/foreign/php/tests/RawCommandTest.php 
b/foreign/php/tests/RawCommandTest.php
new file mode 100644
index 000000000..eca074a23
--- /dev/null
+++ b/foreign/php/tests/RawCommandTest.php
@@ -0,0 +1,77 @@
+<?php
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements.  See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership.  The ASF licenses this file
+// to you 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.
+
+declare(strict_types=1);
+
+use Iggy\Exception\IggyException;
+use PHPUnit\Framework\Attributes\DataProvider;
+use PHPUnit\Framework\Attributes\TestDox;
+use PHPUnit\Framework\TestCase;
+
+final class RawCommandTest extends TestCase
+{
+    private const PING_CODE = 1;
+    private const GET_STATS_CODE = 10;
+    private const UNKNOWN_CODE = 60_000;
+
+    #[TestDox('A raw ping command returns an empty successful response')]
+    public function testRawPingReturnsEmptyResponse(): void
+    {
+        $client = new_client();
+
+        $response = $client->sendRawWithResponse(self::PING_CODE, '');
+
+        assert_same('', $response);
+    }
+
+    #[TestDox('A raw get-stats command returns a non-empty response')]
+    public function testRawGetStatsReturnsNonEmptyResponse(): void
+    {
+        $client = new_client();
+
+        $response = $client->sendRawWithResponse(self::GET_STATS_CODE, '');
+
+        assert_true($response !== '', 'expected a non-empty stats response');
+    }
+
+    #[TestDox('A session-control code is rejected before reaching the server')]
+    #[DataProvider('sessionControlCodes')]
+    public function testRawSessionControlCodeIsRejected(int $code): void
+    {
+        $client = new_client();
+
+        $throwable = assert_throws(static fn () => 
$client->sendRawWithResponse($code, ''));
+
+        assert_instance_of(IggyException::class, $throwable);
+    }
+
+    #[TestDox('An unknown command code is rejected by the server')]
+    public function testRawUnknownCodeIsRejectedByServer(): void
+    {
+        $client = new_client();
+
+        $throwable = assert_throws(static fn () => 
$client->sendRawWithResponse(self::UNKNOWN_CODE, ''));
+
+        assert_instance_of(IggyException::class, $throwable);
+    }
+
+    public static function sessionControlCodes(): array
+    {
+        return [[38], [39], [40], [44], [45]];
+    }
+}
diff --git a/foreign/python/Cargo.toml b/foreign/python/Cargo.toml
index 86138351a..945b6aba9 100644
--- a/foreign/python/Cargo.toml
+++ b/foreign/python/Cargo.toml
@@ -17,7 +17,7 @@
 
 [package]
 name = "apache-iggy"
-version = "0.8.1-dev2"
+version = "0.8.1-dev3"
 edition = "2024"
 authors = ["Iggy Committers <[email protected]>"]
 license = "Apache-2.0"
diff --git a/foreign/python/apache_iggy.pyi b/foreign/python/apache_iggy.pyi
index 5b5366938..facd0961b 100644
--- a/foreign/python/apache_iggy.pyi
+++ b/foreign/python/apache_iggy.pyi
@@ -635,6 +635,25 @@ class IggyClient:
         Creates a new consumer group consumer.
         Returns the consumer or a PyRuntimeError on failure.
         """
+    def send_raw_with_response(
+        self, code: builtins.int, payload: builtins.bytes
+    ) -> collections.abc.Awaitable[bytes]:
+        r"""
+        Send a command code with a payload and return the raw response bytes.
+
+        Session-control codes are rejected client-side. HTTP transport does not
+        support raw binary commands.
+
+        Args:
+            code: Command code as `int`.
+            payload: Request payload as `bytes`.
+
+        Returns:
+            An awaitable that resolves to the raw response `bytes`.
+
+        Raises:
+            PyRuntimeError: If the command cannot be sent or the server 
returns an error.
+        """
 
 @typing.final
 class IggyConsumer:
diff --git a/foreign/python/pyproject.toml b/foreign/python/pyproject.toml
index b36bff18b..785d9248d 100644
--- a/foreign/python/pyproject.toml
+++ b/foreign/python/pyproject.toml
@@ -22,7 +22,7 @@ build-backend = "maturin"
 [project]
 name = "apache-iggy"
 requires-python = ">=3.10"
-version = "0.8.1.dev2"
+version = "0.8.1.dev3"
 description = "Apache Iggy is the persistent message streaming platform 
written in Rust, supporting QUIC, TCP and HTTP transport protocols, capable of 
processing millions of messages per second."
 readme = "README.md"
 license = { file = "LICENSE" }
diff --git a/foreign/python/src/client.rs b/foreign/python/src/client.rs
index 2a36ef333..a2c68db65 100644
--- a/foreign/python/src/client.rs
+++ b/foreign/python/src/client.rs
@@ -15,13 +15,14 @@
 // specific language governing permissions and limitations
 // under the License.
 
+use bytes::Bytes;
 use iggy::prelude::{
     Consumer as RustConsumer, IggyClient as RustIggyClient, IggyMessage as 
RustMessage,
     PollingStrategy as RustPollingStrategy, *,
 };
 use pyo3::PyRef;
 use pyo3::prelude::*;
-use pyo3::types::{PyDelta, PyList, PyType};
+use pyo3::types::{PyBytes, PyDelta, PyList, PyType};
 use pyo3_async_runtimes::tokio::future_into_py;
 use pyo3_stub_gen::define_stub_info_gatherer;
 use pyo3_stub_gen::derive::{gen_stub_pyclass, gen_stub_pymethods};
@@ -823,6 +824,37 @@ impl IggyClient {
             })
         })
     }
+
+    /// Send a command code with a payload and return the raw response bytes.
+    ///
+    /// Session-control codes are rejected client-side. HTTP transport does not
+    /// support raw binary commands.
+    ///
+    /// Args:
+    ///     code: Command code as `int`.
+    ///     payload: Request payload as `bytes`.
+    ///
+    /// Returns:
+    ///     An awaitable that resolves to the raw response `bytes`.
+    ///
+    /// Raises:
+    ///     PyRuntimeError: If the command cannot be sent or the server 
returns an error.
+    
#[gen_stub(override_return_type(type_repr="collections.abc.Awaitable[bytes]", 
imports=("collections.abc")))]
+    fn send_raw_with_response<'a>(
+        &self,
+        py: Python<'a>,
+        code: u32,
+        #[gen_stub(override_type(type_repr = "builtins.bytes"))] payload: 
Vec<u8>,
+    ) -> PyResult<Bound<'a, PyAny>> {
+        let inner = self.inner.clone();
+        future_into_py(py, async move {
+            let response = inner
+                .send_binary_request(code, Bytes::from(payload))
+                .await
+                .map_err(|e| PyErr::new::<pyo3::exceptions::PyRuntimeError, 
_>(e.to_string()))?;
+            Ok(Python::attach(|py| PyBytes::new(py, &response).unbind()))
+        })
+    }
 }
 
 define_stub_info_gatherer!(stub_info);
diff --git a/foreign/python/tests/test_raw_command.py 
b/foreign/python/tests/test_raw_command.py
new file mode 100644
index 000000000..467dd252c
--- /dev/null
+++ b/foreign/python/tests/test_raw_command.py
@@ -0,0 +1,47 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements.  See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership.  The ASF licenses this file
+# to you 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.
+
+import pytest
+
+from apache_iggy import IggyClient
+
+
[email protected]
+async def test_raw_ping_returns_empty_response(iggy_client: IggyClient):
+    response = await iggy_client.send_raw_with_response(1, b"")
+
+    assert response == b""
+
+
[email protected]
+async def test_raw_get_stats_returns_non_empty_response(iggy_client: 
IggyClient):
+    response = await iggy_client.send_raw_with_response(10, b"")
+
+    assert response
+
+
[email protected]
[email protected]("code", [38, 39, 40, 44, 45])
+async def test_raw_session_control_code_is_rejected(iggy_client: IggyClient, 
code: int):
+    with pytest.raises(RuntimeError, match="(?i)invalid command"):
+        await iggy_client.send_raw_with_response(code, b"")
+
+
[email protected]
+async def test_raw_unknown_code_is_rejected_by_server(iggy_client: IggyClient):
+    with pytest.raises(RuntimeError, match="(?i)invalid command"):
+        await iggy_client.send_raw_with_response(60_000, b"")
diff --git a/foreign/python/uv.lock b/foreign/python/uv.lock
index 3beb7ab49..55960c278 100644
--- a/foreign/python/uv.lock
+++ b/foreign/python/uv.lock
@@ -12,7 +12,7 @@ exclude-newer-span = "P7D"
 
 [[package]]
 name = "apache-iggy"
-version = "0.8.1.dev2"
+version = "0.8.1.dev3"
 source = { editable = "." }
 
 [package.optional-dependencies]
diff --git a/raw-command-iggy-sdk.md b/raw-command-iggy-sdk.md
new file mode 100644
index 000000000..004046d21
--- /dev/null
+++ b/raw-command-iggy-sdk.md
@@ -0,0 +1,461 @@
+# Raw command API across Iggy SDKs: implementation plan
+
+Goal: every official Iggy SDK exposes a public method that sends an
+arbitrary command code (`u32`) plus an opaque byte payload to the server
+and returns the raw response bytes. This mirrors what the Rust SDK
+already ships and is the extension point for forked servers that add
+custom command handlers.
+
+## 1. Reference: what Rust already has
+
+### 1.1 API surface
+
+| Layer | Item | Location |
+| --- | --- | --- |
+| Transport trait | `BinaryTransport::send_raw_with_response(&self, code: u32, 
payload: Bytes) -> Result<Bytes, IggyError>` | 
`core/common/src/traits/binary_transport.rs:31` |
+| Trait impls | TCP / QUIC / WebSocket | `core/sdk/src/tcp/tcp_client.rs:150`, 
`core/sdk/src/quic/quic_client.rs:139`, 
`core/sdk/src/websocket/websocket_client.rs:138` |
+| Public user API | `IggyClient::send_binary_request(&self, code: u32, 
payload: Bytes) -> Result<Bytes, IggyError>` (inherent method, no trait import 
needed) | `core/sdk/src/clients/client.rs:210` |
+| HTTP counterpart | `IggyClient::send_http_request(...)` (REST path, binary 
raw send is unavailable over HTTP) | `core/sdk/src/clients/client.rs:224` |
+
+Behavior of `send_binary_request` (the semantics every port must copy):
+
+1. Session-control codes are rejected client-side with
+   `IggyError::InvalidCommand` before hitting the wire. The list is
+   `SESSION_CONTROL_CODES` (`core/sdk/src/clients/client.rs:50`):
+   `LOGIN_USER_CODE = 38`, `LOGOUT_USER_CODE = 39`,
+   `LOGIN_REGISTER_CODE = 40`,
+   `LOGIN_WITH_PERSONAL_ACCESS_TOKEN_CODE = 44`,
+   `LOGIN_REGISTER_WITH_PAT_CODE = 45`. Rationale: session mutation
+   must go through the typed login/logout methods so client session
+   state (and VSR dedup counters) stay correct.
+2. TCP / QUIC / WebSocket dispatch to `send_raw_with_response`.
+3. HTTP transport returns `IggyError::FeatureUnavailable` (there is no
+   binary frame concept over REST).
+4. Under the `vsr` feature, unknown codes are rejected client-side
+   (`operation_for_code`, `core/sdk/src/vsr.rs:147`):
+   unknown -> `InvalidCommand`, replicated code without an operation
+   mapping -> `UnknownReplicatedCommand` (14002). Custom codes are a
+   classic-protocol feature.
+
+### 1.2 Wire format (classic TCP binary protocol, QUIC/WS identical framing)
+
+Request:
+
+```text
+[ length : u32 LE ]  4 bytes, = 4 (code field) + payload.len()
+[ code   : u32 LE ]  4 bytes
+[ payload: N bytes ]
+```
+
+Response:
+
+```text
+[ status : u32 LE ]  4 bytes, 0 = OK
+[ length : u32 LE ]  4 bytes, response payload length
+[ payload: length bytes ]   length <= 1 is treated as empty
+```
+
+Non-zero status maps to `IggyError::from_code(status)`. Source:
+`core/sdk/src/tcp/tcp_client.rs:789-829` and `handle_response` at
+`:324`. Every foreign SDK already implements exactly this framing in
+its own send path, so no wire work is needed anywhere.
+
+### 1.3 Server behavior for arbitrary codes
+
+- Classic server: closed `match frame.code` in
+  `core/server/src/binary/dispatch.rs:150`. An unknown code logs and
+  returns `IggyError::InvalidCommand` (`dispatch.rs:451-454`). Forked
+  servers add match arms there. There is no runtime handler registry.
+- server-ng: code routed at `core/server-ng/src/dispatch.rs:1133`,
+  fallback at `:1244`. The command metadata registry is in
+  `core/binary_protocol/src/dispatch.rs:204` (`lookup_command`).
+- Consequence for tests against a stock server: a raw round-trip can
+  assert (a) known codes work (`PING_CODE = 1` -> empty OK,
+  `GET_STATS_CODE = 10` -> non-empty body) and (b) an unknown code
+  (e.g. `60_000`) returns the invalid-command error.
+
+### 1.4 Existing tests (Rust)
+
+- Integration: `core/integration/tests/sdk/raw.rs`, transport matrix
+  `[Tcp, Quic, Http, WebSocket]` plus a vsr variant. Asserts ping
+  round-trip, non-empty stats body, login code rejected, unknown code
+  rejected under vsr, HTTP -> `FeatureUnavailable`. This file is the
+  canonical template for every other SDK's test.
+- Unit: `core/sdk/src/clients/client.rs:470-497` (HTTP rejection,
+  session-code rejection).
+- BDD: none. Examples: none. (Gaps addressed in sections 4 and 5.)
+
+## 2. Cross-SDK contract
+
+Every SDK adds one public method with language-native casing and types:
+
+| SDK | Signature |
+| --- | --- |
+| Rust | `send_binary_request(code: u32, payload: Bytes) -> Result<Bytes, 
IggyError>` (exists) |
+| Python | `async def send_raw_with_response(code: int, payload: bytes) -> 
bytes` |
+| Node | `sendRawWithResponse(code: number, payload: Buffer): Promise<Buffer>` 
|
+| Java async | `CompletableFuture<byte[]> sendRawWithResponse(int code, byte[] 
payload)` |
+| Java blocking | `byte[] sendRawWithResponse(int code, byte[] payload)` |
+| C# | `Task<byte[]> SendRawWithResponseAsync(uint code, byte[] payload, 
CancellationToken token = default)` |
+| Go | `SendRawWithResponse(ctx context.Context, code uint32, payload []byte) 
([]byte, error)` |
+| PHP | `sendRawWithResponse(int $code, string $payload): string` |
+| C++ | `rust::Vec<uint8_t> send_raw_with_response(uint32_t code, 
rust::Vec<uint8_t> payload)` (via cxx bridge) |
+
+Required semantics, identical everywhere:
+
+1. Success (status 0): return response payload bytes. An empty payload
+   (length <= 1) returns an empty byte value, never null.
+2. Non-zero status: raise/return the SDK's native Iggy error carrying
+   the numeric server error code (each SDK's existing error mapping
+   already does this in its send path. Reuse it instead of inventing new
+   error types).
+3. Reject session-control codes {38, 39, 40, 44, 45} client-side with
+   the SDK's invalid-command error, same rationale as Rust. Define the
+   set as a private constant next to the method.
+4. Where the SDK also has an HTTP transport behind the same public
+   interface (Rust, Java, C#), the HTTP implementation fails with the
+   SDK's feature-unavailable / operation-not-supported error.
+5. Auth: the method goes through the SDK's normal send path, so
+   whatever auto-auth or auth gating the SDK applies to typed commands
+   applies here too. The per-SDK notes document this behavior.
+6. Document the opaque request and response payloads, session-control
+   rejection, and HTTP limitation where applicable. Follow each SDK's
+   existing public API documentation style.
+
+Naming note: the Rust public method is `send_binary_request` while the
+trait method is `send_raw_with_response`. For the ports we standardize
+on the `send_raw_with_response` name (it describes the operation and
+avoids implying an HTTP/binary dichotomy that only Rust and C# have).
+Rust keeps its existing names. Its documentation may note the
+correspondence.
+
+## 3. Per-SDK implementation plans
+
+Ordered roughly by effort (smallest first). All are independent and
+can be done in parallel. Each item lists every file to touch.
+
+### 3.1 Python (`foreign/python/`, pyo3 wrapper over Rust SDK)
+
+State: wraps `iggy::prelude::IggyClient` as `Arc<RustIggyClient>`
+(`foreign/python/src/client.rs:45-49`). Nothing raw is exposed today.
+The Rust seam (`send_binary_request`) is directly reachable.
+
+Changes:
+
+1. `foreign/python/src/client.rs`: add to the
+   `#[gen_stub_pymethods] #[pymethods] impl IggyClient` block (ends
+   `:826`) an async binding following the existing pattern (clone
+   `Arc`, `pyo3_async_runtimes::tokio::future_into_py`):
+   - accept `code: u32, payload: Vec<u8>` (pyo3 converts `bytes`),
+     build `Bytes::from(payload)`,
+   - call `inner.send_binary_request(code, payload).await`, map
+     `IggyError` to `PyRuntimeError` like every other method,
+   - return `PyBytes::new(py, &response)` inside `Python::attach`
+     (pattern: `foreign/python/src/receive_message.rs:37-38`). A bare
+     `Vec<u8>` would surface as `list[int]`,
+   - annotate
+     `#[gen_stub(override_return_type(type_repr = 
"collections.abc.Awaitable[bytes]", imports = ("collections.abc")))]`.
+   Session-code rejection and HTTP handling come for free from the
+   wrapped Rust method.
+2. Regenerate `foreign/python/apache_iggy.pyi` via the `stub_gen` bin
+   (`foreign/python/src/bin/stub_gen.rs`). Do not hand-edit.
+3. No `lib.rs` change (methods on registered classes need no wiring).
+
+Tests: new `foreign/python/tests/test_raw_command.py` following
+`test_message_operations.py` conventions: ping code 1 with the ping
+request payload returns empty bytes. Stats code 10 returns non-empty
+bytes. Login code 38 raises. Unknown code 60000 raises a server error.
+
+### 3.2 PHP (`foreign/php/`, ext-php-rs wrapper over Rust SDK)
+
+State: wraps `Arc<RustIggyClient>` (`foreign/php/src/client.rs:41`).
+no raw surface. Marked experimental but usable end to end.
+
+Changes:
+
+1. `foreign/php/src/client.rs`: add to `#[php_impl] impl IggyClient`:
+   `pub fn send_raw_with_response(&self, code: u32, payload: Binary<u8>) -> 
PhpResult<Binary<u8>>`
+   using `runtime().block_on(...)` around
+   `inner.send_binary_request(code, Bytes::from(Vec::<u8>::from(payload)))`,
+   errors through the existing `to_php_exception` mapper
+   (`foreign/php/src/error.rs:55`). `Binary<u8>` is already used by
+   `send_message.rs:19`. Method surfaces to PHP as
+   `sendRawWithResponse` (ext-php-rs camelCases).
+2. Regenerate `foreign/php/iggy-php.stubs.php` with `cargo php stubs`.
+   CI fails on stub drift (`foreign/php/README.md:39`).
+3. `foreign/php/README.md`: one short API Notes entry.
+
+Tests: extend `foreign/php/tests/IggySdkTest.php` (or a new
+`RawCommandTest.php`) with the same four assertions as Python.
+
+### 3.3 C++ (`foreign/cpp/`, cxx bridge over Rust SDK)
+
+State: usable at the low level (~40 client functions, ~242 e2e test
+cases, BDD wired). Producer/Consumer are stubs but irrelevant here.
+The client layer is complete enough to include. The change is two files.
+
+Changes:
+
+1. `foreign/cpp/src/lib.rs`: add to the `extern "Rust"` block (near
+   `:382`):
+   `fn send_raw_with_response(self: &Client, code: u32, payload: Vec<u8>) -> 
Result<Vec<u8>>;`
+   (cxx has no `Bytes` type, so `Vec<u8>` crosses the FFI).
+2. `foreign/cpp/src/client.rs`: implement on `impl Client` with
+   `RUNTIME.block_on(async { self.inner.send_binary_request(code, 
Bytes::from(payload)).await })`,
+   mapping the error like sibling methods. `bytes` is already a dep
+   (`foreign/cpp/Cargo.toml:30`).
+
+Tests: extend `foreign/cpp/tests/client/low_level_e2e.cpp` with the
+four standard assertions (helpers in `tests/common/test_helpers.hpp`).
+
+### 3.4 Node (`foreign/node/`, pure TypeScript protocol impl)
+
+State: framing and raw send already exist and are even public via
+`getRawClient(cfg).sendCommand(code, payload)`
+(`src/client/client.socket.ts:111`, exported through the barrel), but
+there is no ergonomic method on the `Client` classes and the return is
+the full `{status, length, data}` object.
+
+Changes:
+
+1. `foreign/node/src/wire/command-set.ts`: add to `CommandAPI` (or
+   `AbstractAPI`, `:202-228`):
+
+   ```ts
+   async sendRawWithResponse(code: number, payload: Buffer): Promise<Buffer> {
+     // session-control guard here: throw on {38, 39, 40, 44, 45}
+     const r = await (await this.clientProvider()).sendCommand(code, payload);
+     return r.data;
+   }
+   ```
+
+   `sendCommand` with default `handleResponse = true` already rejects
+   non-zero status via `responseError` and strips the 8-byte header,
+   matching the contract. Method lands on `Client`, `SingleClient`,
+   and `SimpleClient` at once (all extend `CommandAPI`).
+2. Auth note: codes outside `UNLOGGED_COMMAND_CODE`
+   (`src/client/client.socket.ts:35-39`) trigger auto-login first.
+   Document this behavior in the method's JSDoc.
+3. Optional: re-export `COMMAND_CODE` from `src/wire/index.ts` so
+   users can pass named codes (currently not part of the public API).
+
+Tests: unit test beside `src/client/client.utils.test.ts` for the
+guard + framing, plus `foreign/node/src/e2e/tcp.raw.e2e.ts` with the
+four standard assertions (pattern: existing `src/e2e/tcp.system.e2e.ts`).
+
+### 3.5 Go (`foreign/go/`, pure Go protocol impl, TCP only)
+
+State: no public escape hatch. Seam is the unexported
+`do(ctx, cmd)` / `sendWireAndFetchResponse(ctx, wirePayload)` pair in
+`foreign/go/client/tcp/tcp_core.go:292/:333`. Commands live in an
+`internal/` package so users cannot inject custom codes today.
+
+Changes:
+
+1. `foreign/go/contracts/client.go`: add to the `Client` interface
+   (around `:293`):
+   `SendRawWithResponse(ctx context.Context, code uint32, payload []byte) 
([]byte, error)`.
+2. `foreign/go/client/tcp/` (new `tcp_raw.go` or in `tcp_core.go`):
+   implement on `*IggyTcpClient` by building the frame inline the way
+   `encodeWireRequest` (`tcp_core.go:311`) does: acquire pooled buffer
+   (`acquireRequestBuf`, `:241`), 4-byte LE length + 4-byte LE code +
+   payload, then `c.sendWireAndFetchResponse(ctx, buf)` and release.
+   This reuses the existing status/length/body read path unchanged.
+   Add the session-control guard before sending.
+3. `foreign/go/client/iggy_client.go`: no change. `IggyClient` embeds
+   `iggcon.Client` (`:72`) so the method is inherited.
+
+Tests: extend `foreign/go/client/tcp/tcp_core_test.go` using
+`newTestClient` (`:41`, `net.Pipe`) and `serverRespondCapture`
+(`:176`): assert emitted frame layout (length/code/payload), success
+body pass-through, non-zero status -> `ierror.FromCode`, guard codes
+rejected without any write.
+
+### 3.6 C# (`foreign/csharp/`, pure .NET impl, TCP + HTTP)
+
+State: private seam `TcpMessageStream.SendRawAsync` /
+`SendWithResponseAsync` 
(`Iggy_SDK/IggyClient/Implementations/TcpMessageStream.cs:1124/:1072`)
+already does framing, status parsing, error throw, body read. Nothing
+public. `CommandCodes` is `internal` (`Iggy_SDK/Utils/CommandCodes.cs:20`).
+
+Changes:
+
+1. `foreign/csharp/Iggy_SDK/IggyClient/IIggyClient.cs`: add
+   `Task<byte[]> SendRawWithResponseAsync(uint code, byte[] payload, 
CancellationToken token = default);`
+   directly on `IIggyClient` (`:27`), not on a role interface.
+   Precedent for `Task<byte[]>`: `IIggySystem.GetSnapshotAsync`.
+2. `TcpMessageStream.cs`: implement: session-control guard, allocate
+   `new byte[4 + BufferSizes.INITIAL_BYTES_LENGTH + payload.Length]`,
+   `TcpMessageStreamHelpers.CreatePayload(buffer, payload, (int)code)`
+   (`Utils/TcpMessageStreamHelpers.cs:30`). Either cast or widen the
+   helper's `int command` parameter to `uint`), then
+   `using var response = await SendWithResponseAsync(buffer, token);`
+   and return `response.Memory.ToArray()`. Error mapping is already
+   inside `SendRawAsync`.
+3. `HttpMessageStream.cs`: implement the new interface member as
+   `throw new FeatureUnavailableException();` (precedent at
+   `HttpMessageStream.cs:462/:575/:591/:620`). Mandatory or the
+   project will not compile.
+
+Tests: new `foreign/csharp/Iggy_SDK.Tests.Integration/RawCommandTests.cs`
+run against both transports with the existing `SkipHttp`/`SkipTcp`
+attributes: TCP gets the four standard assertions, HTTP asserts
+`FeatureUnavailableException`. Optional unit test for the guard in
+`Iggy_SDK_Tests/`.
+
+### 3.7 Java (`foreign/java/`, Netty async TCP + blocking wrapper + HTTP)
+
+State: `AsyncTcpConnection.send(int commandCode, ByteBuf payload)` is
+public and already returns the response payload with status handling
+(`java-sdk/.../client/async/tcp/AsyncTcpConnection.java:195`), but the
+connection is a private field of `AsyncIggyTcpClient` with no getter,
+so users cannot reach it.
+
+Changes:
+
+1. `java-sdk/.../client/async/tcp/AsyncIggyTcpClient.java`: add
+   `public CompletableFuture<byte[]> sendRawWithResponse(int code, byte[] 
payload)`:
+   session-control guard, null-connection guard like the accessors at
+   `:226-231`, `connection.send(code, Unpooled.wrappedBuffer(payload))`,
+   then `thenApply` copying the `ByteBuf` to `byte[]` and releasing it
+   (pattern: `SystemTcpClient.java:54-60`).
+2. `java-sdk/.../client/blocking/tcp/IggyTcpClient.java`: add
+   `public byte[] sendRawWithResponse(int code, byte[] payload)`
+   delegating via `FutureUtil.resolve(...)` (pattern: `login()`,
+   `:92-94`).
+3. Interface decision (recommended): also add the method to
+   `client/blocking/IggyBaseClient.java:22` so it is reachable through
+   the interface users hold, and implement it in
+   `client/blocking/http/IggyHttpClient.java` as
+   `throw new IggyOperationNotSupportedException(...)` (type exists).
+   This matches Rust and C# behavior. The TCP-only alternative (skip
+   the interface) is smaller but hides the method from anyone coding
+   against `IggyBaseClient`.
+4. Auth caveat to document: `AsyncTcpConnection.send` gates
+   non-login/PING/GET_STATS codes on `authenticated`
+   (`AsyncTcpConnection.java:207-236`). Raw sends require a prior
+   `login()`, consistent with the other SDKs' behavior in practice.
+
+Tests: new 
`java-sdk/src/test/java/org/apache/iggy/client/blocking/tcp/RawCommandTcpClientTest.java`
+using the existing Testcontainers integration base
+(`client/blocking/IntegrationTest.java`), four standard assertions,
+plus an HTTP-side test asserting the unsupported exception if the
+interface route is taken. Async coverage in
+`client/async/AsyncClientIntegrationTest.java` style.
+
+### 3.8 Rust (reference, small follow-ups only)
+
+1. Optional rustdoc touch-up on `send_binary_request` noting it is the
+   canonical raw-command API mirrored by all SDKs.
+2. BDD steps for the new shared feature (section 4), since
+   `bdd/rust/` has no raw coverage today.
+
+## 4. Shared BDD feature (cross-SDK)
+
+New file `bdd/scenarios/raw_command.feature` (third shared feature,
+next to `basic_messaging.feature` and `leader_redirection.feature`):
+
+```gherkin
+Feature: Raw command API
+  Background:
+    Given a running Iggy server
+    And an authenticated root client
+
+  Scenario: Known command round-trips through the raw API
+    When I send a raw command with code 1 and an empty ping payload
+    Then I should receive an empty successful raw response
+    When I send a raw command with code 10 and an empty payload
+    Then I should receive a non-empty raw response
+
+  Scenario: Unknown command code is rejected by the server
+    When I send a raw command with code 60000 and an empty payload
+    Then the raw command should fail with an invalid command error
+
+  Scenario: Session-control code is rejected client-side
+    When I send a raw command with code 38 and an empty payload
+    Then the raw command should fail before reaching the server
+```
+
+Wording of steps to be finalized when writing the first (Rust)
+implementation. The ping payload for code 1 is empty on the classic
+protocol (`PingRequest` serializes to empty bytes), so all steps can
+share one generic "code + empty payload" step. The 60000 unknown-code
+scenario relies on stock-server behavior (`dispatch.rs:451`).
+
+Step definitions per runner (same files that hold basic messaging
+steps):
+
+| Lang | File to extend |
+| --- | --- |
+| rust | `bdd/rust/tests/steps/` (new `raw_command.rs`, register in 
`steps/mod.rs`, new test entry `bdd/rust/tests/raw_command.rs`) |
+| python | `bdd/python/tests/` (new `test_raw_command.py`, pytest-bdd) |
+| go | `bdd/go/tests/` (new `raw_command.go` + suite entry in `suite_test.go`) 
|
+| node | `foreign/node/src/bdd/` (new `raw.ts`) |
+| csharp | `foreign/csharp/Iggy_SDK.Tests.BDD/StepDefinitions/` (new 
`RawCommandSteps.cs`) |
+| java | `bdd/java/src/test/java/org/apache/iggy/bdd/` (new 
`RawCommandSteps.java`) |
+| php | `bdd/php/tests/` (new `RawCommandFeatureTest.php`) |
+| cpp | `bdd/cpp/features/step_definitions/` (new `raw_command_steps.cpp`) |
+
+Script/compose wiring:
+
+- `scripts/run-bdd-tests.sh`: add `raw_command` to the valid feature
+  list (`:48-54`) and to the compose-file selection so it pulls
+  `docker-compose.server.yml` like `basic_messaging` does. No cluster
+  compose needed. Unlike `leader_redirection`, no language gate: all
+  eight runners implement it (gate temporarily if rollout is staged).
+- Runners that enumerate features explicitly need their config touched
+  (go `suite_test.go` reads `BDD_FEATURE`, while csharp uses
+  `--filter-trait Category=...` in `bdd/docker-compose.yml:117-123`).
+  java/cucumber and node/cucumber pick up features from the shared
+  directory automatically. Verify each during implementation.
+
+## 5. Test matrix summary
+
+| SDK | Unit/local | Integration (real server) | BDD |
+| --- | --- | --- | --- |
+| Rust | exists (`client.rs:470-497`) | exists 
(`core/integration/tests/sdk/raw.rs`) | add steps |
+| Python | - | add `tests/test_raw_command.py` | add steps |
+| Node | add guard/framing unit test | add `src/e2e/tcp.raw.e2e.ts` | add 
steps |
+| Java | optional guard test | add `RawCommandTcpClientTest` (+ HTTP 
unsupported) | add steps |
+| C# | optional guard test | add `RawCommandTests` (TCP + HTTP unavailable) | 
add steps |
+| Go | add `tcp_core` pipe tests (frame layout, status, guard) | covered by 
BDD | add steps |
+| PHP | - | add PHPUnit raw test | add steps |
+| C++ | - | extend `low_level_e2e.cpp` | add steps |
+
+Standard four assertions everywhere a real server is available:
+
+1. code 1 (PING), empty payload -> empty success response.
+2. code 10 (GET_STATS), empty payload -> non-empty response.
+3. code 38 (LOGIN_USER) -> rejected client-side, nothing sent.
+4. code 60000 -> server responds with invalid-command error.
+
+## 6. Suggested execution order
+
+1. **Phase 1, SDK methods** (independent, parallelizable):
+   Python and PHP and C++ (thin Rust-wrapper plumbing, hours each),
+   Node (one method on `CommandAPI`), Go (interface + tcp impl),
+   C# (interface + two impls), Java (async + blocking + interface
+   decision). Each lands with its own local/integration tests from
+   section 5.
+2. **Phase 2, shared BDD**: add `raw_command.feature`, Rust steps
+   first (validates the feature wording against the stock server),
+   then the other seven runners, then `run-bdd-tests.sh` wiring.
+3. **Phase 3, docs (optional)**: short raw-command paragraph in each
+   SDK README and a runnable example under `examples/<lang>/` if
+   desired. Rust currently has no example either, so this is a
+   uniform, deferrable follow-up.
+
+## 7. Open decisions
+
+1. **Java interface placement**: `IggyBaseClient` + HTTP throws
+   (recommended, matches Rust/C#) vs TCP-only classes. Affects
+   plan 3.7 step 3.
+2. **Session-control guard set**: plan says mirror all five codes
+   {38, 39, 40, 44, 45}. Codes 40/45 are VSR login-register codes that
+   foreign SDKs (classic protocol only) will never see legitimately.
+   guarding them anyway is free and future-proof.
+3. **Exposing command-code constants publicly** (Node `COMMAND_CODE`
+   export, C# `CommandCodes` visibility): nice-to-have, not required
+   since the API takes numeric codes. Default: skip in phase 1.
+4. **C++ scope**: included because the client layer is mature and the
+   change is two files. Drop to phase 3 if C++ effort is constrained.
diff --git a/scripts/run-bdd-tests.sh b/scripts/run-bdd-tests.sh
index 148a564b3..29e0124d9 100755
--- a/scripts/run-bdd-tests.sh
+++ b/scripts/run-bdd-tests.sh
@@ -36,7 +36,7 @@ usage(){
   log "Usage: $0 [--coverage] <sdk> [feature]"
   log ""
   log "  sdk:     rust | python | php | go | go-race | node | csharp | java | 
cpp | all | clean (default: all)"
-  log "  feature: basic_messaging | leader_redirection | all  (default: all)"
+  log "  feature: basic_messaging | leader_redirection | raw_command | all  
(default: all)"
   log ""
   log "Examples:"
   log "  $0 rust                         # run all features for Rust"
@@ -46,7 +46,7 @@ usage(){
 }
 
 case "$FEATURE" in
-  basic_messaging|leader_redirection|all) ;;
+  basic_messaging|leader_redirection|raw_command|all) ;;
   *)
     log "Unknown feature: ${FEATURE}"
     usage
@@ -66,7 +66,7 @@ ALL_COMPOSE_FILES=(
 
 COMPOSE_FILES=(-f docker-compose.yml)
 case "$FEATURE" in
-  basic_messaging|leader_redirection|all)
+  basic_messaging|leader_redirection|raw_command|all)
     COMPOSE_FILES+=(-f docker-compose.server.yml) ;;
 esac
 case "$FEATURE" in

Reply via email to