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