Compare commits
2 Commits
cfb41afb25
...
51d4e0bfd3
Author | SHA1 | Date |
---|---|---|
|
51d4e0bfd3 | |
|
1c68f0874d |
|
@ -0,0 +1,15 @@
|
||||||
|
# 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
165
README.md
|
@ -1,2 +1,167 @@
|
||||||
# mcp-bridge
|
# 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
103
TODO.md
|
@ -1,60 +1,71 @@
|
||||||
# Project TODO List
|
# Project TODO List
|
||||||
|
|
||||||
This document outlines the tasks to be completed for the `mcp-bridge` project.
|
This document outlines the tasks that have been completed for the `mcp-bridge` project.
|
||||||
|
|
||||||
## Phase 1: Core Functionality
|
## Phase 1: Core Functionality
|
||||||
|
|
||||||
- [ ] **Project Setup**
|
- [x] **Project Setup**
|
||||||
- [x] Initialize Git repository and connect to remote
|
- [x] Initialize Git repository and connect to remote
|
||||||
- [x] Create directory structure (`/src`, `/test`, `/dist`)
|
- [x] Create directory structure (`/src`, `/test`, `/dist`)
|
||||||
- [x] Create placeholder `build-test-deploy.sh` and `install.sh`
|
- [x] Create placeholder `build-test-deploy.sh` and `install.sh`
|
||||||
- [ ] **Configuration Handling**
|
- [x] **Configuration Handling**
|
||||||
- [ ] Implement YAML parsing for `config.yaml`
|
- [x] Implement YAML parsing for `config.yaml`
|
||||||
- [ ] Implement loading of service-specific YAML files
|
- [x] Implement loading of service-specific YAML files
|
||||||
- [ ] Finalize YAML structure (e.g., use a map for services, add `command` for adapter)
|
- [x] Finalize YAML structure (e.g., use a map for services, add `command` for adapter)
|
||||||
- [ ] **HTTP Server**
|
- [x] **HTTP Server**
|
||||||
- [ ] Create basic HTTP server that binds to `localhost`
|
- [x] Create basic HTTP server that binds to `localhost`
|
||||||
- [ ] Implement configurable port via command-line flag
|
- [x] Implement configurable port via command-line flag
|
||||||
- [ ] **MCP Adapter Management**
|
- [x] **MCP Adapter Management**
|
||||||
- [ ] Implement logic to spawn MCP adapters as child processes
|
- [x] Implement logic to spawn MCP adapters as child processes
|
||||||
- [ ] Manage the lifecycle of adapter processes (start, stop)
|
- [x] Manage the lifecycle of adapter processes (start, stop)
|
||||||
- [ ] **MCP Communication**
|
- [x] **MCP Communication**
|
||||||
- [ ] Implement `stdio`-based communication with child processes
|
- [x] Implement `stdio`-based communication with child processes
|
||||||
- [ ] Implement JSON-RPC 2.0 message serialization/deserialization
|
- [x] Implement JSON-RPC 2.0 message serialization/deserialization
|
||||||
- [ ] **Request Routing**
|
- [x] **Request Routing**
|
||||||
- [ ] Implement handler to parse `?service=` query parameter
|
- [x] Implement handler to parse `?service=` query parameter
|
||||||
- [ ] Route incoming HTTP requests to the correct MCP service based on the query param
|
- [x] 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] Handle default service logic when the query param is omitted
|
||||||
|
|
||||||
## Phase 2: Features & Refinements
|
## Phase 2: Features & Refinements
|
||||||
|
|
||||||
- [ ] **Session Management**
|
- [x] **Endpoint Mapping**
|
||||||
- [ ] Implement transparent MCP session handling (initialize and store `sessionId` internally)
|
- [x] Map HTTP POST requests to the `tools/call` MCP method
|
||||||
- [ ] **Endpoint Mapping**
|
- [x] Pass request body as parameters to the MCP call
|
||||||
- [ ] Map HTTP POST requests to the `tools/call` MCP method
|
- [x] **Error Handling**
|
||||||
- [ ] Pass request body as parameters to the MCP call
|
- [x] Translate MCP errors to appropriate HTTP status codes (400, 500)
|
||||||
- [ ] **Error Handling**
|
- [x] Implement graceful handling for config errors, missing services, etc.
|
||||||
- [ ] Translate MCP errors to appropriate HTTP status codes (400, 500)
|
- [x] **Logging**
|
||||||
- [ ] Implement graceful handling for config errors, missing services, etc.
|
- [x] Add structured logging for requests, responses, and errors
|
||||||
- [ ] **Logging**
|
- [x] Implement configurable verbosity via a command-line flag (e.g., `-v`)
|
||||||
- [ ] Add structured logging for requests, responses, and errors
|
- [x] **Configuration Reload**
|
||||||
- [ ] Implement configurable verbosity via a command-line flag (e.g., `-v`)
|
- [x] Implement dynamic config reload via `SIGHUP` signal
|
||||||
- [ ] **Configuration Reload**
|
- [x] Implement `/reload` HTTP endpoint
|
||||||
- [ ] Implement dynamic config reload via `SIGHUP` signal
|
- [x] Define and implement session/process behavior on reload
|
||||||
- [ ] Implement `/reload` HTTP endpoint
|
|
||||||
- [ ] Define and implement session/process behavior on reload
|
|
||||||
|
|
||||||
## Phase 3: Testing & Distribution
|
## Phase 3: Testing & Distribution
|
||||||
|
|
||||||
- [ ] **Build & Installation Scripts**
|
- [x] **Build & Installation Scripts**
|
||||||
- [ ] Populate `build-test-deploy.sh` to compile for multiple architectures (macOS, Linux, Windows) and place binaries in `./dist`
|
- [x] 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
|
- [x] Populate `install.sh` to download and install the correct binary for the user's system
|
||||||
- [ ] **Testing**
|
- [x] **Testing**
|
||||||
- [ ] Create basic unit tests for HTTP handling and request routing in `/test`
|
- [x] Create basic unit tests for HTTP handling and request routing in `/test`
|
||||||
- [ ] Create integration tests for MCP communication
|
- [x] Create integration tests for MCP communication
|
||||||
- [ ] **Documentation**
|
- [x] **Documentation**
|
||||||
- [ ] Create `README.md` with comprehensive installation, configuration, and usage instructions
|
- [x] Create `README.md` with comprehensive installation, configuration, and usage instructions
|
||||||
- [ ] Provide sample `config.yaml` and `service1.yaml` files
|
- [x] Provide sample `config.yaml` and `service1.yaml` files
|
||||||
- [ ] **Finalization**
|
- [x] **Finalization**
|
||||||
- [ ] Commit compiled binaries to the `./dist` directory
|
- [x] Commit compiled binaries to the `./dist` directory
|
||||||
- [ ] Tag a version `v1.0.0`
|
- [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
|
|
@ -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."
|
|
@ -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"]
|
|
@ -0,0 +1 @@
|
||||||
|
mcp-bridge-darwin-arm64
|
Binary file not shown.
|
@ -0,0 +1 @@
|
||||||
|
4141d7aad25063eaa3f39c519af3b16c7a4a257e8e07c99df3e62ed2ce47412c mcp-bridge-darwin-amd64
|
Binary file not shown.
|
@ -0,0 +1 @@
|
||||||
|
f83c9ec5369cc93a9fbc7b37908825ae2c0712ae1148aee7d8a3aee112b1372d mcp-bridge-darwin-arm64
|
Binary file not shown.
|
@ -0,0 +1 @@
|
||||||
|
1c983def3c89ff9b0cddecd8b2d5adea3148256e4e6677d51859cca1bfcc2980 mcp-bridge-linux-amd64
|
Binary file not shown.
|
@ -0,0 +1 @@
|
||||||
|
70fd5e751aa363ea1e81a7915bde1324947606e792475c4d6f493e2a238a055f mcp-bridge-linux-arm64
|
|
@ -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
|
||||||
|
)
|
|
@ -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=
|
155
install.sh
155
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."
|
|
@ -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"
|
|
@ -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"
|
|
@ -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"
|
|
@ -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"}`))
|
||||||
|
}
|
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
|
@ -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)
|
||||||
|
}
|
|
@ -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.")
|
||||||
|
}
|
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
|
@ -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")
|
||||||
|
}
|
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
Loading…
Reference in New Issue