Compare commits

..

No commits in common. "51d4e0bfd354572a585d04bba5f533b7838d2682" and "cfb41afb251e16b2c288064c34e937bec477253e" have entirely different histories.

27 changed files with 46 additions and 2144 deletions

15
.gitignore vendored
View File

@ -1,15 +0,0 @@
# Build artifacts
/bin/
/mcp-adapter-default
/mcp-bridge
/output.log
# Editor directories
/.cursor/
# Logs
*.log
# OS specific files
.DS_Store
Thumbs.db

165
README.md
View File

@ -1,167 +1,2 @@
# 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 <pid>
```
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.

103
TODO.md
View File

@ -1,71 +1,60 @@
# Project TODO List
This document outlines the tasks that have been completed for the `mcp-bridge` project.
This document outlines the tasks to be completed for the `mcp-bridge` project.
## Phase 1: Core Functionality
- [x] **Project Setup**
- [ ] **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`
- [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
- [ ] **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
## Phase 2: Features & Refinements
- [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.
- [x] **Logging**
- [x] Add structured logging for requests, responses, and errors
- [x] Implement configurable verbosity via a command-line flag (e.g., `-v`)
- [x] **Configuration Reload**
- [x] Implement dynamic config reload via `SIGHUP` signal
- [x] Implement `/reload` HTTP endpoint
- [x] Define and implement session/process behavior on reload
- [ ] **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.
- [ ] **Logging**
- [ ] Add structured logging for requests, responses, and errors
- [ ] Implement configurable verbosity via a command-line flag (e.g., `-v`)
- [ ] **Configuration Reload**
- [ ] Implement dynamic config reload via `SIGHUP` signal
- [ ] Implement `/reload` HTTP endpoint
- [ ] Define and implement session/process behavior on reload
## Phase 3: Testing & Distribution
- [x] **Build & Installation Scripts**
- [x] Populate `build-test-deploy.sh` to compile for multiple architectures (macOS, Linux, Windows) and place binaries in `./dist`
- [x] Populate `install.sh` to download and install the correct binary for the user's system
- [x] **Testing**
- [x] Create basic unit tests for HTTP handling and request routing in `/test`
- [x] Create integration tests for MCP communication
- [x] **Documentation**
- [x] Create `README.md` with comprehensive installation, configuration, and usage instructions
- [x] Provide sample `config.yaml` and `service1.yaml` files
- [x] **Finalization**
- [x] Commit compiled binaries to the `./dist` directory
- [x] Tag a version `v1.0.0`
## Version 1.0.0 Release Notes
The initial release of mcp-bridge includes:
- 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
- Multi-architecture binaries (macOS/Linux, amd64/arm64)
- Comprehensive documentation and sample configurations
- [ ] **Build & Installation Scripts**
- [ ] Populate `build-test-deploy.sh` to compile for multiple architectures (macOS, Linux, Windows) and place binaries in `./dist`
- [ ] Populate `install.sh` to download and install the correct binary for the user's system
- [ ] **Testing**
- [ ] Create basic unit tests for HTTP handling and request routing in `/test`
- [ ] Create integration tests for MCP communication
- [ ] **Documentation**
- [ ] Create `README.md` with comprehensive installation, configuration, and usage instructions
- [ ] Provide sample `config.yaml` and `service1.yaml` files
- [ ] **Finalization**
- [ ] Commit compiled binaries to the `./dist` directory
- [ ] Tag a version `v1.0.0`

View File

@ -1,88 +0,0 @@
#!/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."

View File

@ -1,19 +0,0 @@
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"]

1
dist/mcp-bridge vendored
View File

@ -1 +0,0 @@
mcp-bridge-darwin-arm64

Binary file not shown.

View File

@ -1 +0,0 @@
4141d7aad25063eaa3f39c519af3b16c7a4a257e8e07c99df3e62ed2ce47412c mcp-bridge-darwin-amd64

Binary file not shown.

View File

@ -1 +0,0 @@
f83c9ec5369cc93a9fbc7b37908825ae2c0712ae1148aee7d8a3aee112b1372d mcp-bridge-darwin-arm64

Binary file not shown.

View File

@ -1 +0,0 @@
1c983def3c89ff9b0cddecd8b2d5adea3148256e4e6677d51859cca1bfcc2980 mcp-bridge-linux-amd64

Binary file not shown.

View File

@ -1 +0,0 @@
70fd5e751aa363ea1e81a7915bde1324947606e792475c4d6f493e2a238a055f mcp-bridge-linux-arm64

14
go.mod
View File

@ -1,14 +0,0 @@
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
)

23
go.sum
View File

@ -1,23 +0,0 @@
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=

View File

@ -1,155 +0,0 @@
#!/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."

View File

@ -1,16 +0,0 @@
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"

View File

@ -1,19 +0,0 @@
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"

View File

@ -1,16 +0,0 @@
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"

View File

@ -1,144 +0,0 @@
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, &params); 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"}`))
}

View File

@ -1,300 +0,0 @@
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)
}
}

View File

@ -1,126 +0,0 @@
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)
}

View File

@ -1,570 +0,0 @@
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, &params); 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.")
}

View File

@ -1,20 +0,0 @@
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)
}
}

View File

@ -1,128 +0,0 @@
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")
}

View File

@ -1,264 +0,0 @@
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)
}
}