Add mctpreactor for dynamic configuration of MCTP networks

While mctpd[1] may see heavy use in projects such as OpenBMC, it
implements generic functionality necessary to operate MCTP as a
protocol. It therefore should be easy to use in other contexts, and so
it feels unwise to embed OpenBMC-specific details in its implementation.

Conversely, entity-manager's scope is to expose inventory and board
configuration. It externalises all other responsibilities for the sake
of stability and maintenance. While entity-manager is central to
OpenBMC's implementation and has little use in other contexts, embedding
details of how to configure mctpd in entity-manager exceeds its scope.

Thus we reach the design point of mctpreactor, an intermediary process
that encapsulates OpenBMC-specific and mctpd-specific behaviors to
constrain their dispersion in either direction. The design-point was
reached via discussion at [2].

mctpreactor tracks instances of transport-specific MCTP device
configurations[3] appearing as a result of inventory changes, and uses
them to assign endpoint IDs via mctpd.

The lifecycle of an MCTP device can be quite dynamic - mctpd provides
behaviors to recover[4] or remove endpoints from the network. Their
presence cannot be assumed. mctpreactor handles these events: If
a device is removed at the MCTP layer (as it may be unresponsive),
mctpreactor will periodically attempt to re-establish it as an endpoint
so long as the associated configuration on the entity-manager inventory
object remains exposed.

[1]: https://github.com/CodeConstruct/mctp/
[2]: https://github.com/CodeConstruct/mctp/pull/17
[3]: https://gerrit.openbmc.org/c/openbmc/entity-manager/+/70628
[4]: https://github.com/CodeConstruct/mctp/blob/7ec2f8daa3a8948066390aee621d6afa03f6ecd9/docs/endpoint-recovery.md

Change-Id: I5e362cf6e5ce80ce282bab48d912a1038003e236
Signed-off-by: Andrew Jeffery <andrew@codeconstruct.com.au>
diff --git a/src/mctp/MCTPDeviceRepository.hpp b/src/mctp/MCTPDeviceRepository.hpp
new file mode 100644
index 0000000..475acbe
--- /dev/null
+++ b/src/mctp/MCTPDeviceRepository.hpp
@@ -0,0 +1,77 @@
+#pragma once
+
+#include "MCTPEndpoint.hpp"
+
+class MCTPDeviceRepository
+{
+  private:
+    // FIXME: Ugh, hack. Figure out a better data structure?
+    std::map<std::string, std::shared_ptr<MCTPDevice>> devices;
+
+    auto lookup(const std::shared_ptr<MCTPDevice>& device)
+    {
+        auto pred = [&device](const auto& it) { return it.second == device; };
+        return std::ranges::find_if(devices, pred);
+    }
+
+  public:
+    MCTPDeviceRepository() = default;
+    MCTPDeviceRepository(const MCTPDeviceRepository&) = delete;
+    MCTPDeviceRepository(MCTPDeviceRepository&&) = delete;
+    ~MCTPDeviceRepository() = default;
+
+    MCTPDeviceRepository& operator=(const MCTPDeviceRepository&) = delete;
+    MCTPDeviceRepository& operator=(MCTPDeviceRepository&&) = delete;
+
+    void add(const std::string& inventory,
+             const std::shared_ptr<MCTPDevice>& device)
+    {
+        auto [entry, fresh] = devices.emplace(inventory, device);
+        if (!fresh && entry->second.get() != device.get())
+        {
+            throw std::system_error(
+                std::make_error_code(std::errc::device_or_resource_busy),
+                std::format("Tried to add entry for existing device: {}",
+                            device->describe()));
+        }
+    }
+
+    void remove(const std::shared_ptr<MCTPDevice>& device)
+    {
+        auto entry = lookup(device);
+        if (entry == devices.end())
+        {
+            throw std::system_error(
+                std::make_error_code(std::errc::no_such_device),
+                std::format("Trying to remove unknown device: {}",
+                            entry->second->describe()));
+        }
+        devices.erase(entry);
+    }
+
+    bool contains(const std::shared_ptr<MCTPDevice>& device)
+    {
+        return lookup(device) != devices.end();
+    }
+
+    std::optional<std::string>
+        inventoryFor(const std::shared_ptr<MCTPDevice>& device)
+    {
+        auto entry = lookup(device);
+        if (entry == devices.end())
+        {
+            return {};
+        }
+        return entry->first;
+    }
+
+    std::shared_ptr<MCTPDevice> deviceFor(const std::string& inventory)
+    {
+        auto entry = devices.find(inventory);
+        if (entry == devices.end())
+        {
+            return {};
+        }
+        return entry->second;
+    }
+};
diff --git a/src/mctp/MCTPEndpoint.cpp b/src/mctp/MCTPEndpoint.cpp
new file mode 100644
index 0000000..a8fcc91
--- /dev/null
+++ b/src/mctp/MCTPEndpoint.cpp
@@ -0,0 +1,407 @@
+#include "MCTPEndpoint.hpp"
+
+#include "Utils.hpp"
+#include "VariantVisitors.hpp"
+
+#include <bits/fs_dir.h>
+
+#include <boost/system/detail/errc.hpp>
+#include <phosphor-logging/lg2.hpp>
+#include <sdbusplus/asio/connection.hpp>
+#include <sdbusplus/bus.hpp>
+#include <sdbusplus/bus/match.hpp>
+#include <sdbusplus/exception.hpp>
+#include <sdbusplus/message.hpp>
+#include <sdbusplus/message/native_types.hpp>
+
+#include <cassert>
+#include <charconv>
+#include <cstdint>
+#include <exception>
+#include <filesystem>
+#include <format>
+#include <functional>
+#include <map>
+#include <memory>
+#include <optional>
+#include <set>
+#include <stdexcept>
+#include <string>
+#include <system_error>
+#include <utility>
+#include <variant>
+#include <vector>
+
+PHOSPHOR_LOG2_USING;
+
+static constexpr const char* mctpdBusName = "xyz.openbmc_project.MCTP";
+static constexpr const char* mctpdControlPath = "/xyz/openbmc_project/mctp";
+static constexpr const char* mctpdControlInterface =
+    "au.com.CodeConstruct.MCTP";
+static constexpr const char* mctpdEndpointControlInterface =
+    "au.com.CodeConstruct.MCTP.Endpoint";
+
+MCTPDDevice::MCTPDDevice(
+    const std::shared_ptr<sdbusplus::asio::connection>& connection,
+    const std::string& interface, const std::vector<uint8_t>& physaddr) :
+    connection(connection), interface(interface), physaddr(physaddr)
+{}
+
+void MCTPDDevice::onEndpointInterfacesRemoved(
+    const std::weak_ptr<MCTPDDevice>& weak, const std::string& objpath,
+    sdbusplus::message_t& msg)
+{
+    auto path = msg.unpack<sdbusplus::message::object_path>();
+    assert(path.str == objpath);
+
+    auto removedIfaces = msg.unpack<std::set<std::string>>();
+    if (!removedIfaces.contains(mctpdEndpointControlInterface))
+    {
+        return;
+    }
+
+    if (auto self = weak.lock())
+    {
+        self->endpointRemoved();
+    }
+    else
+    {
+        info(
+            "Device for inventory at '{INVENTORY_PATH}' was destroyed concurrent to endpoint removal",
+            "INVENTORY_PATH", objpath);
+    }
+}
+
+void MCTPDDevice::finaliseEndpoint(
+    const std::string& objpath, uint8_t eid, int network,
+    std::function<void(const std::error_code& ec,
+                       const std::shared_ptr<MCTPEndpoint>& ep)>& added)
+{
+    const auto matchSpec =
+        sdbusplus::bus::match::rules::interfacesRemovedAtPath(objpath);
+    removeMatch = std::make_unique<sdbusplus::bus::match_t>(
+        *connection, matchSpec,
+        std::bind_front(MCTPDDevice::onEndpointInterfacesRemoved,
+                        weak_from_this(), objpath));
+    endpoint = std::make_shared<MCTPDEndpoint>(shared_from_this(), connection,
+                                               objpath, network, eid);
+    added({}, endpoint);
+}
+
+void MCTPDDevice::setup(
+    std::function<void(const std::error_code& ec,
+                       const std::shared_ptr<MCTPEndpoint>& ep)>&& added)
+{
+    // Use a lambda to separate state validation from business logic,
+    // where the business logic for a successful setup() is encoded in
+    // MctpdDevice::finaliseEndpoint()
+    auto onSetup = [weak{weak_from_this()}, added{std::move(added)}](
+                       const boost::system::error_code& ec, uint8_t eid,
+                       int network, const std::string& objpath,
+                       bool allocated [[maybe_unused]]) mutable {
+        if (ec)
+        {
+            added(ec, {});
+            return;
+        }
+
+        if (auto self = weak.lock())
+        {
+            self->finaliseEndpoint(objpath, eid, network, added);
+        }
+        else
+        {
+            info(
+                "Device object for inventory at '{INVENTORY_PATH}' was destroyed concurrent to completion of its endpoint setup",
+                "INVENTORY_PATH", objpath);
+        }
+    };
+    connection->async_method_call(onSetup, mctpdBusName, mctpdControlPath,
+                                  mctpdControlInterface, "AssignEndpoint",
+                                  interface, physaddr);
+}
+
+void MCTPDDevice::endpointRemoved()
+{
+    if (endpoint)
+    {
+        debug("Endpoint removed @ [ {MCTP_ENDPOINT} ]", "MCTP_ENDPOINT",
+              endpoint->describe());
+        removeMatch.reset();
+        endpoint->removed();
+        endpoint.reset();
+    }
+}
+
+void MCTPDDevice::remove()
+{
+    if (endpoint)
+    {
+        debug("Removing endpoint @ [ {MCTP_ENDPOINT} ]", "MCTP_ENDPOINT",
+              endpoint->describe());
+        endpoint->remove();
+    }
+}
+
+std::string MCTPDDevice::describe() const
+{
+    std::string description = std::format("interface: {}", interface);
+    if (!physaddr.empty())
+    {
+        description.append(", address: 0x [ ");
+        auto it = physaddr.begin();
+        for (; it != physaddr.end() - 1; it++)
+        {
+            description.append(std::format("{:02x} ", *it));
+        }
+        description.append(std::format("{:02x} ]", *it));
+    }
+    return description;
+}
+
+std::string MCTPDEndpoint::path(const std::shared_ptr<MCTPEndpoint>& ep)
+{
+    return std::format("/xyz/openbmc_project/mctp/{}/{}", ep->network(),
+                       ep->eid());
+}
+
+void MCTPDEndpoint::onMctpEndpointChange(sdbusplus::message_t& msg)
+{
+    auto [iface, changed, _] =
+        msg.unpack<std::string, std::map<std::string, BasicVariantType>,
+                   std::vector<std::string>>();
+    if (iface != mctpdEndpointControlInterface)
+    {
+        return;
+    }
+
+    auto it = changed.find("Connectivity");
+    if (it == changed.end())
+    {
+        return;
+    }
+
+    updateEndpointConnectivity(std::get<std::string>(it->second));
+}
+
+void MCTPDEndpoint::updateEndpointConnectivity(const std::string& connectivity)
+{
+    if (connectivity == "Degraded")
+    {
+        if (notifyDegraded)
+        {
+            notifyDegraded(shared_from_this());
+        }
+    }
+    else if (connectivity == "Available")
+    {
+        if (notifyAvailable)
+        {
+            notifyAvailable(shared_from_this());
+        }
+    }
+    else
+    {
+        debug("Unrecognised connectivity state: '{CONNECTIVITY_STATE}'",
+              "CONNECTIVITY_STATE", connectivity);
+    }
+}
+
+int MCTPDEndpoint::network() const
+{
+    return mctp.network;
+}
+
+uint8_t MCTPDEndpoint::eid() const
+{
+    return mctp.eid;
+}
+
+void MCTPDEndpoint::subscribe(Event&& degraded, Event&& available,
+                              Event&& removed)
+{
+    const auto matchSpec =
+        sdbusplus::bus::match::rules::propertiesChangedNamespace(
+            objpath.str, mctpdEndpointControlInterface);
+
+    this->notifyDegraded = std::move(degraded);
+    this->notifyAvailable = std::move(available);
+    this->notifyRemoved = std::move(removed);
+
+    try
+    {
+        connectivityMatch.emplace(
+            static_cast<sdbusplus::bus_t&>(*connection), matchSpec,
+            [weak{weak_from_this()},
+             path{objpath.str}](sdbusplus::message_t& msg) {
+                if (auto self = weak.lock())
+                {
+                    self->onMctpEndpointChange(msg);
+                }
+                else
+                {
+                    info(
+                        "The endpoint for the device at inventory path '{INVENTORY_PATH}' was destroyed concurrent to the removal of its state change match",
+                        "INVENTORY_PATH", path);
+                }
+            });
+        connection->async_method_call(
+            [weak{weak_from_this()},
+             path{objpath.str}](const boost::system::error_code& ec,
+                                const std::variant<std::string>& value) {
+                if (ec)
+                {
+                    debug(
+                        "Failed to get current connectivity state: {ERROR_MESSAGE}",
+                        "ERROR_MESSAGE", ec.message(), "ERROR_CATEGORY",
+                        ec.category().name(), "ERROR_CODE", ec.value());
+                    return;
+                }
+
+                if (auto self = weak.lock())
+                {
+                    const std::string& connectivity =
+                        std::get<std::string>(value);
+                    self->updateEndpointConnectivity(connectivity);
+                }
+                else
+                {
+                    info(
+                        "The endpoint for the device at inventory path '{INVENTORY_PATH}' was destroyed concurrent to the completion of its connectivity state query",
+                        "INVENTORY_PATH", path);
+                }
+            },
+            mctpdBusName, objpath.str, "org.freedesktop.DBus.Properties", "Get",
+            mctpdEndpointControlInterface, "Connectivity");
+    }
+    catch (const sdbusplus::exception::SdBusError& err)
+    {
+        this->notifyDegraded = nullptr;
+        this->notifyAvailable = nullptr;
+        this->notifyRemoved = nullptr;
+        std::throw_with_nested(
+            MCTPException("Failed to register connectivity signal match"));
+    }
+}
+
+void MCTPDEndpoint::remove()
+{
+    connection->async_method_call(
+        [self{shared_from_this()}](const boost::system::error_code& ec) {
+            if (ec)
+            {
+                debug("Failed to remove endpoint @ [ {MCTP_ENDPOINT} ]",
+                      "MCTP_ENDPOINT", self->describe());
+                return;
+            }
+        },
+        mctpdBusName, objpath.str, mctpdEndpointControlInterface, "Remove");
+}
+
+void MCTPDEndpoint::removed()
+{
+    if (notifyRemoved)
+    {
+        notifyRemoved(shared_from_this());
+    }
+}
+
+std::string MCTPDEndpoint::describe() const
+{
+    return std::format("network: {}, EID: {} | {}", mctp.network, mctp.eid,
+                       dev->describe());
+}
+
+std::shared_ptr<MCTPDevice> MCTPDEndpoint::device() const
+{
+    return dev;
+}
+
+std::optional<SensorBaseConfigMap>
+    I2CMCTPDDevice::match(const SensorData& config)
+{
+    auto iface = config.find(configInterfaceName(configType));
+    if (iface == config.end())
+    {
+        return std::nullopt;
+    }
+    return iface->second;
+}
+
+bool I2CMCTPDDevice::match(const std::set<std::string>& interfaces)
+{
+    return interfaces.contains(configInterfaceName(configType));
+}
+
+std::shared_ptr<I2CMCTPDDevice> I2CMCTPDDevice::from(
+    const std::shared_ptr<sdbusplus::asio::connection>& connection,
+    const SensorBaseConfigMap& iface)
+{
+    auto mType = iface.find("Type");
+    if (mType == iface.end())
+    {
+        throw std::invalid_argument(
+            "No 'Type' member found for provided configuration object");
+    }
+
+    auto type = std::visit(VariantToStringVisitor(), mType->second);
+    if (type != configType)
+    {
+        throw std::invalid_argument("Not an SMBus device");
+    }
+
+    auto mAddress = iface.find("Address");
+    auto mBus = iface.find("Bus");
+    auto mName = iface.find("Name");
+    if (mAddress == iface.end() || mBus == iface.end() || mName == iface.end())
+    {
+        throw std::invalid_argument(
+            "Configuration object violates MCTPI2CTarget schema");
+    }
+
+    auto sAddress = std::visit(VariantToStringVisitor(), mAddress->second);
+    std::uint8_t address{};
+    auto [aptr, aec] = std::from_chars(
+        sAddress.data(), sAddress.data() + sAddress.size(), address);
+    if (aec != std::errc{})
+    {
+        throw std::invalid_argument("Bad device address");
+    }
+
+    auto sBus = std::visit(VariantToStringVisitor(), mBus->second);
+    int bus{};
+    auto [bptr,
+          bec] = std::from_chars(sBus.data(), sBus.data() + sBus.size(), bus);
+    if (bec != std::errc{})
+    {
+        throw std::invalid_argument("Bad bus index");
+    }
+
+    try
+    {
+        return std::make_shared<I2CMCTPDDevice>(connection, bus, address);
+    }
+    catch (const MCTPException& ex)
+    {
+        warning(
+            "Failed to create I2CMCTPDDevice at [ bus: {I2C_BUS}, address: {I2C_ADDRESS} ]: {EXCEPTION}",
+            "I2C_BUS", bus, "I2C_ADDRESS", address, "EXCEPTION", ex);
+        return {};
+    }
+}
+
+std::string I2CMCTPDDevice::interfaceFromBus(int bus)
+{
+    std::filesystem::path netdir =
+        std::format("/sys/bus/i2c/devices/i2c-{}/net", bus);
+    std::error_code ec;
+    std::filesystem::directory_iterator it(netdir, ec);
+    if (ec || it == std::filesystem::end(it))
+    {
+        error("No net device associated with I2C bus {I2C_BUS} at {NET_DEVICE}",
+              "I2C_BUS", bus, "NET_DEVICE", netdir);
+        throw MCTPException("Bus is not configured as an MCTP interface");
+    }
+
+    return it->path().filename();
+}
diff --git a/src/mctp/MCTPEndpoint.hpp b/src/mctp/MCTPEndpoint.hpp
new file mode 100644
index 0000000..02322d1
--- /dev/null
+++ b/src/mctp/MCTPEndpoint.hpp
@@ -0,0 +1,321 @@
+#pragma once
+
+#include "Utils.hpp"
+
+#include <boost/asio/steady_timer.hpp>
+#include <sdbusplus/asio/connection.hpp>
+#include <sdbusplus/bus/match.hpp>
+#include <sdbusplus/message.hpp>
+#include <sdbusplus/message/native_types.hpp>
+
+#include <cstdint>
+#include <iostream>
+
+/**
+ * @file
+ * @brief Abstract and concrete classes representing MCTP concepts and
+ *        behaviours.
+ */
+
+/**
+ * @brief An exception type that may be thrown by implementations of the MCTP
+ * abstract classes.
+ *
+ * This exception should be the basis for all exceptions thrown out of the MCTP
+ * APIs, and should capture any other exceptions that occur.
+ */
+class MCTPException : public std::exception
+{
+  public:
+    MCTPException() = delete;
+    explicit MCTPException(const char* desc) : desc(desc) {}
+    const char* what() const noexcept override
+    {
+        return desc;
+    }
+
+  private:
+    const char* desc;
+};
+
+/**
+ * @brief An enum of the MCTP transports described in DSP0239 v1.10.0 Section 7.
+ *
+ * https://www.dmtf.org/sites/default/files/standards/documents/DSP0239_1.10.0.pdf
+ */
+enum class MCTPTransport
+{
+    Reserved = 0x00,
+    SMBus = 0x01,
+};
+
+/**
+ * @brief Captures properties of MCTP interfaces.
+ *
+ * https://github.com/CodeConstruct/mctp/blob/v1.1/src/mctp.c#L672-L703
+ */
+struct MCTPInterface
+{
+    std::string name;
+    MCTPTransport transport;
+
+    auto operator<=>(const MCTPInterface& r) const = default;
+};
+
+class MCTPDevice;
+
+/**
+ * @brief Captures the behaviour of an endpoint at the MCTP layer
+ *
+ * The lifetime of an instance of MctpEndpoint is proportional to the lifetime
+ * of the endpoint configuration. If an endpoint is deconfigured such that its
+ * device has no assigned EID, then any related MctpEndpoint instance must be
+ * destructed as a consequence.
+ */
+class MCTPEndpoint
+{
+  public:
+    using Event = std::function<void(const std::shared_ptr<MCTPEndpoint>& ep)>;
+    using Result = std::function<void(const std::error_code& ec)>;
+
+    virtual ~MCTPEndpoint() = default;
+
+    /**
+     * @return The Linux network ID of the network in which the endpoint
+               participates
+     */
+    virtual int network() const = 0;
+
+    /**
+     * @return The MCTP endpoint ID of the endpoint in its network
+     */
+    virtual uint8_t eid() const = 0;
+
+    /**
+     * @brief Subscribe to events produced by an endpoint object across its
+     *        lifecycle
+     *
+     * @param degraded The callback to execute when the MCTP layer indicates the
+     *                 endpoint is unresponsive
+     *
+     * @param available The callback to execute when the MCTP layer indicates
+     *                  that communication with the degraded endpoint has been
+     *                  recovered
+     *
+     * @param removed The callback to execute when the MCTP layer indicates the
+     *                endpoint has been removed.
+     */
+    virtual void subscribe(Event&& degraded, Event&& available,
+                           Event&& removed) = 0;
+
+    /**
+     * @brief Remove the endpoint from its associated network
+     */
+    virtual void remove() = 0;
+
+    /**
+     * @return A formatted string representing the endpoint in terms of its
+     *         address properties
+     */
+    virtual std::string describe() const = 0;
+
+    /**
+     * @return A shared pointer to the device instance associated with the
+     *         endpoint.
+     */
+    virtual std::shared_ptr<MCTPDevice> device() const = 0;
+};
+
+/**
+ * @brief Represents an MCTP-capable device on a bus.
+ *
+ * It is often known that an MCTP-capable device exists on a bus prior to the
+ * MCTP stack configuring the device for communication. MctpDevice exposes the
+ * ability to set-up the endpoint device for communication.
+ *
+ * The lifetime of an MctpDevice instance is proportional to the existence of an
+ * MCTP-capable device in the system. If a device represented by an MctpDevice
+ * instance is removed from the system then any related MctpDevice instance must
+ * be destructed a consequence.
+ *
+ * Successful set-up of the device as an endpoint yields an MctpEndpoint
+ * instance. The lifetime of the MctpEndpoint instance produced must not exceed
+ * the lifetime of its parent MctpDevice.
+ */
+class MCTPDevice
+{
+  public:
+    virtual ~MCTPDevice() = default;
+
+    /**
+     * @brief Configure the device for MCTP communication
+     *
+     * @param added The callback to invoke once the setup process has
+     *              completed. The provided error code @p ec must be
+     *              checked as the request may not have succeeded. If
+     *              the request was successful then @p ep contains a
+     *              valid MctpEndpoint instance.
+     */
+    virtual void
+        setup(std::function<void(const std::error_code& ec,
+                                 const std::shared_ptr<MCTPEndpoint>& ep)>&&
+                  added) = 0;
+
+    /**
+     * @brief Remove the device and any associated endpoint from the MCTP stack.
+     */
+    virtual void remove() = 0;
+
+    /**
+     * @return A formatted string representing the device in terms of its
+     *         address properties.
+     */
+    virtual std::string describe() const = 0;
+};
+
+class MCTPDDevice;
+
+/**
+ * @brief An implementation of MctpEndpoint in terms of the D-Bus interfaces
+ *        exposed by @c mctpd.
+ *
+ * The lifetime of an MctpdEndpoint is proportional to the lifetime of the
+ * endpoint object exposed by @c mctpd. The lifecycle of @c mctpd endpoint
+ * objects is discussed here:
+ *
+ * https://github.com/CodeConstruct/mctp/pull/23/files#diff-00234f5f2543b8b9b8a419597e55121fe1cc57cf1c7e4ff9472bed83096bd28e
+ */
+class MCTPDEndpoint :
+    public MCTPEndpoint,
+    public std::enable_shared_from_this<MCTPDEndpoint>
+{
+  public:
+    static std::string path(const std::shared_ptr<MCTPEndpoint>& ep);
+
+    MCTPDEndpoint() = delete;
+    MCTPDEndpoint(
+        const std::shared_ptr<MCTPDDevice>& dev,
+        const std::shared_ptr<sdbusplus::asio::connection>& connection,
+        sdbusplus::message::object_path objpath, int network, uint8_t eid) :
+        dev(dev), connection(connection), objpath(std::move(objpath)),
+        mctp{network, eid}
+    {}
+    MCTPDEndpoint& McptdEndpoint(const MCTPDEndpoint& other) = delete;
+    MCTPDEndpoint(MCTPDEndpoint&& other) noexcept = default;
+    ~MCTPDEndpoint() override = default;
+
+    int network() const override;
+    uint8_t eid() const override;
+    void subscribe(Event&& degraded, Event&& available,
+                   Event&& removed) override;
+    void remove() override;
+
+    std::string describe() const override;
+
+    std::shared_ptr<MCTPDevice> device() const override;
+
+    /**
+     * @brief Indicate the endpoint has been removed
+     *
+     * Called from the implementation of MctpdDevice for resource cleanup
+     * prior to destruction. Resource cleanup is delegated by invoking the
+     * notifyRemoved() callback. As the actions may be abitrary we avoid
+     * invoking notifyRemoved() in the destructor.
+     */
+    void removed();
+
+  private:
+    std::shared_ptr<MCTPDDevice> dev;
+    std::shared_ptr<sdbusplus::asio::connection> connection;
+    sdbusplus::message::object_path objpath;
+    struct
+    {
+        int network;
+        uint8_t eid;
+    } mctp;
+    MCTPEndpoint::Event notifyAvailable;
+    MCTPEndpoint::Event notifyDegraded;
+    MCTPEndpoint::Event notifyRemoved;
+    std::optional<sdbusplus::bus::match_t> connectivityMatch;
+
+    void onMctpEndpointChange(sdbusplus::message_t& msg);
+    void updateEndpointConnectivity(const std::string& connectivity);
+};
+
+/**
+ * @brief An implementation of MctpDevice in terms of D-Bus interfaces exposed
+ *        by @c mctpd.
+ *
+ * The construction or destruction of an MctpdDevice is not required to be
+ * correlated with signals from @c mctpd. For instance, EntityManager may expose
+ * the existance of an MCTP-capable device through its usual configuration
+ * mechanisms.
+ */
+class MCTPDDevice :
+    public MCTPDevice,
+    public std::enable_shared_from_this<MCTPDDevice>
+{
+  public:
+    MCTPDDevice() = delete;
+    MCTPDDevice(const std::shared_ptr<sdbusplus::asio::connection>& connection,
+                const std::string& interface,
+                const std::vector<uint8_t>& physaddr);
+    MCTPDDevice(const MCTPDDevice& other) = delete;
+    MCTPDDevice(MCTPDDevice&& other) = delete;
+    ~MCTPDDevice() override = default;
+
+    void setup(std::function<void(const std::error_code& ec,
+                                  const std::shared_ptr<MCTPEndpoint>& ep)>&&
+                   added) override;
+    void remove() override;
+    std::string describe() const override;
+
+  private:
+    static void onEndpointInterfacesRemoved(
+        const std::weak_ptr<MCTPDDevice>& weak, const std::string& objpath,
+        sdbusplus::message_t& msg);
+
+    std::shared_ptr<sdbusplus::asio::connection> connection;
+    const std::string interface;
+    const std::vector<uint8_t> physaddr;
+    std::shared_ptr<MCTPDEndpoint> endpoint;
+    std::unique_ptr<sdbusplus::bus::match_t> removeMatch;
+
+    /**
+     * @brief Actions to perform once endpoint setup has succeeded
+     *
+     * Now that the endpoint exists two tasks remain:
+     *
+     * 1. Setup the match capturing removal of the endpoint object by mctpd
+     * 2. Invoke the callback to notify the requester that setup has completed,
+     *    providing the MctpEndpoint instance associated with the MctpDevice.
+     */
+    void finaliseEndpoint(
+        const std::string& objpath, uint8_t eid, int network,
+        std::function<void(const std::error_code& ec,
+                           const std::shared_ptr<MCTPEndpoint>& ep)>& added);
+    void endpointRemoved();
+};
+
+class I2CMCTPDDevice : public MCTPDDevice
+{
+  public:
+    static std::optional<SensorBaseConfigMap> match(const SensorData& config);
+    static bool match(const std::set<std::string>& interfaces);
+    static std::shared_ptr<I2CMCTPDDevice>
+        from(const std::shared_ptr<sdbusplus::asio::connection>& connection,
+             const SensorBaseConfigMap& iface);
+
+    I2CMCTPDDevice() = delete;
+    I2CMCTPDDevice(
+        const std::shared_ptr<sdbusplus::asio::connection>& connection, int bus,
+        uint8_t physaddr) :
+        MCTPDDevice(connection, interfaceFromBus(bus), {physaddr})
+    {}
+    ~I2CMCTPDDevice() override = default;
+
+  private:
+    static constexpr const char* configType = "MCTPI2CTarget";
+
+    static std::string interfaceFromBus(int bus);
+};
diff --git a/src/mctp/MCTPReactor.cpp b/src/mctp/MCTPReactor.cpp
new file mode 100644
index 0000000..9d32682
--- /dev/null
+++ b/src/mctp/MCTPReactor.cpp
@@ -0,0 +1,209 @@
+#include "MCTPReactor.hpp"
+
+#include "MCTPDeviceRepository.hpp"
+#include "MCTPEndpoint.hpp"
+#include "Utils.hpp"
+
+#include <boost/system/detail/error_code.hpp>
+#include <phosphor-logging/lg2.hpp>
+
+#include <cstdlib>
+#include <memory>
+#include <optional>
+#include <string>
+#include <system_error>
+#include <utility>
+#include <vector>
+
+PHOSPHOR_LOG2_USING;
+
+void MCTPReactor::deferSetup(const std::shared_ptr<MCTPDevice>& dev)
+{
+    debug("Deferring setup for MCTP device at [ {MCTP_DEVICE} ]", "MCTP_DEVICE",
+          dev->describe());
+
+    deferred.emplace(dev);
+}
+
+void MCTPReactor::untrackEndpoint(const std::shared_ptr<MCTPEndpoint>& ep)
+{
+    server.disassociate(MCTPDEndpoint::path(ep));
+}
+
+void MCTPReactor::trackEndpoint(const std::shared_ptr<MCTPEndpoint>& ep)
+{
+    info("Added MCTP endpoint to device: [ {MCTP_ENDPOINT} ]", "MCTP_ENDPOINT",
+         ep->describe());
+
+    ep->subscribe(
+        // Degraded
+        [](const std::shared_ptr<MCTPEndpoint>& ep) {
+            debug("Endpoint entered degraded state: [ {MCTP_ENDPOINT} ]",
+                  "MCTP_ENDPOINT", ep->describe());
+        },
+        // Available
+        [](const std::shared_ptr<MCTPEndpoint>& ep) {
+            debug("Endpoint entered available state: [ {MCTP_ENDPOINT} ]",
+                  "MCTP_ENDPOINT", ep->describe());
+        },
+        // Removed
+        [weak{weak_from_this()}](const std::shared_ptr<MCTPEndpoint>& ep) {
+            info("Removed MCTP endpoint from device: [ {MCTP_ENDPOINT} ]",
+                 "MCTP_ENDPOINT", ep->describe());
+            if (auto self = weak.lock())
+            {
+                self->untrackEndpoint(ep);
+                // Only defer the setup if we know inventory is still present
+                if (self->devices.contains(ep->device()))
+                {
+                    self->deferSetup(ep->device());
+                }
+            }
+            else
+            {
+                info(
+                    "The reactor object was destroyed concurrent to the removal of the remove match for the endpoint '{MCTP_ENDPOINT}'",
+                    "MCTP_ENDPOINT", ep->describe());
+            }
+        });
+
+    // Proxy-host the association back to the inventory at the same path as the
+    // endpoint in mctpd.
+    //
+    // clang-format off
+    // ```
+    // # busctl call xyz.openbmc_project.ObjectMapper /xyz/openbmc_project/object_mapper xyz.openbmc_project.ObjectMapper GetAssociatedSubTree ooias /xyz/openbmc_project/mctp/1/9/configured_by / 0 1 xyz.openbmc_project.Configuration.MCTPDevice
+    // a{sa{sas}} 1 "/xyz/openbmc_project/inventory/system/nvme/NVMe_1/NVMe_1_Temp" 1 "xyz.openbmc_project.EntityManager" 1 "xyz.openbmc_project.Configuration.MCTPDevice"
+    // ```
+    // clang-format on
+    std::optional<std::string> item = devices.inventoryFor(ep->device());
+    if (!item)
+    {
+        error("Inventory missing for endpoint: [ {MCTP_ENDPOINT} ]",
+              "MCTP_ENDPOINT", ep->describe());
+        return;
+    }
+    std::vector<Association> associations{
+        {"configured_by", "configures", *item}};
+    server.associate(MCTPDEndpoint::path(ep), associations);
+}
+
+void MCTPReactor::setupEndpoint(const std::shared_ptr<MCTPDevice>& dev)
+{
+    debug(
+        "Attempting to setup up MCTP endpoint for device at [ {MCTP_DEVICE} ]",
+        "MCTP_DEVICE", dev->describe());
+    dev->setup([weak{weak_from_this()},
+                dev](const std::error_code& ec,
+                     const std::shared_ptr<MCTPEndpoint>& ep) mutable {
+        auto self = weak.lock();
+        if (!self)
+        {
+            info(
+                "The reactor object was destroyed concurrent to the completion of the endpoint setup for '{MCTP_ENDPOINT}'",
+                "MCTP_ENDPOINT", ep->describe());
+            return;
+        }
+
+        if (ec)
+        {
+            debug(
+                "Setup failed for MCTP device at [ {MCTP_DEVICE} ]: {ERROR_MESSAGE}",
+                "MCTP_DEVICE", dev->describe(), "ERROR_MESSAGE", ec.message());
+
+            self->deferSetup(dev);
+            return;
+        }
+
+        try
+        {
+            self->trackEndpoint(ep);
+        }
+        catch (const MCTPException& e)
+        {
+            error("Failed to track endpoint '{MCTP_ENDPOINT}': {EXCEPTION}",
+                  "MCTP_ENDPOINT", ep->describe(), "EXCEPTION", e);
+            self->deferSetup(dev);
+        }
+    });
+}
+
+void MCTPReactor::tick()
+{
+    auto toSetup = std::exchange(deferred, {});
+    for (const auto& entry : toSetup)
+    {
+        setupEndpoint(entry);
+    }
+}
+
+void MCTPReactor::manageMCTPDevice(const std::string& path,
+                                   const std::shared_ptr<MCTPDevice>& device)
+{
+    if (!device)
+    {
+        return;
+    }
+
+    try
+    {
+        devices.add(path, device);
+        debug("MCTP device inventory added at '{INVENTORY_PATH}'",
+              "INVENTORY_PATH", path);
+        setupEndpoint(device);
+    }
+    catch (const std::system_error& e)
+    {
+        if (e.code() != std::errc::device_or_resource_busy)
+        {
+            throw e;
+        }
+
+        auto current = devices.deviceFor(path);
+        if (!current)
+        {
+            warning(
+                "Invalid state: Failed to manage device for inventory at '{INVENTORY_PATH}', but the inventory item is unrecognised",
+                "INVENTORY_PATH", path);
+            return;
+        }
+
+        // TODO: Ensure remove completion happens-before add. For now this
+        // happens unsynchronised. Make some noise about it.
+        warning(
+            "Unsynchronised endpoint reinitialsation due to configuration change at '{INVENTORY_PATH}': Removing '{MCTP_DEVICE}'",
+            "INVENTORY_PATH", path, "MCTP_DEVICE", current->describe());
+
+        unmanageMCTPDevice(path);
+
+        devices.add(path, device);
+
+        // Pray (this is the unsynchronised bit)
+        deferSetup(device);
+    }
+}
+
+void MCTPReactor::unmanageMCTPDevice(const std::string& path)
+{
+    auto device = devices.deviceFor(path);
+    if (!device)
+    {
+        debug("Unrecognised inventory item: {INVENTORY_PATH}", "INVENTORY_PATH",
+              path);
+        return;
+    }
+
+    debug("MCTP device inventory removed at '{INVENTORY_PATH}'",
+          "INVENTORY_PATH", path);
+
+    deferred.erase(device);
+
+    // Remove the device from the repository before notifying the device itself
+    // of removal so we don't defer its setup
+    devices.remove(device);
+
+    debug("Stopping management of MCTP device at [ {MCTP_DEVICE} ]",
+          "MCTP_DEVICE", device->describe());
+
+    device->remove();
+}
diff --git a/src/mctp/MCTPReactor.hpp b/src/mctp/MCTPReactor.hpp
new file mode 100644
index 0000000..ca20b45
--- /dev/null
+++ b/src/mctp/MCTPReactor.hpp
@@ -0,0 +1,53 @@
+#pragma once
+
+#include "MCTPDeviceRepository.hpp"
+#include "MCTPEndpoint.hpp"
+#include "Utils.hpp"
+
+#include <string>
+#include <vector>
+
+struct AssociationServer
+{
+    virtual ~AssociationServer() = default;
+
+    virtual void associate(const std::string& path,
+                           const std::vector<Association>& associations) = 0;
+    virtual void disassociate(const std::string& path) = 0;
+};
+
+class MCTPReactor : public std::enable_shared_from_this<MCTPReactor>
+{
+    using MCTPDeviceFactory = std::function<std::shared_ptr<MCTPDevice>(
+        const std::string& interface, const std::vector<std::uint8_t>& physaddr,
+        std::optional<std::uint8_t> eid)>;
+
+  public:
+    MCTPReactor() = delete;
+    MCTPReactor(const MCTPReactor&) = delete;
+    MCTPReactor(MCTPReactor&&) = delete;
+    explicit MCTPReactor(AssociationServer& server) : server(server) {}
+    ~MCTPReactor() = default;
+    MCTPReactor& operator=(const MCTPReactor&) = delete;
+    MCTPReactor& operator=(MCTPReactor&&) = delete;
+
+    void tick();
+
+    void manageMCTPDevice(const std::string& path,
+                          const std::shared_ptr<MCTPDevice>& device);
+    void unmanageMCTPDevice(const std::string& path);
+
+  private:
+    static std::optional<std::string> findSMBusInterface(int bus);
+
+    AssociationServer& server;
+    MCTPDeviceRepository devices;
+
+    // Tracks MCTP devices that have failed their setup
+    std::set<std::shared_ptr<MCTPDevice>> deferred;
+
+    void deferSetup(const std::shared_ptr<MCTPDevice>& dev);
+    void setupEndpoint(const std::shared_ptr<MCTPDevice>& dev);
+    void trackEndpoint(const std::shared_ptr<MCTPEndpoint>& ep);
+    void untrackEndpoint(const std::shared_ptr<MCTPEndpoint>& ep);
+};
diff --git a/src/mctp/MCTPReactorMain.cpp b/src/mctp/MCTPReactorMain.cpp
new file mode 100644
index 0000000..9abc0fc
--- /dev/null
+++ b/src/mctp/MCTPReactorMain.cpp
@@ -0,0 +1,249 @@
+#include "MCTPEndpoint.hpp"
+#include "MCTPReactor.hpp"
+#include "Utils.hpp"
+
+#include <boost/asio/io_context.hpp>
+#include <boost/asio/post.hpp>
+#include <boost/asio/steady_timer.hpp>
+#include <phosphor-logging/lg2.hpp>
+#include <sdbusplus/asio/connection.hpp>
+#include <sdbusplus/asio/object_server.hpp>
+#include <sdbusplus/bus.hpp>
+#include <sdbusplus/bus/match.hpp>
+#include <sdbusplus/message.hpp>
+#include <sdbusplus/message/native_types.hpp>
+
+#include <chrono>
+#include <cstdlib>
+#include <format>
+#include <functional>
+#include <map>
+#include <memory>
+#include <optional>
+#include <set>
+#include <stdexcept>
+#include <system_error>
+#include <vector>
+
+PHOSPHOR_LOG2_USING;
+
+class DBusAssociationServer : public AssociationServer
+{
+  public:
+    DBusAssociationServer() = delete;
+    DBusAssociationServer(const DBusAssociationServer&) = delete;
+    DBusAssociationServer(DBusAssociationServer&&) = delete;
+    explicit DBusAssociationServer(
+        const std::shared_ptr<sdbusplus::asio::connection>& connection) :
+        server(connection)
+    {
+        server.add_manager("/xyz/openbmc_project/mctp");
+    }
+    ~DBusAssociationServer() override = default;
+    DBusAssociationServer& operator=(const DBusAssociationServer&) = delete;
+    DBusAssociationServer& operator=(DBusAssociationServer&&) = delete;
+
+    void associate(const std::string& path,
+                   const std::vector<Association>& associations) override
+    {
+        auto [entry, _] = objects.emplace(
+            path, server.add_interface(path, association::interface));
+        std::shared_ptr<sdbusplus::asio::dbus_interface> iface = entry->second;
+        iface->register_property("Associations", associations);
+        iface->initialize();
+    }
+
+    void disassociate(const std::string& path) override
+    {
+        const auto entry = objects.find(path);
+        if (entry == objects.end())
+        {
+            throw std::logic_error(std::format(
+                "Attempted to untrack path that was not tracked: {}", path));
+        }
+        std::shared_ptr<sdbusplus::asio::dbus_interface> iface = entry->second;
+        server.remove_interface(entry->second);
+        objects.erase(entry);
+    }
+
+  private:
+    std::shared_ptr<sdbusplus::asio::connection> connection;
+    sdbusplus::asio::object_server server;
+    std::map<std::string, std::shared_ptr<sdbusplus::asio::dbus_interface>>
+        objects;
+};
+
+static std::shared_ptr<MCTPDevice> deviceFromConfig(
+    const std::shared_ptr<sdbusplus::asio::connection>& connection,
+    const SensorData& config)
+{
+    try
+    {
+        std::optional<SensorBaseConfigMap> iface;
+        // NOLINTNEXTLINE(bugprone-assignment-in-if-condition)
+        if ((iface = I2CMCTPDDevice::match(config)))
+        {
+            return I2CMCTPDDevice::from(connection, *iface);
+        }
+    }
+    catch (const std::invalid_argument& ex)
+    {
+        error("Unable to create device: {EXCEPTION}", "EXCEPTION", ex);
+    }
+
+    return {};
+}
+
+static void addInventory(
+    const std::shared_ptr<sdbusplus::asio::connection>& connection,
+    const std::shared_ptr<MCTPReactor>& reactor, sdbusplus::message_t& msg)
+{
+    auto [path,
+          exposed] = msg.unpack<sdbusplus::message::object_path, SensorData>();
+    try
+    {
+        reactor->manageMCTPDevice(path, deviceFromConfig(connection, exposed));
+    }
+    catch (const std::logic_error& e)
+    {
+        error(
+            "Addition of inventory at '{INVENTORY_PATH}' caused an invalid program state: {EXCEPTION}",
+            "INVENTORY_PATH", path, "EXCEPTION", e);
+    }
+    catch (const std::system_error& e)
+    {
+        error(
+            "Failed to manage device described by inventory at '{INVENTORY_PATH}: {EXCEPTION}'",
+            "INVENTORY_PATH", path, "EXCEPTION", e);
+    }
+}
+
+static void removeInventory(const std::shared_ptr<MCTPReactor>& reactor,
+                            sdbusplus::message_t& msg)
+{
+    auto [path, removed] =
+        msg.unpack<sdbusplus::message::object_path, std::set<std::string>>();
+    try
+    {
+        if (I2CMCTPDDevice::match(removed))
+        {
+            reactor->unmanageMCTPDevice(path.str);
+        }
+    }
+    catch (const std::logic_error& e)
+    {
+        error(
+            "Removal of inventory at '{INVENTORY_PATH}' caused an invalid program state: {EXCEPTION}",
+            "INVENTORY_PATH", path, "EXCEPTION", e);
+    }
+    catch (const std::system_error& e)
+    {
+        error(
+            "Failed to unmanage device described by inventory at '{INVENTORY_PATH}: {EXCEPTION}'",
+            "INVENTORY_PATH", path, "EXCEPTION", e);
+    }
+}
+
+static void manageMCTPEntity(
+    const std::shared_ptr<sdbusplus::asio::connection>& connection,
+    const std::shared_ptr<MCTPReactor>& reactor, ManagedObjectType& entities)
+{
+    for (const auto& [path, config] : entities)
+    {
+        try
+        {
+            reactor->manageMCTPDevice(path,
+                                      deviceFromConfig(connection, config));
+        }
+        catch (const std::logic_error& e)
+        {
+            error(
+                "Addition of inventory at '{INVENTORY_PATH}' caused an invalid program state: {EXCEPTION}",
+                "INVENTORY_PATH", path, "EXCEPTION", e);
+        }
+        catch (const std::system_error& e)
+        {
+            error(
+                "Failed to manage device described by inventory at '{INVENTORY_PATH}: {EXCEPTION}'",
+                "INVENTORY_PATH", path, "EXCEPTION", e);
+        }
+    }
+}
+
+static void exitReactor(boost::asio::io_context* io, sdbusplus::message_t& msg)
+{
+    auto name = msg.unpack<std::string>();
+    info("Shutting down mctpreactor, lost dependency '{SERVICE_NAME}'",
+         "SERVICE_NAME", name);
+    io->stop();
+}
+
+int main()
+{
+    constexpr std::chrono::seconds period(5);
+
+    boost::asio::io_context io;
+    auto systemBus = std::make_shared<sdbusplus::asio::connection>(io);
+    DBusAssociationServer associationServer(systemBus);
+    auto reactor = std::make_shared<MCTPReactor>(associationServer);
+    boost::asio::steady_timer clock(io);
+
+    std::function<void(const boost::system::error_code&)> alarm =
+        [&](const boost::system::error_code& ec) {
+            if (ec)
+            {
+                return;
+            }
+            clock.expires_after(period);
+            clock.async_wait(alarm);
+            reactor->tick();
+        };
+    clock.expires_after(period);
+    clock.async_wait(alarm);
+
+    using namespace sdbusplus::bus::match;
+
+    const std::string entityManagerNameLostSpec =
+        rules::nameOwnerChanged("xyz.openbmc_project.EntityManager");
+
+    auto entityManagerNameLostMatch = sdbusplus::bus::match_t(
+        static_cast<sdbusplus::bus_t&>(*systemBus), entityManagerNameLostSpec,
+        std::bind_front(exitReactor, &io));
+
+    const std::string mctpdNameLostSpec =
+        rules::nameOwnerChanged("xyz.openbmc_project.MCTP");
+
+    auto mctpdNameLostMatch = sdbusplus::bus::match_t(
+        static_cast<sdbusplus::bus_t&>(*systemBus), mctpdNameLostSpec,
+        std::bind_front(exitReactor, &io));
+
+    const std::string interfacesRemovedMatchSpec =
+        rules::sender("xyz.openbmc_project.EntityManager") +
+        // Trailing slash on path: Listen for signals on the inventory subtree
+        rules::interfacesRemovedAtPath("/xyz/openbmc_project/inventory/");
+
+    auto interfacesRemovedMatch = sdbusplus::bus::match_t(
+        static_cast<sdbusplus::bus_t&>(*systemBus), interfacesRemovedMatchSpec,
+        std::bind_front(removeInventory, reactor));
+
+    const std::string interfacesAddedMatchSpec =
+        rules::sender("xyz.openbmc_project.EntityManager") +
+        // Trailing slash on path: Listen for signals on the inventory subtree
+        rules::interfacesAddedAtPath("/xyz/openbmc_project/inventory/");
+
+    auto interfacesAddedMatch = sdbusplus::bus::match_t(
+        static_cast<sdbusplus::bus_t&>(*systemBus), interfacesAddedMatchSpec,
+        std::bind_front(addInventory, systemBus, reactor));
+
+    systemBus->request_name("xyz.openbmc_project.MCTPReactor");
+
+    boost::asio::post(io, [reactor, systemBus]() {
+        auto gsc = std::make_shared<GetSensorConfiguration>(
+            systemBus, std::bind_front(manageMCTPEntity, systemBus, reactor));
+        gsc->getConfiguration({"MCTPI2CTarget"});
+    });
+
+    io.run();
+
+    return EXIT_SUCCESS;
+}
diff --git a/src/mctp/meson.build b/src/mctp/meson.build
new file mode 100644
index 0000000..2b10004
--- /dev/null
+++ b/src/mctp/meson.build
@@ -0,0 +1,8 @@
+executable(
+    'mctpreactor',
+    'MCTPReactorMain.cpp',
+    'MCTPReactor.cpp',
+    'MCTPEndpoint.cpp',
+    dependencies: [ default_deps, utils_dep ],
+    install: true
+)
diff --git a/src/meson.build b/src/meson.build
index 64d156d..85b754b 100644
--- a/src/meson.build
+++ b/src/meson.build
@@ -101,6 +101,10 @@
     subdir('ipmb')
 endif
 
+if get_option('mctp').allowed()
+    subdir('mctp')
+endif
+
 if get_option('mcu').allowed()
     subdir('mcu')
 endif
diff --git a/src/tests/meson.build b/src/tests/meson.build
index 0415e96..c477843 100644
--- a/src/tests/meson.build
+++ b/src/tests/meson.build
@@ -56,3 +56,28 @@
         include_directories: src_inc,
     ),
 )
+
+test(
+    'MCTPReactor',
+    executable(
+        'test_MCTPReactor',
+        'test_MCTPReactor.cpp',
+        '../mctp/MCTPReactor.cpp',
+        '../mctp/MCTPEndpoint.cpp',
+        dependencies: [ gmock_dep, ut_deps_list, utils_dep ],
+        implicit_include_directories: false,
+        include_directories: '../mctp'
+    )
+)
+
+test(
+    'MCTPEndpoint',
+    executable(
+        'test_MCTPEndpoint',
+        'test_MCTPEndpoint.cpp',
+        '../mctp/MCTPEndpoint.cpp',
+        dependencies: [ gmock_dep, ut_deps_list, utils_dep ],
+        implicit_include_directories: false,
+        include_directories: '../mctp'
+    )
+)
diff --git a/src/tests/test_MCTPEndpoint.cpp b/src/tests/test_MCTPEndpoint.cpp
new file mode 100644
index 0000000..36b7d3c
--- /dev/null
+++ b/src/tests/test_MCTPEndpoint.cpp
@@ -0,0 +1,88 @@
+#include "MCTPEndpoint.hpp"
+#include "Utils.hpp"
+
+#include <stdexcept>
+
+#include <gtest/gtest.h>
+
+TEST(I2CMCTPDDevice, matchEmptyConfig)
+{
+    SensorData config{};
+    EXPECT_FALSE(I2CMCTPDDevice::match(config));
+}
+
+TEST(I2CMCTPDDevice, matchIrrelevantConfig)
+{
+    SensorData config{{"xyz.openbmc_project.Configuration.NVME1000", {}}};
+    EXPECT_FALSE(I2CMCTPDDevice::match(config));
+}
+
+TEST(I2CMCTPDDevice, matchRelevantConfig)
+{
+    SensorData config{{"xyz.openbmc_project.Configuration.MCTPI2CTarget", {}}};
+    EXPECT_TRUE(I2CMCTPDDevice::match(config));
+}
+
+TEST(I2CMCTPDDevice, fromBadIfaceNoType)
+{
+    SensorBaseConfigMap iface{{}};
+    EXPECT_THROW(I2CMCTPDDevice::from({}, iface), std::invalid_argument);
+}
+
+TEST(I2CMCTPDDevice, fromBadIfaceWrongType)
+{
+    SensorBaseConfigMap iface{{"Type", "NVME1000"}};
+    EXPECT_THROW(I2CMCTPDDevice::from({}, iface), std::invalid_argument);
+}
+
+TEST(I2CMCTPDDevice, fromBadIfaceNoAddress)
+{
+    SensorBaseConfigMap iface{
+        {"Bus", "0"},
+        {"Name", "test"},
+        {"Type", "MCTPI2CTarget"},
+    };
+    EXPECT_THROW(I2CMCTPDDevice::from({}, iface), std::invalid_argument);
+}
+
+TEST(I2CMCTPDDevice, fromBadIfaceBadAddress)
+{
+    SensorBaseConfigMap iface{
+        {"Address", "not a number"},
+        {"Bus", "0"},
+        {"Name", "test"},
+        {"Type", "MCTPI2CTarget"},
+    };
+    EXPECT_THROW(I2CMCTPDDevice::from({}, iface), std::invalid_argument);
+}
+
+TEST(I2CMCTPDDevice, fromBadIfaceNoBus)
+{
+    SensorBaseConfigMap iface{
+        {"Address", "0x1d"},
+        {"Name", "test"},
+        {"Type", "MCTPI2CTarget"},
+    };
+    EXPECT_THROW(I2CMCTPDDevice::from({}, iface), std::invalid_argument);
+}
+
+TEST(I2CMCTPDDevice, fromBadIfaceBadBus)
+{
+    SensorBaseConfigMap iface{
+        {"Address", "0x1d"},
+        {"Bus", "not a number"},
+        {"Name", "test"},
+        {"Type", "MCTPI2CTarget"},
+    };
+    EXPECT_THROW(I2CMCTPDDevice::from({}, iface), std::invalid_argument);
+}
+
+TEST(I2CMCTPDDevice, fromBadIfaceNoName)
+{
+    SensorBaseConfigMap iface{
+        {"Address", "0x1d"},
+        {"Bus", "0"},
+        {"Type", "MCTPI2CTarget"},
+    };
+    EXPECT_THROW(I2CMCTPDDevice::from({}, iface), std::invalid_argument);
+}
diff --git a/src/tests/test_MCTPReactor.cpp b/src/tests/test_MCTPReactor.cpp
new file mode 100644
index 0000000..abdd9e9
--- /dev/null
+++ b/src/tests/test_MCTPReactor.cpp
@@ -0,0 +1,250 @@
+#include "MCTPEndpoint.hpp"
+#include "MCTPReactor.hpp"
+#include "Utils.hpp"
+
+#include <cstdint>
+#include <functional>
+#include <memory>
+#include <string>
+#include <system_error>
+#include <vector>
+
+#include <gmock/gmock.h>
+#include <gtest/gtest.h>
+
+class MockMCTPDevice : public MCTPDevice
+{
+  public:
+    ~MockMCTPDevice() override = default;
+
+    MOCK_METHOD(void, setup,
+                (std::function<void(const std::error_code& ec,
+                                    const std::shared_ptr<MCTPEndpoint>& ep)> &&
+                 added),
+                (override));
+    MOCK_METHOD(void, remove, (), (override));
+    MOCK_METHOD(std::string, describe, (), (const, override));
+};
+
+class MockMCTPEndpoint : public MCTPEndpoint
+{
+  public:
+    ~MockMCTPEndpoint() override = default;
+
+    MOCK_METHOD(int, network, (), (const, override));
+    MOCK_METHOD(uint8_t, eid, (), (const, override));
+    MOCK_METHOD(void, subscribe,
+                (Event && degraded, Event&& available, Event&& removed),
+                (override));
+    MOCK_METHOD(void, remove, (), (override));
+    MOCK_METHOD(std::string, describe, (), (const, override));
+    MOCK_METHOD(std::shared_ptr<MCTPDevice>, device, (), (const, override));
+};
+
+class MockAssociationServer : public AssociationServer
+{
+  public:
+    ~MockAssociationServer() override = default;
+
+    MOCK_METHOD(void, associate,
+                (const std::string& path,
+                 const std::vector<Association>& associations),
+                (override));
+    MOCK_METHOD(void, disassociate, (const std::string& path), (override));
+};
+
+class MCTPReactorFixture : public testing::Test
+{
+  protected:
+    void SetUp() override
+    {
+        reactor = std::make_shared<MCTPReactor>(assoc);
+        device = std::make_shared<MockMCTPDevice>();
+        EXPECT_CALL(*device, describe())
+            .WillRepeatedly(testing::Return("mock device"));
+
+        endpoint = std::make_shared<MockMCTPEndpoint>();
+        EXPECT_CALL(*endpoint, device())
+            .WillRepeatedly(testing::Return(device));
+        EXPECT_CALL(*endpoint, describe())
+            .WillRepeatedly(testing::Return("mock endpoint"));
+        EXPECT_CALL(*endpoint, eid()).WillRepeatedly(testing::Return(9));
+        EXPECT_CALL(*endpoint, network()).WillRepeatedly(testing::Return(1));
+    }
+
+    void TearDown() override
+    {
+        // https://stackoverflow.com/a/10289205
+        EXPECT_TRUE(testing::Mock::VerifyAndClearExpectations(endpoint.get()));
+        EXPECT_TRUE(testing::Mock::VerifyAndClearExpectations(device.get()));
+    }
+
+    MockAssociationServer assoc;
+    std::shared_ptr<MCTPReactor> reactor;
+    std::shared_ptr<MockMCTPDevice> device;
+    std::shared_ptr<MockMCTPEndpoint> endpoint;
+};
+
+TEST_F(MCTPReactorFixture, manageNullDevice)
+{
+    reactor->manageMCTPDevice("/test", {});
+    reactor->unmanageMCTPDevice("/test");
+}
+
+TEST_F(MCTPReactorFixture, manageMockDeviceSetupFailure)
+{
+    EXPECT_CALL(*device, remove());
+    EXPECT_CALL(*device, setup(testing::_))
+        .WillOnce(testing::InvokeArgument<0>(
+            std::make_error_code(std::errc::permission_denied), endpoint));
+
+    reactor->manageMCTPDevice("/test", device);
+    reactor->unmanageMCTPDevice("/test");
+}
+
+TEST_F(MCTPReactorFixture, manageMockDevice)
+{
+    std::function<void(const std::shared_ptr<MCTPEndpoint>& ep)> removeHandler;
+
+    std::vector<Association> requiredAssociation{
+        {"configured_by", "configures", "/test"}};
+    EXPECT_CALL(assoc, associate("/xyz/openbmc_project/mctp/1/9",
+                                 requiredAssociation));
+    EXPECT_CALL(assoc, disassociate("/xyz/openbmc_project/mctp/1/9"));
+
+    EXPECT_CALL(*endpoint, remove()).WillOnce(testing::Invoke([&]() {
+        removeHandler(endpoint);
+    }));
+    EXPECT_CALL(*endpoint, subscribe(testing::_, testing::_, testing::_))
+        .WillOnce(testing::SaveArg<2>(&removeHandler));
+
+    EXPECT_CALL(*device, remove()).WillOnce(testing::Invoke([&]() {
+        endpoint->remove();
+    }));
+    EXPECT_CALL(*device, setup(testing::_))
+        .WillOnce(testing::InvokeArgument<0>(std::error_code(), endpoint));
+
+    reactor->manageMCTPDevice("/test", device);
+    reactor->unmanageMCTPDevice("/test");
+}
+
+TEST_F(MCTPReactorFixture, manageMockDeviceDeferredSetup)
+{
+    std::function<void(const std::shared_ptr<MCTPEndpoint>& ep)> removeHandler;
+
+    std::vector<Association> requiredAssociation{
+        {"configured_by", "configures", "/test"}};
+    EXPECT_CALL(assoc, associate("/xyz/openbmc_project/mctp/1/9",
+                                 requiredAssociation));
+    EXPECT_CALL(assoc, disassociate("/xyz/openbmc_project/mctp/1/9"));
+
+    EXPECT_CALL(*endpoint, remove()).WillOnce(testing::Invoke([&]() {
+        removeHandler(endpoint);
+    }));
+    EXPECT_CALL(*endpoint, subscribe(testing::_, testing::_, testing::_))
+        .WillOnce(testing::SaveArg<2>(&removeHandler));
+
+    EXPECT_CALL(*device, remove()).WillOnce(testing::Invoke([&]() {
+        endpoint->remove();
+    }));
+    EXPECT_CALL(*device, setup(testing::_))
+        .WillOnce(testing::InvokeArgument<0>(
+            std::make_error_code(std::errc::permission_denied), endpoint))
+        .WillOnce(testing::InvokeArgument<0>(std::error_code(), endpoint));
+
+    reactor->manageMCTPDevice("/test", device);
+    reactor->tick();
+    reactor->unmanageMCTPDevice("/test");
+}
+
+TEST_F(MCTPReactorFixture, manageMockDeviceRemoved)
+{
+    std::function<void(const std::shared_ptr<MCTPEndpoint>& ep)> removeHandler;
+
+    std::vector<Association> requiredAssociation{
+        {"configured_by", "configures", "/test"}};
+    EXPECT_CALL(assoc,
+                associate("/xyz/openbmc_project/mctp/1/9", requiredAssociation))
+        .Times(2);
+    EXPECT_CALL(assoc, disassociate("/xyz/openbmc_project/mctp/1/9")).Times(2);
+
+    EXPECT_CALL(*endpoint, remove()).WillOnce(testing::Invoke([&]() {
+        removeHandler(endpoint);
+    }));
+    EXPECT_CALL(*endpoint, subscribe(testing::_, testing::_, testing::_))
+        .Times(2)
+        .WillRepeatedly(testing::SaveArg<2>(&removeHandler));
+
+    EXPECT_CALL(*device, remove()).WillOnce(testing::Invoke([&]() {
+        endpoint->remove();
+    }));
+    EXPECT_CALL(*device, setup(testing::_))
+        .Times(2)
+        .WillRepeatedly(
+            testing::InvokeArgument<0>(std::error_code(), endpoint));
+
+    reactor->manageMCTPDevice("/test", device);
+    removeHandler(endpoint);
+    reactor->tick();
+    reactor->unmanageMCTPDevice("/test");
+}
+
+TEST(MCTPReactor, replaceConfiguration)
+{
+    MockAssociationServer assoc{};
+    auto reactor = std::make_shared<MCTPReactor>(assoc);
+    std::function<void(const std::shared_ptr<MCTPEndpoint>& ep)> removeHandler;
+
+    std::vector<Association> requiredAssociation{
+        {"configured_by", "configures", "/test"}};
+
+    EXPECT_CALL(assoc,
+                associate("/xyz/openbmc_project/mctp/1/9", requiredAssociation))
+        .Times(2);
+    EXPECT_CALL(assoc, disassociate("/xyz/openbmc_project/mctp/1/9")).Times(2);
+
+    auto endpoint = std::make_shared<MockMCTPEndpoint>();
+    EXPECT_CALL(*endpoint, describe())
+        .WillRepeatedly(testing::Return("mock endpoint"));
+    EXPECT_CALL(*endpoint, eid()).WillRepeatedly(testing::Return(9));
+    EXPECT_CALL(*endpoint, network()).WillRepeatedly(testing::Return(1));
+    EXPECT_CALL(*endpoint, remove())
+        .Times(2)
+        .WillRepeatedly(testing::Invoke([&]() { removeHandler(endpoint); }));
+    EXPECT_CALL(*endpoint, subscribe(testing::_, testing::_, testing::_))
+        .Times(2)
+        .WillRepeatedly(testing::SaveArg<2>(&removeHandler));
+
+    auto initial = std::make_shared<MockMCTPDevice>();
+    EXPECT_CALL(*initial, describe())
+        .WillRepeatedly(testing::Return("mock device: initial"));
+    EXPECT_CALL(*initial, setup(testing::_))
+        .WillOnce(testing::InvokeArgument<0>(std::error_code(), endpoint));
+    EXPECT_CALL(*initial, remove()).WillOnce(testing::Invoke([&]() {
+        endpoint->remove();
+    }));
+
+    auto replacement = std::make_shared<MockMCTPDevice>();
+    EXPECT_CALL(*replacement, describe())
+        .WillRepeatedly(testing::Return("mock device: replacement"));
+    EXPECT_CALL(*replacement, setup(testing::_))
+        .WillOnce(testing::InvokeArgument<0>(std::error_code(), endpoint));
+    EXPECT_CALL(*replacement, remove()).WillOnce(testing::Invoke([&]() {
+        endpoint->remove();
+    }));
+
+    EXPECT_CALL(*endpoint, device())
+        .WillOnce(testing::Return(initial))
+        .WillOnce(testing::Return(initial))
+        .WillOnce(testing::Return(replacement))
+        .WillOnce(testing::Return(replacement));
+
+    reactor->manageMCTPDevice("/test", initial);
+    reactor->manageMCTPDevice("/test", replacement);
+    reactor->tick();
+    reactor->unmanageMCTPDevice("/test");
+
+    EXPECT_TRUE(testing::Mock::VerifyAndClearExpectations(initial.get()));
+    EXPECT_TRUE(testing::Mock::VerifyAndClearExpectations(replacement.get()));
+    EXPECT_TRUE(testing::Mock::VerifyAndClearExpectations(endpoint.get()));
+}