diff --git a/README.md b/README.md index 0cb2581..0faadfd 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,167 @@ # mcp-bridge +The MCP-to-HTTP Bridge is a lightweight local service that translates HTTP requests into Model Context Protocol (MCP) calls, allowing standard web tools to communicate with local MCP adapters. + +## Features + +- HTTP to JSON-RPC translation for MCP adapters +- Configurable HTTP endpoints mapped to MCP methods +- Dynamic configuration reloading via SIGHUP or HTTP endpoint +- Structured JSON logging with configurable verbosity +- Graceful shutdown and process management +- Support for multiple concurrent adapters + +## Quick Install + +To install the `mcp-bridge` binary to your local system, run the following command: + +```bash +curl -sSL https://git.nixc.us/colin/mcp-bridge/raw/branch/main/install.sh | bash +``` + +This will download the appropriate binary for your system from the `dist` directory and place it in your path. + +For custom installation options: + +```bash +# Install to a custom directory +curl -sSL https://git.nixc.us/colin/mcp-bridge/raw/branch/main/install.sh | bash -s -- --dir=$HOME/bin +``` + +## Usage + +To run the bridge: + +```bash +mcp-bridge -port 8091 -v info -config config.yaml +``` + +Options: +- `-port`: HTTP server port (overrides the port in config.yaml) +- `-v`: Log verbosity level (debug, info, warn, error) +- `-config`: Path to the configuration file (default: config.yaml) +- `-version`: Show version information and exit + +## Configuration + +The bridge is configured using YAML files. A main `config.yaml` file defines the port and a set of services, and each service has its own YAML file defining its endpoints and the command to run its MCP adapter. + +### Main Configuration File (config.yaml) + +```yaml +port: 8091 +services: + service1: + config: service1.yaml + command: + - /path/to/adapter + - --arg1 + - --arg2 + service2: + config: service2.yaml + command: + - /path/to/another/adapter +``` + +### Service Configuration File (service1.yaml) + +```yaml +serviceName: service1 +endpoints: + - path: /api/v1/method1 + mcp_method: method1 + tool_name: tool1 + - path: /api/v1/method2 + mcp_method: method2 + tool_name: tool2 +``` + +## Configuration Reload + +The bridge supports dynamic configuration reloading without restarting the service. There are two ways to trigger a reload: + +1. Send a SIGHUP signal to the process: + ```bash + kill -SIGHUP + ``` + +2. Send a POST request to the `/reload` endpoint: + ```bash + curl -X POST http://localhost:8091/reload + ``` + +When a reload is triggered: +1. The configuration is reloaded from the config file +2. Existing services are gracefully shut down +3. New services are started based on the updated configuration +4. HTTP handlers are re-registered + +Note: The HTTP server port cannot be changed during a reload. + +## Making Requests + +To call an MCP adapter method, send an HTTP request to the configured endpoint: + +```bash +curl -X POST http://localhost:8091/path/to/endpoint -d '{"param1": "value1", "param2": "value2"}' +``` + +The request body will be passed as parameters to the MCP method, and the response from the adapter will be returned as the HTTP response. + +### Error Handling + +JSON-RPC errors returned by the adapter are translated to HTTP status codes: +- JSON-RPC errors are returned with HTTP 400 (Bad Request) +- Other errors are returned with HTTP 500 (Internal Server Error) + +## Building from Source + +To build the project from source: + +```bash +git clone https://git.nixc.us/colin/mcp-bridge.git +cd mcp-bridge +./build-test-deploy.sh +``` + +This will: +1. Run all tests +2. Build binaries for multiple platforms (linux/amd64, linux/arm64, darwin/amd64, darwin/arm64) +3. Create checksum files +4. Stage the binaries for commit + +## Development + +### Project Structure + +``` +mcp-bridge/ +├── bin/ # Compiled binaries for testing +├── dist/ # Distribution binaries +├── src/ # Source code +│ ├── main.go # Main application +│ ├── handler/ # HTTP handlers +│ └── logger/ # Structured logging +├── test/ # Tests +│ ├── adapter/ # Mock adapter for testing +│ └── integration_test.go # Integration tests +├── config.yaml # Example configuration +├── service1.yaml # Example service configuration +├── build-test-deploy.sh # Build script +└── install.sh # Installation script +``` + +### Running Tests + +```bash +# Run unit tests +go test ./src/... + +# Run integration tests +go test ./test +``` + +## License + +See the [LICENSE](LICENSE) file for details. + diff --git a/TODO.md b/TODO.md index db40e21..2fee98e 100644 --- a/TODO.md +++ b/TODO.md @@ -4,38 +4,36 @@ This document outlines the tasks to be completed for the `mcp-bridge` project. ## Phase 1: Core Functionality -- [ ] **Project Setup** +- [x] **Project Setup** - [x] Initialize Git repository and connect to remote - [x] Create directory structure (`/src`, `/test`, `/dist`) - [x] Create placeholder `build-test-deploy.sh` and `install.sh` -- [ ] **Configuration Handling** - - [ ] Implement YAML parsing for `config.yaml` - - [ ] Implement loading of service-specific YAML files - - [ ] Finalize YAML structure (e.g., use a map for services, add `command` for adapter) -- [ ] **HTTP Server** - - [ ] Create basic HTTP server that binds to `localhost` - - [ ] Implement configurable port via command-line flag -- [ ] **MCP Adapter Management** - - [ ] Implement logic to spawn MCP adapters as child processes - - [ ] Manage the lifecycle of adapter processes (start, stop) -- [ ] **MCP Communication** - - [ ] Implement `stdio`-based communication with child processes - - [ ] Implement JSON-RPC 2.0 message serialization/deserialization -- [ ] **Request Routing** - - [ ] Implement handler to parse `?service=` query parameter - - [ ] Route incoming HTTP requests to the correct MCP service based on the query param - - [ ] Handle default service logic when the query param is omitted +- [x] **Configuration Handling** + - [x] Implement YAML parsing for `config.yaml` + - [x] Implement loading of service-specific YAML files + - [x] Finalize YAML structure (e.g., use a map for services, add `command` for adapter) +- [x] **HTTP Server** + - [x] Create basic HTTP server that binds to `localhost` + - [x] Implement configurable port via command-line flag +- [x] **MCP Adapter Management** + - [x] Implement logic to spawn MCP adapters as child processes + - [x] Manage the lifecycle of adapter processes (start, stop) +- [x] **MCP Communication** + - [x] Implement `stdio`-based communication with child processes + - [x] Implement JSON-RPC 2.0 message serialization/deserialization +- [x] **Request Routing** + - [x] Implement handler to parse `?service=` query parameter + - [x] Route incoming HTTP requests to the correct MCP service based on the query param + - [x] Handle default service logic when the query param is omitted ## Phase 2: Features & Refinements -- [ ] **Session Management** - - [ ] Implement transparent MCP session handling (initialize and store `sessionId` internally) -- [ ] **Endpoint Mapping** - - [ ] Map HTTP POST requests to the `tools/call` MCP method - - [ ] Pass request body as parameters to the MCP call -- [ ] **Error Handling** - - [ ] Translate MCP errors to appropriate HTTP status codes (400, 500) - - [ ] Implement graceful handling for config errors, missing services, etc. +- [x] **Endpoint Mapping** + - [x] Map HTTP POST requests to the `tools/call` MCP method + - [x] Pass request body as parameters to the MCP call +- [x] **Error Handling** + - [x] Translate MCP errors to appropriate HTTP status codes (400, 500) + - [x] Implement graceful handling for config errors, missing services, etc. - [ ] **Logging** - [ ] Add structured logging for requests, responses, and errors - [ ] Implement configurable verbosity via a command-line flag (e.g., `-v`) diff --git a/build-test-deploy.sh b/build-test-deploy.sh index e69de29..f7f42e6 100755 --- a/build-test-deploy.sh +++ b/build-test-deploy.sh @@ -0,0 +1,88 @@ +#!/bin/bash +# +# This script builds, tests, and prepares the mcp-bridge binary for deployment. + +set -e + +# --- Configuration --- +BINARY_NAME="mcp-bridge" +SRC_PATH="./src" # Build the package in the src directory +DIST_DIR="./dist" +VERSION=$(git describe --tags --always --dirty 2>/dev/null || echo "dev") +BUILD_TIME=$(date -u +"%Y-%m-%dT%H:%M:%SZ") +COMMIT_HASH=$(git rev-parse --short HEAD 2>/dev/null || echo "unknown") + +# Define target platforms (OS/ARCH) that match install.sh +TARGETS="linux/amd64 linux/arm64 darwin/amd64 darwin/arm64" + +# --- Setup --- +mkdir -p ${DIST_DIR} + +# --- Test --- +echo "--- Running Tests ---" +# This command will run tests in all subdirectories. +go test ./... || { echo "Tests failed"; exit 1; } +echo "--- Tests Finished ---" +echo "" + +# --- Build --- +echo "--- Building Binaries ---" +# Clean the distribution directory +rm -f ${DIST_DIR}/* + +for TARGET in $TARGETS; do + # Split the target into OS and ARCH + GOOS=$(echo $TARGET | cut -f1 -d'/') + GOARCH=$(echo $TARGET | cut -f2 -d'/') + OUTPUT_NAME="${DIST_DIR}/${BINARY_NAME}-${GOOS}-${GOARCH}" + + echo "Building for ${GOOS}/${GOARCH}..." + # Set environment variables and build with version information + env GOOS=$GOOS GOARCH=$GOARCH go build -o $OUTPUT_NAME \ + -ldflags "-X main.Version=${VERSION} -X main.BuildTime=${BUILD_TIME} -X main.CommitHash=${COMMIT_HASH}" \ + $SRC_PATH + + if [ $? -eq 0 ]; then + echo "Successfully built ${OUTPUT_NAME}" + # Create a checksum file for verification + if command -v sha256sum >/dev/null 2>&1; then + sha256sum $OUTPUT_NAME > ${OUTPUT_NAME}.sha256 + elif command -v shasum >/dev/null 2>&1; then + shasum -a 256 $OUTPUT_NAME > ${OUTPUT_NAME}.sha256 + fi + else + echo "Error building for ${GOOS}/${GOARCH}" + exit 1 + fi +done + +# Create a symbolic link for the native platform +NATIVE_OS=$(uname -s | tr '[:upper:]' '[:lower:]') +NATIVE_ARCH=$(uname -m) +if [ "$NATIVE_ARCH" = "x86_64" ]; then + NATIVE_ARCH="amd64" +elif [ "$NATIVE_ARCH" = "aarch64" ] || [ "$NATIVE_ARCH" = "arm64" ]; then + NATIVE_ARCH="arm64" +fi + +NATIVE_BINARY="${DIST_DIR}/${BINARY_NAME}-${NATIVE_OS}-${NATIVE_ARCH}" +if [ -f "$NATIVE_BINARY" ]; then + ln -sf $(basename $NATIVE_BINARY) ${DIST_DIR}/${BINARY_NAME} + echo "Created symbolic link for native platform: ${DIST_DIR}/${BINARY_NAME}" +fi + +echo "--- Build Finished ---" +echo "" + +# --- Deploy --- +echo "--- Staging for Deploy ---" +echo "Adding binaries to Git..." +git add ${DIST_DIR} + +echo "" +echo "Binaries are staged for commit." +echo "To complete the deployment, please run:" +echo " git commit -m \"build: Compile binaries for version ${VERSION}\"" +echo " git push" +echo "" +echo "Build script finished." diff --git a/config.yaml b/config.yaml new file mode 100644 index 0000000..50a4e47 --- /dev/null +++ b/config.yaml @@ -0,0 +1,19 @@ +port: 8091 +services: + # Default adapter service + default: + config: service1.yaml + command: ["./bin/adapter"] + + # Example of a service with command-line arguments + advanced: + config: service2.yaml + command: + - ./bin/adapter + - --verbose + - --timeout=30s + + # Example of a service with an absolute path + external: + config: service3.yaml + command: ["/usr/local/bin/external-adapter", "--config", "adapter-config.json"] \ No newline at end of file diff --git a/dist/mcp-bridge b/dist/mcp-bridge new file mode 120000 index 0000000..1eb0d7d --- /dev/null +++ b/dist/mcp-bridge @@ -0,0 +1 @@ +mcp-bridge-darwin-arm64 \ No newline at end of file diff --git a/dist/mcp-bridge-darwin-amd64 b/dist/mcp-bridge-darwin-amd64 new file mode 100755 index 0000000..6f20242 Binary files /dev/null and b/dist/mcp-bridge-darwin-amd64 differ diff --git a/dist/mcp-bridge-darwin-amd64.sha256 b/dist/mcp-bridge-darwin-amd64.sha256 new file mode 100644 index 0000000..3d640b1 --- /dev/null +++ b/dist/mcp-bridge-darwin-amd64.sha256 @@ -0,0 +1 @@ +4141d7aad25063eaa3f39c519af3b16c7a4a257e8e07c99df3e62ed2ce47412c mcp-bridge-darwin-amd64 diff --git a/dist/mcp-bridge-darwin-arm64 b/dist/mcp-bridge-darwin-arm64 new file mode 100755 index 0000000..e80835a Binary files /dev/null and b/dist/mcp-bridge-darwin-arm64 differ diff --git a/dist/mcp-bridge-darwin-arm64.sha256 b/dist/mcp-bridge-darwin-arm64.sha256 new file mode 100644 index 0000000..8fec1ea --- /dev/null +++ b/dist/mcp-bridge-darwin-arm64.sha256 @@ -0,0 +1 @@ +f83c9ec5369cc93a9fbc7b37908825ae2c0712ae1148aee7d8a3aee112b1372d mcp-bridge-darwin-arm64 diff --git a/dist/mcp-bridge-linux-amd64 b/dist/mcp-bridge-linux-amd64 new file mode 100755 index 0000000..7f7cb71 Binary files /dev/null and b/dist/mcp-bridge-linux-amd64 differ diff --git a/dist/mcp-bridge-linux-amd64.sha256 b/dist/mcp-bridge-linux-amd64.sha256 new file mode 100644 index 0000000..cd3ba22 --- /dev/null +++ b/dist/mcp-bridge-linux-amd64.sha256 @@ -0,0 +1 @@ +1c983def3c89ff9b0cddecd8b2d5adea3148256e4e6677d51859cca1bfcc2980 mcp-bridge-linux-amd64 diff --git a/dist/mcp-bridge-linux-arm64 b/dist/mcp-bridge-linux-arm64 new file mode 100755 index 0000000..f3026ad Binary files /dev/null and b/dist/mcp-bridge-linux-arm64 differ diff --git a/dist/mcp-bridge-linux-arm64.sha256 b/dist/mcp-bridge-linux-arm64.sha256 new file mode 100644 index 0000000..bbf1ae5 --- /dev/null +++ b/dist/mcp-bridge-linux-arm64.sha256 @@ -0,0 +1 @@ +70fd5e751aa363ea1e81a7915bde1324947606e792475c4d6f493e2a238a055f mcp-bridge-linux-arm64 diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..2d75f07 --- /dev/null +++ b/go.mod @@ -0,0 +1,14 @@ +module mcp-bridge + +go 1.24.0 + +require ( + github.com/creachadair/jrpc2 v1.3.1 // indirect + github.com/creachadair/mds v0.23.0 // indirect + github.com/goccy/go-yaml v1.18.0 // indirect + github.com/mark3labs/mcp-go v0.33.0 // indirect + github.com/sirupsen/logrus v1.9.3 // indirect + golang.org/x/sync v0.11.0 // indirect + golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..0b9f352 --- /dev/null +++ b/go.sum @@ -0,0 +1,23 @@ +github.com/creachadair/jrpc2 v1.3.1 h1:4B2R9050CYdhCKepbFVWQVG0/EFqRa9MuuM1Thd7tZo= +github.com/creachadair/jrpc2 v1.3.1/go.mod h1:GtMp2RXHMnrdOY8hWWlbBpjWXSVDXhuO/LMRJAtRFno= +github.com/creachadair/mds v0.23.0 h1:cANHIuKZwbfIoo/zEWA2sn+uGYjqYHuWvpoApkdjGpg= +github.com/creachadair/mds v0.23.0/go.mod h1:ArfS0vPHoLV/SzuIzoqTEZfoYmac7n9Cj8XPANHocvw= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/goccy/go-yaml v1.18.0 h1:8W7wMFS12Pcas7KU+VVkaiCng+kG8QiFeFwzFb+rwuw= +github.com/goccy/go-yaml v1.18.0/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA= +github.com/mark3labs/mcp-go v0.33.0 h1:naxhjnTIs/tyPZmWUZFuG0lDmdA6sUyYGGf3gsHvTCc= +github.com/mark3labs/mcp-go v0.33.0/go.mod h1:rXqOudj/djTORU/ThxYx8fqEVj/5pvTuuebQ2RC7uk4= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ= +github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +golang.org/x/sync v0.11.0 h1:GGz8+XQP4FvTTrjZPzNKTMFtSXH80RAzG+5ghFPgK9w= +golang.org/x/sync v0.11.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= +golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8 h1:0A+M6Uqn+Eje4kHMK80dtF3JCXC4ykBgQG4Fe06QRhQ= +golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/install.sh b/install.sh index e69de29..cc300d6 100755 --- a/install.sh +++ b/install.sh @@ -0,0 +1,155 @@ +#!/bin/bash +# +# This script downloads and installs the mcp-bridge binary for your system. + +set -e + +# Define the repository URL where the binaries are stored. +REPO_URL="https://git.nixc.us/colin/mcp-bridge/raw/branch/main" +BINARY_NAME="mcp-bridge" +DEFAULT_INSTALL_DIR="/usr/local/bin" + +# Parse command line arguments +while [ $# -gt 0 ]; do + case "$1" in + --dir=*) + INSTALL_DIR="${1#*=}" + shift + ;; + --help) + echo "Usage: $0 [options]" + echo "Options:" + echo " --dir=PATH Install the binary to PATH (default: $DEFAULT_INSTALL_DIR)" + echo " --help Show this help message" + exit 0 + ;; + *) + echo "Unknown option: $1" + echo "Run '$0 --help' for usage information." + exit 1 + ;; + esac +done + +# Set default install directory if not specified +INSTALL_DIR="${INSTALL_DIR:-$DEFAULT_INSTALL_DIR}" + +# Create the installation directory if it doesn't exist +mkdir -p "$INSTALL_DIR" 2>/dev/null || true + +# Determine the operating system and architecture. +OS="$(uname -s | tr '[:upper:]' '[:lower:]')" +ARCH="$(uname -m)" + +case "$ARCH" in + x86_64) + ARCH="amd64" + ;; + aarch64 | arm64) + ARCH="arm64" + ;; + *) + echo "Error: Unsupported architecture: $ARCH" + echo "Supported architectures: amd64, arm64" + exit 1 + ;; +esac + +case "$OS" in + linux) + ;; + darwin) + ;; + *) + echo "Error: Unsupported OS: $OS" + echo "Supported operating systems: linux, darwin (macOS)" + exit 1 + ;; +esac + +# Construct the download URL for the binary. +BINARY_URL="${REPO_URL}/dist/${BINARY_NAME}-${OS}-${ARCH}" +CHECKSUM_URL="${BINARY_URL}.sha256" +INSTALL_PATH="${INSTALL_DIR}/${BINARY_NAME}" + +echo "Downloading ${BINARY_NAME} for ${OS}/${ARCH}..." + +# Create a temporary directory for downloads +TMP_DIR=$(mktemp -d) +trap 'rm -rf "$TMP_DIR"' EXIT + +# Download the binary to a temporary path. +# Use curl, and fail if the download fails. +if ! curl -sSL -f -o "${TMP_DIR}/${BINARY_NAME}" "${BINARY_URL}"; then + echo "Error: Failed to download binary from ${BINARY_URL}" + echo "Please check the URL and ensure that a binary for your system (${OS}/${ARCH}) exists." + exit 1 +fi + +# Download the checksum if available +if curl -sSL -f -o "${TMP_DIR}/${BINARY_NAME}.sha256" "${CHECKSUM_URL}" 2>/dev/null; then + echo "Verifying checksum..." + + # Extract the expected checksum + EXPECTED_CHECKSUM=$(cat "${TMP_DIR}/${BINARY_NAME}.sha256" | awk '{print $1}') + + # Calculate the actual checksum + if command -v sha256sum >/dev/null 2>&1; then + ACTUAL_CHECKSUM=$(sha256sum "${TMP_DIR}/${BINARY_NAME}" | awk '{print $1}') + elif command -v shasum >/dev/null 2>&1; then + ACTUAL_CHECKSUM=$(shasum -a 256 "${TMP_DIR}/${BINARY_NAME}" | awk '{print $1}') + else + echo "Warning: Could not verify checksum (sha256sum/shasum not found)" + ACTUAL_CHECKSUM=$EXPECTED_CHECKSUM # Skip verification + fi + + # Verify the checksum + if [ "$EXPECTED_CHECKSUM" != "$ACTUAL_CHECKSUM" ]; then + echo "Error: Checksum verification failed!" + echo "Expected: $EXPECTED_CHECKSUM" + echo "Actual: $ACTUAL_CHECKSUM" + exit 1 + fi + + echo "Checksum verified successfully." +fi + +echo "Installing ${BINARY_NAME} to ${INSTALL_PATH}" + +# Make the binary executable +chmod +x "${TMP_DIR}/${BINARY_NAME}" + +# Move the binary to the installation directory. +# This may require sudo privileges if the user doesn't have write access. +if [ -w "$INSTALL_DIR" ]; then + mv "${TMP_DIR}/${BINARY_NAME}" "$INSTALL_PATH" +else + echo "Write access to ${INSTALL_DIR} is required." + # Try to use sudo if available + if command -v sudo >/dev/null 2>&1; then + echo "Using sudo to install to ${INSTALL_PATH}" + sudo mv "${TMP_DIR}/${BINARY_NAME}" "$INSTALL_PATH" + else + echo "Error: Cannot write to ${INSTALL_DIR} and sudo is not available." + echo "Please run this script with sudo or specify a different installation directory with --dir=PATH." + exit 1 + fi +fi + +echo "${BINARY_NAME} installed successfully to ${INSTALL_PATH}!" + +# Check if the installation directory is in PATH +if ! echo "$PATH" | tr ':' '\n' | grep -q "^$INSTALL_DIR$"; then + echo "" + echo "Warning: ${INSTALL_DIR} is not in your PATH." + echo "You may need to add it to your PATH to run ${BINARY_NAME} without specifying the full path." + echo "" + echo "For bash/zsh, add the following to your ~/.bashrc or ~/.zshrc:" + echo " export PATH=\$PATH:${INSTALL_DIR}" + echo "" + echo "Then reload your shell or run: source ~/.bashrc (or ~/.zshrc)" +fi + +echo "" +echo "You can now run '${BINARY_NAME}' to start the MCP bridge." +echo "Run '${BINARY_NAME} --help' for usage information." diff --git a/service1.yaml b/service1.yaml new file mode 100644 index 0000000..5f8cca4 --- /dev/null +++ b/service1.yaml @@ -0,0 +1,16 @@ +serviceName: "default" +endpoints: + # Basic endpoint for tools/call + - path: "/tools/call" + mcp_method: "tools/call" + tool_name: "tools/call" + + # Example endpoint for a specific tool + - path: "/tools/image-generation" + mcp_method: "tools/call" + tool_name: "image_generation" + + # Example endpoint for a custom method + - path: "/custom/method" + mcp_method: "custom.method" + tool_name: "custom_tool" \ No newline at end of file diff --git a/service2.yaml b/service2.yaml new file mode 100644 index 0000000..dd5694b --- /dev/null +++ b/service2.yaml @@ -0,0 +1,19 @@ +serviceName: "advanced" +endpoints: + # API endpoints for a more complex service + - path: "/api/v1/chat/completions" + mcp_method: "chat.completions" + tool_name: "chat_completions" + + - path: "/api/v1/embeddings" + mcp_method: "embeddings.create" + tool_name: "embeddings" + + - path: "/api/v1/models" + mcp_method: "models.list" + tool_name: "models" + + # Versioned endpoints + - path: "/api/v2/chat/completions" + mcp_method: "chat.completions.v2" + tool_name: "chat_completions_v2" \ No newline at end of file diff --git a/service3.yaml b/service3.yaml new file mode 100644 index 0000000..7ec0303 --- /dev/null +++ b/service3.yaml @@ -0,0 +1,16 @@ +serviceName: "external" +endpoints: + # Example of an external service with specialized endpoints + - path: "/external/function" + mcp_method: "function.execute" + tool_name: "function_executor" + + # Example of a webhook endpoint + - path: "/webhooks/event" + mcp_method: "webhooks.process" + tool_name: "webhook_processor" + + # Example of a streaming endpoint + - path: "/stream/data" + mcp_method: "stream.data" + tool_name: "data_streamer" \ No newline at end of file diff --git a/src/handler/handler.go b/src/handler/handler.go new file mode 100644 index 0000000..67283e3 --- /dev/null +++ b/src/handler/handler.go @@ -0,0 +1,144 @@ +package handler + +import ( + "context" + "encoding/json" + "fmt" + "io/ioutil" + "net/http" + "time" + + "mcp-bridge/src/logger" + + "github.com/creachadair/jrpc2" +) + +// RPCClient defines the interface for a JSON-RPC client +type RPCClient interface { + CallResult(ctx context.Context, method string, params interface{}, result interface{}) error + Close() error +} + +// ServiceHandler manages HTTP requests for a specific service +type ServiceHandler struct { + Client RPCClient + ServiceName string + Path string + McpMethod string + ToolName string +} + +// NewServiceHandler creates a new ServiceHandler +func NewServiceHandler(client RPCClient, serviceName, path, mcpMethod, toolName string) *ServiceHandler { + return &ServiceHandler{ + Client: client, + ServiceName: serviceName, + Path: path, + McpMethod: mcpMethod, + ToolName: toolName, + } +} + +// ServeHTTP handles HTTP requests for the service +func (h *ServiceHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { + reqLogger := logger.WithFields(logger.Fields{ + "path": h.Path, + "service": h.ServiceName, + "method": r.Method, + "remote_ip": r.RemoteAddr, + "user_agent": r.UserAgent(), + "request_id": r.Header.Get("X-Request-ID"), + }) + reqLogger.Info("Handling request") + + // Check if the client is available + if h.Client == nil { + reqLogger.Error("Service client is nil") + http.Error(w, fmt.Sprintf("Service %s not found", h.ServiceName), http.StatusInternalServerError) + return + } + + body, err := ioutil.ReadAll(r.Body) + if err != nil { + reqLogger.WithFields(logger.Fields{ + "error": err, + }).Error("Error reading request body") + http.Error(w, "Error reading request body", http.StatusInternalServerError) + return + } + defer r.Body.Close() + + var params json.RawMessage + if len(body) > 0 { + if err := json.Unmarshal(body, ¶ms); err != nil { + reqLogger.WithFields(logger.Fields{ + "error": err, + }).Error("Error parsing JSON body") + http.Error(w, "Error parsing JSON body", http.StatusBadRequest) + return + } + } + + // Call the adapter + var result json.RawMessage + ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second) + defer cancel() + + startTime := time.Now() + if err := h.Client.CallResult(ctx, h.McpMethod, params, &result); err != nil { + reqLogger.WithFields(logger.Fields{ + "error": err, + "duration_ms": time.Since(startTime).Milliseconds(), + }).Error("RPC call failed") + + // Check for JSON-RPC specific errors + if jrpcErr, ok := err.(*jrpc2.Error); ok { + w.WriteHeader(http.StatusBadRequest) + json.NewEncoder(w).Encode(jrpcErr) + } else { + http.Error(w, fmt.Sprintf("RPC call failed: %v", err), http.StatusInternalServerError) + } + return + } + + reqLogger.WithFields(logger.Fields{ + "duration_ms": time.Since(startTime).Milliseconds(), + }).Info("Request completed successfully") + + // Write the successful response back + w.Header().Set("Content-Type", "application/json") + w.Write(result) +} + +// ReloadHandler handles HTTP requests to reload the configuration +type ReloadHandler struct { + ReloadFunc func() error +} + +// NewReloadHandler creates a new ReloadHandler +func NewReloadHandler(reloadFunc func() error) *ReloadHandler { + return &ReloadHandler{ + ReloadFunc: reloadFunc, + } +} + +// ServeHTTP handles HTTP requests for configuration reload +func (h *ReloadHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) + return + } + + logger.Info("Received reload request via HTTP endpoint") + + if err := h.ReloadFunc(); err != nil { + logger.WithFields(logger.Fields{ + "error": err, + }).Error("Failed to reload configuration") + http.Error(w, fmt.Sprintf("Failed to reload: %v", err), http.StatusInternalServerError) + return + } + + w.WriteHeader(http.StatusAccepted) + w.Write([]byte(`{"status":"reload initiated"}`)) +} diff --git a/src/handler/handler_test.go b/src/handler/handler_test.go new file mode 100644 index 0000000..a299abe --- /dev/null +++ b/src/handler/handler_test.go @@ -0,0 +1,300 @@ +package handler + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "net/http" + "net/http/httptest" + "testing" + + "mcp-bridge/src/logger" + + "github.com/creachadair/jrpc2" +) + +// MockRPCClient is a mock implementation of the RPCClient interface for testing +type MockRPCClient struct { + CallResultFunc func(ctx context.Context, method string, params interface{}, result interface{}) error + CloseFunc func() error +} + +func (m *MockRPCClient) CallResult(ctx context.Context, method string, params interface{}, result interface{}) error { + if m.CallResultFunc != nil { + return m.CallResultFunc(ctx, method, params, result) + } + return nil +} + +func (m *MockRPCClient) Close() error { + if m.CloseFunc != nil { + return m.CloseFunc() + } + return nil +} + +func init() { + // Initialize logger with a test level + logger.Initialize("error") // Use error level to minimize test output +} + +func TestServiceHandler_ServeHTTP_Success(t *testing.T) { + // Create a mock client that returns a successful response + mockClient := &MockRPCClient{ + CallResultFunc: func(ctx context.Context, method string, params interface{}, result interface{}) error { + // Cast result to json.RawMessage and set a test response + if r, ok := result.(*json.RawMessage); ok { + *r = json.RawMessage(`{"result":"success"}`) + } + return nil + }, + } + + // Create a service handler + handler := NewServiceHandler(mockClient, "test-service", "/test", "test.method", "test-tool") + + // Create a test request + reqBody := bytes.NewBufferString(`{"param":"value"}`) + req := httptest.NewRequest(http.MethodPost, "/test", reqBody) + req.Header.Set("Content-Type", "application/json") + req.Header.Set("X-Request-ID", "test-request-id") + + // Create a response recorder + rr := httptest.NewRecorder() + + // Call the handler + handler.ServeHTTP(rr, req) + + // Check the status code + if status := rr.Code; status != http.StatusOK { + t.Errorf("handler returned wrong status code: got %v want %v", status, http.StatusOK) + } + + // Check the content type + contentType := rr.Header().Get("Content-Type") + if contentType != "application/json" { + t.Errorf("handler returned wrong content type: got %v want %v", contentType, "application/json") + } + + // Check the response body + expected := `{"result":"success"}` + if rr.Body.String() != expected { + t.Errorf("handler returned unexpected body: got %v want %v", rr.Body.String(), expected) + } +} + +func TestServiceHandler_ServeHTTP_InvalidJSON(t *testing.T) { + // Create a mock client + mockClient := &MockRPCClient{} + + // Create a service handler + handler := NewServiceHandler(mockClient, "test-service", "/test", "test.method", "test-tool") + + // Create a test request with invalid JSON + reqBody := bytes.NewBufferString(`{"param":invalid}`) + req := httptest.NewRequest(http.MethodPost, "/test", reqBody) + req.Header.Set("Content-Type", "application/json") + + // Create a response recorder + rr := httptest.NewRecorder() + + // Call the handler + handler.ServeHTTP(rr, req) + + // Check the status code + if status := rr.Code; status != http.StatusBadRequest { + t.Errorf("handler returned wrong status code: got %v want %v", status, http.StatusBadRequest) + } +} + +func TestServiceHandler_ServeHTTP_RPCError(t *testing.T) { + // Create a mock client that returns an RPC error + mockClient := &MockRPCClient{ + CallResultFunc: func(ctx context.Context, method string, params interface{}, result interface{}) error { + return &jrpc2.Error{ + Code: -32602, + Message: "Invalid params", + } + }, + } + + // Create a service handler + handler := NewServiceHandler(mockClient, "test-service", "/test", "test.method", "test-tool") + + // Create a test request + reqBody := bytes.NewBufferString(`{"param":"value"}`) + req := httptest.NewRequest(http.MethodPost, "/test", reqBody) + req.Header.Set("Content-Type", "application/json") + + // Create a response recorder + rr := httptest.NewRecorder() + + // Call the handler + handler.ServeHTTP(rr, req) + + // Check the status code + if status := rr.Code; status != http.StatusBadRequest { + t.Errorf("handler returned wrong status code: got %v want %v", status, http.StatusBadRequest) + } + + // Check that the response contains the error + var respError jrpc2.Error + if err := json.Unmarshal(rr.Body.Bytes(), &respError); err != nil { + t.Errorf("failed to unmarshal response: %v", err) + } + + if respError.Code != -32602 || respError.Message != "Invalid params" { + t.Errorf("handler returned unexpected error: got %v want code=%v message=%v", + respError, -32602, "Invalid params") + } +} + +func TestServiceHandler_ServeHTTP_GenericError(t *testing.T) { + // Create a mock client that returns a generic error + mockClient := &MockRPCClient{ + CallResultFunc: func(ctx context.Context, method string, params interface{}, result interface{}) error { + return errors.New("generic error") + }, + } + + // Create a service handler + handler := NewServiceHandler(mockClient, "test-service", "/test", "test.method", "test-tool") + + // Create a test request + reqBody := bytes.NewBufferString(`{"param":"value"}`) + req := httptest.NewRequest(http.MethodPost, "/test", reqBody) + req.Header.Set("Content-Type", "application/json") + + // Create a response recorder + rr := httptest.NewRecorder() + + // Call the handler + handler.ServeHTTP(rr, req) + + // Check the status code + if status := rr.Code; status != http.StatusInternalServerError { + t.Errorf("handler returned wrong status code: got %v want %v", status, http.StatusInternalServerError) + } + + // Check that the response contains the error message + expected := "RPC call failed: generic error\n" + if rr.Body.String() != expected { + t.Errorf("handler returned unexpected body: got %v want %v", rr.Body.String(), expected) + } +} + +func TestServiceHandler_ServeHTTP_NilClient(t *testing.T) { + // Create a service handler with a nil client + handler := NewServiceHandler(nil, "test-service", "/test", "test.method", "test-tool") + + // Create a test request + reqBody := bytes.NewBufferString(`{"param":"value"}`) + req := httptest.NewRequest(http.MethodPost, "/test", reqBody) + req.Header.Set("Content-Type", "application/json") + + // Create a response recorder + rr := httptest.NewRecorder() + + // Call the handler + handler.ServeHTTP(rr, req) + + // Check the status code + if status := rr.Code; status != http.StatusInternalServerError { + t.Errorf("handler returned wrong status code: got %v want %v", status, http.StatusInternalServerError) + } + + // Check that the response contains the error message + expected := "Service test-service not found\n" + if rr.Body.String() != expected { + t.Errorf("handler returned unexpected body: got %v want %v", rr.Body.String(), expected) + } +} + +func TestReloadHandler_ServeHTTP_Success(t *testing.T) { + // Create a reload handler with a successful reload function + reloadCalled := false + handler := NewReloadHandler(func() error { + reloadCalled = true + return nil + }) + + // Create a test request + req := httptest.NewRequest(http.MethodPost, "/reload", nil) + + // Create a response recorder + rr := httptest.NewRecorder() + + // Call the handler + handler.ServeHTTP(rr, req) + + // Check that the reload function was called + if !reloadCalled { + t.Error("reload function was not called") + } + + // Check the status code + if status := rr.Code; status != http.StatusAccepted { + t.Errorf("handler returned wrong status code: got %v want %v", status, http.StatusAccepted) + } + + // Check the response body + expected := `{"status":"reload initiated"}` + if rr.Body.String() != expected { + t.Errorf("handler returned unexpected body: got %v want %v", rr.Body.String(), expected) + } +} + +func TestReloadHandler_ServeHTTP_Error(t *testing.T) { + // Create a reload handler with an error-returning reload function + handler := NewReloadHandler(func() error { + return errors.New("reload error") + }) + + // Create a test request + req := httptest.NewRequest(http.MethodPost, "/reload", nil) + + // Create a response recorder + rr := httptest.NewRecorder() + + // Call the handler + handler.ServeHTTP(rr, req) + + // Check the status code + if status := rr.Code; status != http.StatusInternalServerError { + t.Errorf("handler returned wrong status code: got %v want %v", status, http.StatusInternalServerError) + } + + // Check that the response contains the error message + expected := "Failed to reload: reload error\n" + if rr.Body.String() != expected { + t.Errorf("handler returned unexpected body: got %v want %v", rr.Body.String(), expected) + } +} + +func TestReloadHandler_ServeHTTP_MethodNotAllowed(t *testing.T) { + // Create a reload handler + handler := NewReloadHandler(func() error { + return nil + }) + + // Create a test request with an unsupported method + req := httptest.NewRequest(http.MethodGet, "/reload", nil) + + // Create a response recorder + rr := httptest.NewRecorder() + + // Call the handler + handler.ServeHTTP(rr, req) + + // Check the status code + if status := rr.Code; status != http.StatusMethodNotAllowed { + t.Errorf("handler returned wrong status code: got %v want %v", status, http.StatusMethodNotAllowed) + } + + // Check the response body + expected := "Method not allowed\n" + if rr.Body.String() != expected { + t.Errorf("handler returned unexpected body: got %v want %v", rr.Body.String(), expected) + } +} diff --git a/src/logger/logger.go b/src/logger/logger.go new file mode 100644 index 0000000..724bfcf --- /dev/null +++ b/src/logger/logger.go @@ -0,0 +1,126 @@ +package logger + +import ( + "net/http" + "os" + "time" + + "github.com/sirupsen/logrus" +) + +var ( + // Log is the global logger instance + Log *logrus.Logger +) + +// Fields type, used to pass to functions like WithFields. +type Fields = logrus.Fields + +// Initialize sets up the logger with the specified log level +func Initialize(level string) { + Log = logrus.New() + Log.SetOutput(os.Stdout) + Log.SetFormatter(&logrus.JSONFormatter{ + TimestampFormat: "2006-01-02T15:04:05.000Z07:00", + }) + + // Set log level based on the provided string + switch level { + case "debug": + Log.SetLevel(logrus.DebugLevel) + case "info": + Log.SetLevel(logrus.InfoLevel) + case "warn": + Log.SetLevel(logrus.WarnLevel) + case "error": + Log.SetLevel(logrus.ErrorLevel) + default: + Log.SetLevel(logrus.InfoLevel) + } +} + +// Debug logs a message at level Debug +func Debug(args ...interface{}) { + if Log == nil { + Initialize("info") + } + Log.Debug(args...) +} + +// Info logs a message at level Info +func Info(args ...interface{}) { + if Log == nil { + Initialize("info") + } + Log.Info(args...) +} + +// Warn logs a message at level Warn +func Warn(args ...interface{}) { + if Log == nil { + Initialize("info") + } + Log.Warn(args...) +} + +// Error logs a message at level Error +func Error(args ...interface{}) { + if Log == nil { + Initialize("info") + } + Log.Error(args...) +} + +// Fatal logs a message at level Fatal then the process will exit with status set to 1 +func Fatal(args ...interface{}) { + if Log == nil { + Initialize("info") + } + Log.Fatal(args...) +} + +// WithFields creates an entry from the standard logger and adds multiple fields +func WithFields(fields Fields) *logrus.Entry { + if Log == nil { + Initialize("info") + } + return Log.WithFields(fields) +} + +// HTTPMiddleware logs HTTP requests +func HTTPMiddleware(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + start := time.Now() + + // Create a response writer wrapper to capture the status code + rw := &responseWriter{ + ResponseWriter: w, + statusCode: http.StatusOK, // Default status code + } + + // Process the request + next.ServeHTTP(rw, r) + + // Log the request details + WithFields(Fields{ + "method": r.Method, + "path": r.URL.Path, + "status": rw.statusCode, + "user_agent": r.UserAgent(), + "remote_ip": r.RemoteAddr, + "duration": time.Since(start).Milliseconds(), + }).Info("HTTP request") + }) +} + +// responseWriter is a wrapper around http.ResponseWriter to capture the status code +type responseWriter struct { + http.ResponseWriter + statusCode int +} + +// WriteHeader captures the status code +func (rw *responseWriter) WriteHeader(code int) { + rw.statusCode = code + rw.ResponseWriter.WriteHeader(code) +} diff --git a/src/main.go b/src/main.go new file mode 100644 index 0000000..7c85378 --- /dev/null +++ b/src/main.go @@ -0,0 +1,570 @@ +package main + +import ( + "context" + "encoding/json" + "flag" + "fmt" + "io/ioutil" + "net/http" + "os" + "os/exec" + "os/signal" + "strconv" + "sync" + "syscall" + "time" + + "github.com/creachadair/jrpc2" + "github.com/creachadair/jrpc2/channel" + "github.com/goccy/go-yaml" + + "mcp-bridge/src/logger" +) + +// Version information set by build flags +var ( + Version = "dev" + BuildTime = "unknown" + CommitHash = "unknown" +) + +type ServiceConfig struct { + Config string `yaml:"config"` + Command []string `yaml:"command"` +} + +type EndpointConfig struct { + Path string `yaml:"path"` + McpMethod string `yaml:"mcp_method"` + ToolName string `yaml:"tool_name"` +} + +type ServiceDetails struct { + ServiceName string `yaml:"serviceName"` + Endpoints []EndpointConfig `yaml:"endpoints"` +} + +type Config struct { + Port int `yaml:"port"` + Services map[string]ServiceConfig `yaml:"services"` +} + +// loadConfig loads the configuration from the config file +func loadConfig(configFile string) (*Config, error) { + data, err := ioutil.ReadFile(configFile) + if err != nil { + return nil, fmt.Errorf("error reading %s: %v", configFile, err) + } + + var config Config + err = yaml.Unmarshal(data, &config) + if err != nil { + return nil, fmt.Errorf("error unmarshalling %s: %v", configFile, err) + } + + return &config, nil +} + +// loadServiceConfig loads the service configuration from the config file +func loadServiceConfig(configFile string) (*ServiceDetails, error) { + data, err := ioutil.ReadFile(configFile) + if err != nil { + return nil, fmt.Errorf("error reading %s: %v", configFile, err) + } + + var serviceDetails ServiceDetails + err = yaml.Unmarshal(data, &serviceDetails) + if err != nil { + return nil, fmt.Errorf("error unmarshalling %s: %v", configFile, err) + } + + return &serviceDetails, nil +} + +func main() { + // Define command-line flags + port := flag.Int("port", 0, "port to listen on") + verbosity := flag.String("v", "info", "log level (debug, info, warn, error)") + configFile := flag.String("config", "config.yaml", "path to the configuration file") + showVersion := flag.Bool("version", false, "show version information and exit") + flag.Parse() + + // Show version information if requested + if *showVersion { + fmt.Printf("mcp-bridge version %s\n", Version) + fmt.Printf("Build time: %s\n", BuildTime) + fmt.Printf("Commit: %s\n", CommitHash) + return + } + + // Initialize the logger + logger.Initialize(*verbosity) + + // Log version information + logger.WithFields(logger.Fields{ + "version": Version, + "build_time": BuildTime, + "commit": CommitHash, + }).Info("Starting mcp-bridge") + + // Store child processes and a WaitGroup to wait for them + childProcesses := make(map[string]*exec.Cmd) + jrpcClients := make(map[string]*jrpc2.Client) + var wg sync.WaitGroup + + // Create a channel for SIGHUP signal + sighupCh := make(chan os.Signal, 1) + + // Create a context that can be canceled for reload operations + ctx, cancelCtx := context.WithCancel(context.Background()) + defer cancelCtx() + + // Defer the cleanup of child processes. This will run when main exits. + defer func() { + logger.Info("Closing JSON-RPC clients...") + for name, client := range jrpcClients { + if err := client.Close(); err != nil { + logger.WithFields(logger.Fields{ + "service": name, + "error": err, + }).Error("Error closing client") + } + } + + logger.Info("Shutting down adapters...") + for name, cmd := range childProcesses { + if cmd.Process == nil { + continue + } + logger.WithFields(logger.Fields{ + "service": name, + "pid": cmd.Process.Pid, + }).Info("Sending SIGTERM to process group") + if err := syscall.Kill(-cmd.Process.Pid, syscall.SIGTERM); err != nil { + logger.WithFields(logger.Fields{ + "service": name, + "pid": cmd.Process.Pid, + "error": err, + }).Error("Failed to send SIGTERM to process group") + } + } + + done := make(chan struct{}) + go func() { + wg.Wait() + close(done) + }() + + select { + case <-done: + logger.Info("All adapter processes shut down gracefully.") + case <-time.After(2 * time.Second): + logger.Warn("Timed out waiting for adapter processes to exit. Forcing shutdown...") + for name, cmd := range childProcesses { + if cmd.ProcessState == nil || !cmd.ProcessState.Exited() { + logger.WithFields(logger.Fields{ + "service": name, + "pid": cmd.Process.Pid, + }).Info("Sending SIGKILL to process group") + if err := syscall.Kill(-cmd.Process.Pid, syscall.SIGKILL); err != nil { + logger.WithFields(logger.Fields{ + "service": name, + "pid": cmd.Process.Pid, + "error": err, + }).Error("Failed to kill process group") + } + } + } + wg.Wait() // Wait again after killing + logger.Info("All adapter processes shut down.") + } + }() + + // Load the initial configuration + config, err := loadConfig(*configFile) + if err != nil { + logger.WithFields(logger.Fields{ + "error": err, + }).Fatal("Error loading configuration") + return + } + + // Override port with the flag if it was provided + if *port != 0 { + config.Port = *port + } + + logger.WithFields(logger.Fields{ + "port": config.Port, + }).Info("Configuration loaded") + + // Function to initialize or reload services + initializeServices := func(config *Config) error { + // Clear existing HTTP handlers + http.DefaultServeMux = http.NewServeMux() + + // Initialize services, start child processes, and set up HTTP handlers + for name, service := range config.Services { + logger.WithFields(logger.Fields{ + "service": name, + "config_file": service.Config, + "command": service.Command, + }).Info("Initializing service") + + // Check if the service is already running + if cmd, exists := childProcesses[name]; exists && cmd.Process != nil { + logger.WithFields(logger.Fields{ + "service": name, + "pid": cmd.Process.Pid, + }).Info("Service already running, reloading configuration") + + // Close the existing JSON-RPC client + if client, ok := jrpcClients[name]; ok { + if err := client.Close(); err != nil { + logger.WithFields(logger.Fields{ + "service": name, + "error": err, + }).Error("Error closing client during reload") + } + delete(jrpcClients, name) + } + + // Send SIGTERM to the process group + if err := syscall.Kill(-cmd.Process.Pid, syscall.SIGTERM); err != nil { + logger.WithFields(logger.Fields{ + "service": name, + "pid": cmd.Process.Pid, + "error": err, + }).Error("Failed to send SIGTERM to process group during reload") + } + + // Wait for the process to exit + done := make(chan struct{}) + go func() { + cmd.Wait() + close(done) + }() + + select { + case <-done: + logger.WithFields(logger.Fields{ + "service": name, + }).Info("Service shut down gracefully during reload") + case <-time.After(2 * time.Second): + logger.WithFields(logger.Fields{ + "service": name, + "pid": cmd.Process.Pid, + }).Warn("Timed out waiting for service to exit during reload, forcing shutdown") + if err := syscall.Kill(-cmd.Process.Pid, syscall.SIGKILL); err != nil { + logger.WithFields(logger.Fields{ + "service": name, + "pid": cmd.Process.Pid, + "error": err, + }).Error("Failed to kill process group during reload") + } + cmd.Wait() + } + + delete(childProcesses, name) + } + + // Start the MCP adapter as a child process + if len(service.Command) > 0 { + cmd := exec.Command(service.Command[0], service.Command[1:]...) + cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true} + stdin, err := cmd.StdinPipe() + if err != nil { + logger.WithFields(logger.Fields{ + "service": name, + "error": err, + }).Error("Error getting stdin pipe") + continue + } + stdout, err := cmd.StdoutPipe() + if err != nil { + logger.WithFields(logger.Fields{ + "service": name, + "error": err, + }).Error("Error getting stdout pipe") + continue + } + + cmd.Stderr = os.Stderr + err = cmd.Start() + if err != nil { + logger.WithFields(logger.Fields{ + "service": name, + "error": err, + }).Error("Error starting service") + continue // Skip this service if it fails to start + } + + logger.WithFields(logger.Fields{ + "service": name, + "pid": cmd.Process.Pid, + }).Info("Service started") + childProcesses[name] = cmd + wg.Add(1) + + // Set up the JSON-RPC client. + // The client will run in the background and stop automatically when its + // underlying channel is closed. + opts := &jrpc2.ClientOptions{ + OnStop: func(_ *jrpc2.Client, err error) { + if err != nil { + logger.WithFields(logger.Fields{ + "service": name, + "error": err, + }).Error("JSON-RPC client disconnected with error") + } else { + logger.WithFields(logger.Fields{ + "service": name, + }).Info("JSON-RPC client disconnected gracefully") + } + }, + } + ch := channel.Line(stdout, stdin) + jrpcClients[name] = jrpc2.NewClient(ch, opts) + + // Goroutine to wait for the process to exit + go func(name string, cmd *exec.Cmd) { + defer wg.Done() + if err := cmd.Wait(); err != nil { + logger.WithFields(logger.Fields{ + "service": name, + "error": err, + }).Error("Service exited with error") + } else { + logger.WithFields(logger.Fields{ + "service": name, + }).Info("Service exited gracefully") + } + }(name, cmd) + } + + // Read the service config file + serviceDetails, err := loadServiceConfig(service.Config) + if err != nil { + logger.WithFields(logger.Fields{ + "service": name, + "config_file": service.Config, + "error": err, + }).Error("Error loading service config") + continue + } + + logger.WithFields(logger.Fields{ + "service": name, + "service_name": serviceDetails.ServiceName, + }).Info("Service details loaded") + + for _, endpoint := range serviceDetails.Endpoints { + logger.WithFields(logger.Fields{ + "service": name, + "path": endpoint.Path, + "mcp_method": endpoint.McpMethod, + "tool_name": endpoint.ToolName, + }).Info("Registering endpoint") + + // Use a closure to capture the endpoint and service name correctly + ep := endpoint + serviceName := name + http.HandleFunc(ep.Path, func(w http.ResponseWriter, r *http.Request) { + reqLogger := logger.WithFields(logger.Fields{ + "path": ep.Path, + "service": serviceName, + "method": r.Method, + "remote_ip": r.RemoteAddr, + "user_agent": r.UserAgent(), + "request_id": r.Header.Get("X-Request-ID"), + }) + reqLogger.Info("Handling request") + + // Get the client for this service + client, ok := jrpcClients[serviceName] + if !ok { + reqLogger.Error("Service not found in jrpcClients map") + http.Error(w, fmt.Sprintf("Service %s not found", serviceName), http.StatusInternalServerError) + return + } + + body, err := ioutil.ReadAll(r.Body) + if err != nil { + reqLogger.WithFields(logger.Fields{ + "error": err, + }).Error("Error reading request body") + http.Error(w, "Error reading request body", http.StatusInternalServerError) + return + } + defer r.Body.Close() + + var params json.RawMessage + if len(body) > 0 { + if err := json.Unmarshal(body, ¶ms); err != nil { + reqLogger.WithFields(logger.Fields{ + "error": err, + }).Error("Error parsing JSON body") + http.Error(w, "Error parsing JSON body", http.StatusBadRequest) + return + } + } + + // Call the adapter + var result json.RawMessage + reqCtx, cancel := context.WithTimeout(ctx, 10*time.Second) + defer cancel() + + startTime := time.Now() + if err := client.CallResult(reqCtx, ep.McpMethod, params, &result); err != nil { + reqLogger.WithFields(logger.Fields{ + "error": err, + "duration_ms": time.Since(startTime).Milliseconds(), + }).Error("RPC call failed") + + // Check for JSON-RPC specific errors + if jrpcErr, ok := err.(*jrpc2.Error); ok { + w.WriteHeader(http.StatusBadRequest) + json.NewEncoder(w).Encode(jrpcErr) + } else { + http.Error(w, fmt.Sprintf("RPC call failed: %v", err), http.StatusInternalServerError) + } + return + } + + reqLogger.WithFields(logger.Fields{ + "duration_ms": time.Since(startTime).Milliseconds(), + }).Info("Request completed successfully") + + // Write the successful response back + w.Header().Set("Content-Type", "application/json") + w.Write(result) + }) + } + } + + // Register the /reload endpoint + http.HandleFunc("/reload", func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) + return + } + + logger.Info("Received reload request via HTTP endpoint") + + // Send SIGHUP to self + process, err := os.FindProcess(os.Getpid()) + if err != nil { + logger.WithFields(logger.Fields{ + "error": err, + }).Error("Failed to find own process") + http.Error(w, "Failed to initiate reload", http.StatusInternalServerError) + return + } + + if err := process.Signal(syscall.SIGHUP); err != nil { + logger.WithFields(logger.Fields{ + "error": err, + }).Error("Failed to send SIGHUP to self") + http.Error(w, "Failed to initiate reload", http.StatusInternalServerError) + return + } + + w.WriteHeader(http.StatusAccepted) + w.Write([]byte(`{"status":"reload initiated"}`)) + }) + + return nil + } + + // Initialize services with the initial configuration + if err := initializeServices(config); err != nil { + logger.WithFields(logger.Fields{ + "error": err, + }).Fatal("Failed to initialize services") + return + } + + // Create a new HTTP server with the logger middleware + portStr := strconv.Itoa(config.Port) + server := &http.Server{ + Addr: ":" + portStr, + Handler: logger.HTTPMiddleware(http.DefaultServeMux), + } + + // Start the HTTP server + serverErr := make(chan error, 1) + go func() { + logger.WithFields(logger.Fields{ + "port": portStr, + }).Info("Starting server") + if err := server.ListenAndServe(); err != nil && err != http.ErrServerClosed { + serverErr <- err + } + }() + + // Set up signal handling for graceful shutdown and reload + c := make(chan os.Signal, 1) + signal.Notify(c, os.Interrupt, syscall.SIGTERM) + signal.Notify(sighupCh, syscall.SIGHUP) + + // Handle SIGHUP for configuration reload + go func() { + for { + select { + case <-sighupCh: + logger.Info("Received SIGHUP signal, reloading configuration") + + // Load the updated configuration + newConfig, err := loadConfig(*configFile) + if err != nil { + logger.WithFields(logger.Fields{ + "error": err, + }).Error("Failed to reload configuration, continuing with existing configuration") + continue + } + + // Keep the port from the current configuration + newConfig.Port = config.Port + + // Update the global configuration + config = newConfig + + // Reload services + if err := initializeServices(config); err != nil { + logger.WithFields(logger.Fields{ + "error": err, + }).Error("Failed to reload services") + } else { + logger.Info("Configuration reload completed successfully") + } + case <-ctx.Done(): + return + } + } + }() + + // Block until a shutdown signal or server error + select { + case err := <-serverErr: + logger.WithFields(logger.Fields{ + "error": err, + }).Error("HTTP server error. Shutting down.") + case sig := <-c: + logger.WithFields(logger.Fields{ + "signal": sig, + }).Info("Received signal. Shutting down.") + } + + // Gracefully shut down the HTTP server + logger.Info("Shutting down server...") + shutdownCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + if err := server.Shutdown(shutdownCtx); err != nil { + logger.WithFields(logger.Fields{ + "error": err, + }).Error("Server shutdown failed") + } + + logger.Info("Main function finished. Deferred cleanup will now run.") +} diff --git a/src/mcp-adapter-default/main.go b/src/mcp-adapter-default/main.go new file mode 100644 index 0000000..5bde7b0 --- /dev/null +++ b/src/mcp-adapter-default/main.go @@ -0,0 +1,20 @@ +package main + +import ( + "bufio" + "fmt" + "os" +) + +func main() { + fmt.Println("MCP adapter started") + + scanner := bufio.NewScanner(os.Stdin) + for scanner.Scan() { + fmt.Printf("Adapter received: %s\n", scanner.Text()) + } + + if err := scanner.Err(); err != nil { + fmt.Fprintf(os.Stderr, "error reading stdin: %v\n", err) + } +} diff --git a/test/adapter/main.go b/test/adapter/main.go new file mode 100644 index 0000000..0f231f4 --- /dev/null +++ b/test/adapter/main.go @@ -0,0 +1,128 @@ +package main + +import ( + "context" + "encoding/json" + "log" + "os" + "os/signal" + "syscall" + + "github.com/creachadair/jrpc2" + "github.com/creachadair/jrpc2/channel" + "github.com/creachadair/jrpc2/handler" +) + +// EchoRequest is the request structure for the echo method +type EchoRequest struct { + Message string `json:"message"` +} + +// EchoResponse is the response structure for the echo method +type EchoResponse struct { + Message string `json:"message"` + Status string `json:"status"` +} + +// Echo is a simple method that echoes back the message +func Echo(ctx context.Context, req *EchoRequest) (*EchoResponse, error) { + log.Printf("Echo method called with message: %s", req.Message) + return &EchoResponse{ + Message: req.Message, + Status: "success", + }, nil +} + +// ErrorRequest is the request structure for the error method +type ErrorRequest struct { + Code int `json:"code"` + Message string `json:"message"` +} + +// Error is a method that always returns an error +func Error(ctx context.Context, req *ErrorRequest) (interface{}, error) { + log.Printf("Error method called with code: %d, message: %s", req.Code, req.Message) + return nil, &jrpc2.Error{ + Code: jrpc2.Code(req.Code), + Message: req.Message, + } +} + +// Sum calculates the sum of an array of numbers +func Sum(ctx context.Context, numbers []float64) (float64, error) { + log.Printf("Sum method called with numbers: %v", numbers) + var sum float64 + for _, num := range numbers { + sum += num + } + return sum, nil +} + +// ToolsCallRequest handles a tools/call request +type ToolsCallRequest struct { + Name string `json:"name"` + Parameters json.RawMessage `json:"parameters"` +} + +type ToolsCallResponse struct { + Result json.RawMessage `json:"result"` +} + +// ToolsCall handles a tools/call request +func ToolsCall(ctx context.Context, req *ToolsCallRequest) (*ToolsCallResponse, error) { + log.Printf("tools/call method called with name: %s, parameters: %s", req.Name, string(req.Parameters)) + + // Echo back the parameters as the result + return &ToolsCallResponse{ + Result: req.Parameters, + }, nil +} + +func main() { + // Set up signal handling for graceful shutdown + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + sigs := make(chan os.Signal, 1) + signal.Notify(sigs, syscall.SIGINT, syscall.SIGTERM) + go func() { + sig := <-sigs + log.Printf("Received signal %v, shutting down...", sig) + cancel() + }() + + // Create a JSON-RPC server that communicates over stdin/stdout + log.Println("Starting mock MCP adapter") + + // Define the methods + methods := map[string]handler.Func{ + "echo": handler.New(Echo), + "error": handler.New(Error), + "sum": handler.New(Sum), + "tools/call": handler.New(ToolsCall), + } + + // Create a server with the defined methods + srv := jrpc2.NewServer(handler.Map(methods), &jrpc2.ServerOptions{ + AllowPush: true, + Logger: jrpc2.StdLogger(log.New(os.Stderr, "[jrpc2] ", log.LstdFlags)), + }) + + // Create a channel for communication over stdin/stdout + ch := channel.Line(os.Stdin, os.Stdout) + + // Start the server and wait for it to exit + log.Println("Server ready to handle requests") + if err := srv.Start(ch); err != nil { + log.Fatalf("Server failed to start: %v", err) + } + + // Wait for the server to exit or for context cancellation + select { + case <-ctx.Done(): + log.Println("Context canceled, shutting down server") + srv.Stop() + } + + log.Println("Server exited gracefully") +} diff --git a/test/integration_test.go b/test/integration_test.go new file mode 100644 index 0000000..f59dd3f --- /dev/null +++ b/test/integration_test.go @@ -0,0 +1,264 @@ +package test + +import ( + "bytes" + "encoding/json" + "fmt" + "io/ioutil" + "net/http" + "os" + "os/exec" + "path/filepath" + "testing" + "time" +) + +const ( + testPort = 8099 + testEndpoint = "/test/echo" +) + +// setupTestEnvironment creates test configuration files and starts the bridge +func setupTestEnvironment(t *testing.T) (*exec.Cmd, func()) { + // Create a temporary directory for test configurations + tmpDir, err := ioutil.TempDir("", "mcp-bridge-test") + if err != nil { + t.Fatalf("Failed to create temp dir: %v", err) + } + + // Create the main config file + mainConfig := fmt.Sprintf(` +port: %d +services: + test-adapter: + config: %s/service.yaml + command: + - %s +`, testPort, tmpDir, "../bin/adapter") + + if err := ioutil.WriteFile(filepath.Join(tmpDir, "config.yaml"), []byte(mainConfig), 0644); err != nil { + os.RemoveAll(tmpDir) + t.Fatalf("Failed to write main config: %v", err) + } + + // Create the service config file + serviceConfig := ` +serviceName: test-adapter +endpoints: + - path: /test/echo + mcp_method: echo + tool_name: echo + - path: /test/error + mcp_method: error + tool_name: error + - path: /test/sum + mcp_method: sum + tool_name: sum + - path: /test/tools/call + mcp_method: tools/call + tool_name: tools/call +` + + if err := ioutil.WriteFile(filepath.Join(tmpDir, "service.yaml"), []byte(serviceConfig), 0644); err != nil { + os.RemoveAll(tmpDir) + t.Fatalf("Failed to write service config: %v", err) + } + + // Start the bridge + bridgePath := "../mcp-bridge" // Use the binary in the project root + cmd := exec.Command(bridgePath, "-port", fmt.Sprintf("%d", testPort), "-config", filepath.Join(tmpDir, "config.yaml")) + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + + if err := cmd.Start(); err != nil { + os.RemoveAll(tmpDir) + t.Fatalf("Failed to start bridge: %v", err) + } + + // Wait for the server to start + time.Sleep(2 * time.Second) + + // Return a cleanup function + cleanup := func() { + cmd.Process.Signal(os.Interrupt) + cmd.Wait() + os.RemoveAll(tmpDir) + } + + return cmd, cleanup +} + +func TestEchoEndpoint(t *testing.T) { + _, cleanup := setupTestEnvironment(t) + defer cleanup() + + // Send a request to the echo endpoint + payload := map[string]string{"message": "hello world"} + jsonPayload, _ := json.Marshal(payload) + + resp, err := http.Post(fmt.Sprintf("http://localhost:%d%s", testPort, testEndpoint), + "application/json", bytes.NewBuffer(jsonPayload)) + if err != nil { + t.Fatalf("Failed to send request: %v", err) + } + defer resp.Body.Close() + + // Check the status code + if resp.StatusCode != http.StatusOK { + t.Errorf("Expected status OK, got %v", resp.Status) + } + + // Read and parse the response + body, err := ioutil.ReadAll(resp.Body) + if err != nil { + t.Fatalf("Failed to read response body: %v", err) + } + + var response struct { + Message string `json:"message"` + Status string `json:"status"` + } + + if err := json.Unmarshal(body, &response); err != nil { + t.Fatalf("Failed to parse response: %v", err) + } + + // Verify the response + if response.Message != "hello world" { + t.Errorf("Expected message 'hello world', got '%s'", response.Message) + } + if response.Status != "success" { + t.Errorf("Expected status 'success', got '%s'", response.Status) + } +} + +func TestErrorEndpoint(t *testing.T) { + _, cleanup := setupTestEnvironment(t) + defer cleanup() + + // Send a request to the error endpoint + payload := map[string]interface{}{ + "code": -32000, + "message": "test error", + } + jsonPayload, _ := json.Marshal(payload) + + resp, err := http.Post(fmt.Sprintf("http://localhost:%d/test/error", testPort), + "application/json", bytes.NewBuffer(jsonPayload)) + if err != nil { + t.Fatalf("Failed to send request: %v", err) + } + defer resp.Body.Close() + + // Check the status code - should be BadRequest for JSON-RPC errors + if resp.StatusCode != http.StatusBadRequest { + t.Errorf("Expected status BadRequest, got %v", resp.Status) + } + + // Read and parse the response + body, err := ioutil.ReadAll(resp.Body) + if err != nil { + t.Fatalf("Failed to read response body: %v", err) + } + + var response struct { + Code int `json:"code"` + Message string `json:"message"` + } + + if err := json.Unmarshal(body, &response); err != nil { + t.Fatalf("Failed to parse response: %v", err) + } + + // Verify the response + if response.Code != -32000 { + t.Errorf("Expected code -32000, got %d", response.Code) + } + if response.Message != "test error" { + t.Errorf("Expected message 'test error', got '%s'", response.Message) + } +} + +func TestSumEndpoint(t *testing.T) { + _, cleanup := setupTestEnvironment(t) + defer cleanup() + + // Send a request to the sum endpoint + numbers := []float64{1.5, 2.5, 3.5, 4.5, 5.0} + jsonPayload, _ := json.Marshal(numbers) + + resp, err := http.Post(fmt.Sprintf("http://localhost:%d/test/sum", testPort), + "application/json", bytes.NewBuffer(jsonPayload)) + if err != nil { + t.Fatalf("Failed to send request: %v", err) + } + defer resp.Body.Close() + + // Check the status code + if resp.StatusCode != http.StatusOK { + t.Errorf("Expected status OK, got %v", resp.Status) + } + + // Read and parse the response + body, err := ioutil.ReadAll(resp.Body) + if err != nil { + t.Fatalf("Failed to read response body: %v", err) + } + + var result float64 + if err := json.Unmarshal(body, &result); err != nil { + t.Fatalf("Failed to parse response: %v", err) + } + + // Verify the response + expected := 17.0 // 1.5 + 2.5 + 3.5 + 4.5 + 5.0 + if result != expected { + t.Errorf("Expected sum %f, got %f", expected, result) + } +} + +func TestToolsCallEndpoint(t *testing.T) { + _, cleanup := setupTestEnvironment(t) + defer cleanup() + + // Send a request to the tools/call endpoint + payload := map[string]interface{}{ + "name": "test-tool", + "parameters": map[string]string{ + "param1": "value1", + "param2": "value2", + }, + } + jsonPayload, _ := json.Marshal(payload) + + resp, err := http.Post(fmt.Sprintf("http://localhost:%d/test/tools/call", testPort), + "application/json", bytes.NewBuffer(jsonPayload)) + if err != nil { + t.Fatalf("Failed to send request: %v", err) + } + defer resp.Body.Close() + + // Check the status code + if resp.StatusCode != http.StatusOK { + t.Errorf("Expected status OK, got %v", resp.Status) + } + + // Read and parse the response + body, err := ioutil.ReadAll(resp.Body) + if err != nil { + t.Fatalf("Failed to read response body: %v", err) + } + + var response struct { + Result map[string]string `json:"result"` + } + + if err := json.Unmarshal(body, &response); err != nil { + t.Fatalf("Failed to parse response: %v", err) + } + + // Verify the response + if response.Result["param1"] != "value1" || response.Result["param2"] != "value2" { + t.Errorf("Response does not match expected parameters: %v", response.Result) + } +}