Compare commits

...

58 Commits
v8.3.0 ... main

Author SHA1 Message Date
jialong.wang
6d871e638b feat: update version 2025-11-05 15:26:38 +08:00
xbingW
ad2aeb4cf9 Update version.json 2025-08-14 14:45:49 +08:00
xbingW
8247d11dda Update version.json 2025-08-08 10:55:18 +08:00
xbingW
63588b8f5e Update version.json 2025-08-07 20:13:00 +08:00
xbingW
bf8d64c6b2 Update version.json 2025-07-24 20:25:36 +08:00
xbingW
0b8b253efc Create version.json 2025-07-10 19:31:10 +08:00
xbingW
24e2bac1bd Update README.md 2025-05-27 11:45:01 +08:00
safe1ine
8113dbb171 Update README.md 2025-04-21 16:08:27 +08:00
xbingW
66c4e60255 feat: desc 2025-04-10 19:33:42 +08:00
xbingW
eeae48affb feat: tools 2025-04-10 18:30:29 +08:00
xbingW
75a5346e6f feat: tools 2025-04-10 18:28:54 +08:00
xbingW
b6f694a376 feat: tools 2025-04-10 18:27:39 +08:00
xbingW
9cab30cef1 feat: defaults 2025-04-10 18:14:57 +08:00
xbingW
8fd59844ea feat: log level 2025-04-10 17:35:54 +08:00
xbingW
681e7f95f2 feat: mcp go 2025-04-10 17:30:17 +08:00
xbingW
84c2db9ee3 fix: int64 2025-04-10 17:23:01 +08:00
xbingW
c845231a1d feat: quick start 2025-04-10 17:23:01 +08:00
safe1ine
91a26b8f93 Update README.md 2025-04-10 10:32:42 +08:00
xbingW
24770cf5b4 fix: unit 2025-04-09 20:20:34 +08:00
xbingW
ff4d6616fb feat: attack events 2025-04-09 20:15:49 +08:00
xbingW
a13d269c28 feat: path 2025-04-09 19:25:11 +08:00
xbingW
19db896a9a feat: log 2025-04-09 19:16:41 +08:00
xbingW
01c38c26d0 feat: whitelist 2025-04-09 19:16:05 +08:00
xbingW
14fb45f648 feat: blacklist 2025-04-09 19:03:30 +08:00
xbingW
f371d588a4 feat: blacklist 2025-04-09 18:40:34 +08:00
xbingW
1317b3ed5a fix: rule 2025-04-09 18:16:54 +08:00
xbingW
7fdf63310d fix: tml 2025-04-09 18:15:56 +08:00
xbingW
1578ea6027 fix: tools 2025-04-09 18:13:24 +08:00
xbingW
ec3dc96765 feat: mcp go 2025-04-09 18:11:50 +08:00
姚凯
48e828cc03 fix: check slce response data status code 2025-04-08 10:27:49 +08:00
safe1ine
78e71aae23 Merge pull request #1179 from dhsifss/feat/mcp
fix: get_api response
2025-04-07 18:49:47 +08:00
姚凯
857e0c46ef fix: get_slce_api 2025-04-07 18:46:55 +08:00
姚凯
8cee76ecbe fix: remove register classmethod 2025-04-07 18:00:09 +08:00
Changzhi Li
6abcb6b69f fix requests.get 2025-04-07 09:34:48 +00:00
Changzhi Li
075bd524a9 fix names 2025-04-07 09:22:26 +00:00
Changzhi Li
5a96c2d4d0 remove bad spacve 2025-04-07 09:10:30 +00:00
Changzhi Li
8de97ff483 add mcp get_attack_event tool 2025-04-07 07:41:42 +00:00
xbingW
b7449480b3 Update docker-compose.yaml 2025-04-03 19:34:19 +08:00
姚凯
38f00302bf fix: log format 2025-04-03 19:12:14 +08:00
姚凯
850fd440ff refactor: tools 2025-04-03 18:08:14 +08:00
姚凯
b243e2b51d refactor: tools 2025-04-03 17:17:36 +08:00
xbingW
34fc8fa8e5 fix: ci 2025-04-03 15:24:53 +08:00
safe1ine
6ceeae4d2b Merge pull request #1174 from dhsifss/feat/mcp
feat: add doc and rename
2025-04-03 15:11:30 +08:00
xbingW
abd0427273 feat: rename 2025-04-03 15:04:16 +08:00
xbingW
82f0c5d52e fix: arm 2025-04-03 15:01:50 +08:00
xbingW
c7fa1efe5d feat: build image 2025-04-03 14:49:39 +08:00
xbingW
be0571a67f fix: ci 2025-04-03 14:46:26 +08:00
xbingW
a79d932801 fix: ci 2025-04-03 14:45:09 +08:00
xbingW
8157ef050e fix: ci 2025-04-03 14:44:25 +08:00
xbingW
04d1891891 fix: ci 2025-04-03 14:43:50 +08:00
xbingW
1d7eeeba36 fix: ci 2025-04-03 14:40:03 +08:00
xbingW
52f6e857df fix: ci 2025-04-03 14:38:21 +08:00
xbingW
255fd4173d fix: ci 2025-04-03 14:34:19 +08:00
姚凯
1a80a5ac07 feat: add doc and rename 2025-04-03 14:33:17 +08:00
xbingW
6324dea166 feat: add ci 2025-04-03 14:31:16 +08:00
safe1ine
695c438ec3 Merge pull request #1173 from dhsifss/feat/mcp
feat: init slmcp
2025-04-03 10:29:57 +08:00
姚凯
bb0b1187a0 feat: init slmcp 2025-04-02 23:12:41 +08:00
姚凯
d4f31efdea fix: environment 2024-12-19 14:58:47 +08:00
33 changed files with 2368 additions and 5 deletions

41
.github/workflows/slmcp-docker.yml vendored Normal file
View File

@@ -0,0 +1,41 @@
name: MCP Docker
on:
push:
branches:
- main
tags:
- "v*"
paths:
- "mcp_server/**"
- ".github/workflows/slmcp-docker.yml"
jobs:
build-and-push:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
with:
platforms: linux/amd64,linux/arm64
- name: Login to DockerHub
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKERIO_USERNAME }}
password: ${{ secrets.DOCKERIO_PASSWORD }}
- name: Build and push
uses: docker/build-push-action@v5
with:
context: ./mcp_server
push: true
platforms: linux/amd64,linux/arm64
tags: |
chaitin/safeline-mcp:latest
chaitin/safeline-mcp:${{ github.ref_name }}
cache-from: type=registry,ref=chaitin/safeline-mcp:buildcache
cache-to: type=registry,ref=chaitin/safeline-mcp:buildcache,mode=max

3
.gitignore vendored
View File

@@ -5,3 +5,6 @@
*.tar.gz
build.sh
compose.yml
__pycache__
.cursor
.vscode

View File

@@ -7,9 +7,9 @@
</h4>
<p align="center">
<a target="_blank" href="https://waf.chaitin.com/">🏠 Website</a> &nbsp; | &nbsp;
<a target="_blank" href="https://docs.waf.chaitin.com/">📖 Docs</a> &nbsp; | &nbsp;
<a target="_blank" href="https://demo.waf.chaitin.com:9443/">🔍 Live Demo</a> &nbsp; | &nbsp;
<a target="_blank" href="https://ly.safepoint.cloud/laA8asp">🏠 Website</a> &nbsp; | &nbsp;
<a target="_blank" href="https://ly.safepoint.cloud/w2AeHhb">📖 Docs</a> &nbsp; | &nbsp;
<a target="_blank" href="https://ly.safepoint.cloud/hSMd4SH">🔍 Live Demo</a> &nbsp; | &nbsp;
<a target="_blank" href="https://discord.gg/SVnZGzHFvn">🙋‍♂️ Discord</a> &nbsp; | &nbsp;
<a target="_blank" href="/README_CN.md">中文版</a>
</p>
@@ -77,11 +77,11 @@ List of the main features as follows:
#### 📦 Installing
Information on how to install SafeLine can be found in the [Install Guide](https://docs.waf.chaitin.com/en/tutorials/install)
Information on how to install SafeLine can be found in the [Install Guide](https://docs.waf.chaitin.com/en/GetStarted/Deploy)
#### ⚙️ Protecting Web Apps
to see [Configuration](https://docs.waf.chaitin.com/en/tutorials/Configuration)
to see [Configuration](https://docs.waf.chaitin.com/en/GetStarted/AddApplication)
## 📋 More Informations

28
mcp_server/Dockerfile Normal file
View File

@@ -0,0 +1,28 @@
FROM golang:1.24-alpine AS builder
WORKDIR /app
RUN apk add --no-cache git
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 go build -o mcp-server .
FROM alpine:latest
WORKDIR /app
RUN apk add --no-cache ca-certificates tzdata
COPY --from=builder /app/mcp-server .
COPY --from=builder /app/config.yaml .
ENV TZ=Asia/Shanghai
EXPOSE 5678
CMD ["./mcp-server"]

256
mcp_server/README.md Normal file
View File

@@ -0,0 +1,256 @@
# SafeLine MCP Server
SafeLine MCP Server is an implementation of the [Model Context Protocol (MCP)](https://modelcontextprotocol.io/introduction) that provides complete management and control capabilities for SafeLine WAF.
[![Docker](https://img.shields.io/badge/Docker-Supported-2496ED?style=flat-square&logo=docker&logoColor=white)](docker-compose.yml)
[![Go Version](https://img.shields.io/badge/Go-1.21+-00ADD8?style=flat-square&logo=go&logoColor=white)](go.mod)
## Use Cases
- Automated management and control of SafeLine WAF instances
- WAF configuration and policy management through API
- Building AI-based security protection tools and applications
## Prerequisites
1. Install [Docker](https://www.docker.com/) (if running in container)
2. Configure SafeLine API Token (obtained from SafeLine console)
## Features
- Complete MCP (Management Control Protocol) server implementation
- Support for SafeLine WAF instance management and control
- Flexible configuration system supporting file configuration and environment variables
- Docker containerization support
- Secure API communication
## Quick Start
### Environment Variables
| Environment Variable | Description | Default Value | Required |
|---------|------|--------|-----|
| LISTEN_PORT | Service listening port | 5678 | No |
| LISTEN_ADDRESS | Service listening address | 0.0.0.0 | No |
| SAFELINE_SECRET | SSE server secret | - | No |
| SAFELINE_ADDRESS | SafeLine API address | - | Yes |
| SAFELINE_API_TOKEN | SafeLine API authentication token | - | Yes |
### Using Docker
#### Method 1: Using docker run
```bash
docker run -d \
--name safeline-mcp \
-p 5678:5678 \
-e SAFELINE_API_TOKEN="your_api_token" \
-e SAFELINE_ADDRESS="https://your.safeline.com" \
-e LISTEN_PORT=5678 \
-e LISTEN_ADDRESS="0.0.0.0" \
chaitin/safeline-mcp:latest
```
#### Method 2: Using docker-compose
```bash
# 1. Clone repository
git clone https://github.com/chaitin/safeline-mcp.git
cd safeline-mcp
# 2. Edit docker-compose.yml to configure environment variables
# Example docker-compose.yml:
# version: '3'
# services:
# mcp:
# image: chaitin/safeline-mcp:latest
# container_name: safeline-mcp
# ports:
# - "5678:5678"
# environment:
# - SAFELINE_API_TOKEN=your_api_token
# - SAFELINE_ADDRESS=https://your.safeline.com
# - LISTEN_PORT=5678
# - LISTEN_ADDRESS=0.0.0.0
# 3. Start service
docker compose -f docker-compose.yml up -d
```
#### Method 3: Using Go
```bash
# 1. Clone repository
git clone https://github.com/chaitin/SafeLine.git
cd safeline-mcp
# 2. Install dependencies
go mod download
# 3. Configure config.yaml
cp config.yaml.example config.yaml
# Edit config.yaml with necessary configurations
# 4. Run service
go run main.go
```
For more API details, please refer to the [API Documentation](https://demo.waf.chaitin.com:9443/swagger/index.html).
## Tools
### Application Management
- **create_application**
### Rule Management
- **create_blacklist_rule**
- **create_whitelist_rule**
### Analyze
- **get_attack_events**
## Development Guide
The Go API in this project is currently under development, and APIs may change. If you have specific requirements, please submit an Issue for discussion.
### Directory Structure
```
internal/
├── api/ # API implementation
│ ├── app/ # Application-related APIs
│ │ └── create_application.go
│ └── rule/ # Rule-related APIs
│ └── create_rule.go
└── tools/ # MCP tool implementation
├── app/ # Application-related tools
│ └── create_application.go
└── rule/ # Rule-related tools
└── create_rule.go
```
### Adding New Tools
1. **Create Tool File**
- Create corresponding directory and file under `internal/tools`
- File name should match tool name
- Use separate file for each tool
- Example: `internal/tools/app/create_application.go`
2. **Tool Implementation Template**
```go
package app
type ToolName struct{}
type ToolParams struct {
// Parameter definitions
Param1 string `json:"param1" desc:"parameter description" required:"true"`
Param2 int `json:"param2" desc:"parameter description" required:"false"`
}
type ToolResult struct {
Field1 string `json:"field1"`
}
func (t *ToolName) Name() string {
return "tool_name"
}
func (t *ToolName) Description() string {
return "tool description"
}
func (t *ToolName) Validate(params ToolParams) error {
// Parameter validation logic
return nil
}
func (t *ToolName) Execute(ctx context.Context, params ToolParams) (result ToolResult, err error) {
// Tool execution logic
return result, nil
}
```
3. **[Optional]Create API Implementation**
If you need to use some APIs that have not been implemented yet, you need to create corresponding files in the api directory for implementation
- Create same directory structure under `internal/api`
- File name should match tool func
- Example: `internal/api/app/create_application.go`
**API Implementation Template**
```go
package app
type RequestType struct {
// Request parameter definitions
Param1 string `json:"param1"`
Param2 int `json:"param2"`
}
func APIName(ctx context.Context, req *RequestType) (ResultType, error) {
if req == nil {
return nil, errors.New("request is required")
}
var resp api.Response[ResultType]
err := api.Service().Post(ctx, "/api/path", req, &resp)
if err != nil {
return nil, errors.Wrap(err, "failed to execute")
}
if resp.Err != nil {
return nil, errors.New(resp.Msg)
}
return resp.Data, nil
}
```
4. **Tool Registration (init.go)**
The tool registration file `internal/tools/init.go` is used to centrally manage all tool registrations
- Register all tools uniformly in the `init()` function
- Use the `AppendTool()` method for registration
- Example:
```go
// Register create application tool
AppendTool(&app.CreateApp{})
// Register create blacklist rule tool
AppendTool(&rule.CreateBlacklistRule{})
```
### Development Standards
1. **Naming Conventions**
- Use lowercase letters and underscores for tool names
- File names should match tool names
2. **Directory Organization**
- Divide directories by functional modules (e.g., app, rule, etc.)
- Maintain consistent structure between tools and api directories
- Keep related functionality in the same directory
3. **Code Standards**
- Follow Go standard code conventions
- Add necessary parameter validation
- Use unified error handling approach
- Add appropriate logging
4. **Documentation Requirements**
- Provide clear functional description in tool Description
- Add detailed description for parameters
- Update API toolkit documentation in README
### Example
Refer to the implementation of the `create_application` tool:
- Tool implementation: `internal/tools/app/create_application.go`
- API implementation: `internal/api/app/create_application.go`

27
mcp_server/config.yaml Normal file
View File

@@ -0,0 +1,27 @@
# Server Configuration
server:
name: "SafeLine MCP Server"
version: "1.0.0"
# Can be overridden by environment variable LISTEN_PORT
port: 5678
# Can be overridden by environment variable LISTEN_ADDRESS
host: "0.0.0.0"
# Can be overridden by environment variable SAFELINE_SECRET
secret: "" # Secret for SSE server
# Logger Configuration
logger:
level: "info" # Log level: debug, info, warn, error
file_path: "" # Log file path
console: true # Whether to output to console
caller: false # Whether to record caller information
development: true # Whether to use development mode
# API Configuration
api:
# Can be overridden by environment variable SAFELINE_ADDRESS
base_url: "" # API service address
# Can be overridden by environment variable SAFELINE_API_TOKEN
token: "" # Authentication token
timeout: 30 # Timeout in seconds
debug: false # Whether to enable debug mode
insecure_skip_verify: true # Whether to skip certificate verification

View File

@@ -0,0 +1,15 @@
version: '3.8'
services:
mcp_server:
image: chaitin/safeline-mcp:latest
container_name: mcp_server
restart: always
ports:
- "5678:5678"
environment:
- SAFELINE_SECRET=your_secret_key # optional, if you want to use secret key to authenticate
- SAFELINE_ADDRESS=https://your_safeline_ip:9443 # required, your SafeLine WAF address
- SAFELINE_API_TOKEN=your_safeline_api_token # required, your SafeLine WAF api token
- LISTEN_PORT=5678 # optional, default is 5678
- LISTEN_ADDRESS=0.0.0.0 # optional, default is 0.0.0.0

16
mcp_server/go.mod Normal file
View File

@@ -0,0 +1,16 @@
module github.com/chaitin/SafeLine/mcp_server
go 1.24.1
require (
github.com/mark3labs/mcp-go v0.18.0
go.uber.org/zap v1.27.0
gopkg.in/yaml.v3 v3.0.1
)
require (
github.com/google/uuid v1.6.0 // indirect
github.com/mcuadros/go-defaults v1.2.0
github.com/yosida95/uritemplate/v3 v3.0.2 // indirect
go.uber.org/multierr v1.11.0 // indirect
)

28
mcp_server/go.sum Normal file
View File

@@ -0,0 +1,28 @@
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
github.com/mark3labs/mcp-go v0.18.0 h1:YuhgIVjNlTG2ZOwmrkORWyPTp0dz1opPEqvsPtySXao=
github.com/mark3labs/mcp-go v0.18.0/go.mod h1:KmJndYv7GIgcPVwEKJjNcbhVQ+hJGJhrCCB/9xITzpE=
github.com/mcuadros/go-defaults v1.2.0 h1:FODb8WSf0uGaY8elWJAkoLL0Ri6AlZ1bFlenk56oZtc=
github.com/mcuadros/go-defaults v1.2.0/go.mod h1:WEZtHEVIGYVDqkKSWBdWKUVdRyKlMfulPaGDWIVeCWY=
github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg=
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/yosida95/uritemplate/v3 v3.0.2 h1:Ed3Oyj9yrmi9087+NczuL5BwkIc4wvTb5zIM+UJPGz4=
github.com/yosida95/uritemplate/v3 v3.0.2/go.mod h1:ILOh0sOhIJR3+L/8afwt/kE++YT040gmv5BQTMR2HP4=
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0=
go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y=
go.uber.org/zap v1.27.0 h1:aJMhYGrd5QSmlpLMr2MftRKl7t8J8PTZPA732ud/XR8=
go.uber.org/zap v1.27.0/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

View File

@@ -0,0 +1,47 @@
package analyze
import (
"context"
"fmt"
"github.com/chaitin/SafeLine/mcp_server/internal/api"
)
type GetEventListRequest struct {
Page int `json:"page"`
PageSize int `json:"page_size"`
IP string `json:"ip"`
Start int64 `json:"start"`
End int64 `json:"end"`
}
type GetEventListResponse struct {
Nodes []Event `json:"nodes"`
Total int64 `json:"total"`
}
type Event struct {
ID uint `json:"id"`
IP string `json:"ip"`
Protocol int `json:"protocol"`
Host string `json:"host"`
DstPort uint64 `json:"dst_port"`
UpdatedAt int64 `json:"updated_at"`
StartAt int64 `json:"start_at"`
EndAt int64 `json:"end_at"`
DenyCount int64 `json:"deny_count"`
PassCount int64 `json:"pass_count"`
Finished bool `json:"finished"`
Country string `json:"country"`
Province string `json:"province"`
City string `json:"city"`
}
func GetEventList(ctx context.Context, req *GetEventListRequest) (*GetEventListResponse, error) {
var resp api.Response[GetEventListResponse]
err := api.Service().Get(ctx, fmt.Sprintf("/api/open/events?page=%d&page_size=%d&ip=%s&start=%d&end=%d", req.Page, req.PageSize, req.IP, req.Start, req.End), &resp)
if err != nil {
return nil, err
}
return &resp.Data, nil
}

View File

@@ -0,0 +1,34 @@
package app
import (
"context"
"github.com/chaitin/SafeLine/mcp_server/internal/api"
"github.com/chaitin/SafeLine/mcp_server/pkg/errors"
)
type CreateAppRequest struct {
ServerNames []string `json:"server_names"`
Ports []string `json:"ports"`
Upstreams []string `json:"upstreams"`
Comment string `json:"comment"`
}
// CreateApp Create new website or app
func CreateApp(ctx context.Context, req *CreateAppRequest) (int64, error) {
if req == nil {
return 0, errors.New("request is required")
}
var resp api.Response[int64]
err := api.Service().Post(ctx, "/api/open/site", req, &resp)
if err != nil {
return 0, errors.Wrap(err, "failed to create app")
}
if resp.Err != nil {
return 0, errors.New(resp.Msg)
}
return resp.Data, nil
}

View File

@@ -0,0 +1,157 @@
package api
import (
"bytes"
"context"
"crypto/tls"
"encoding/json"
"fmt"
"io"
"net/http"
"time"
"github.com/chaitin/SafeLine/mcp_server/pkg/errors"
"github.com/chaitin/SafeLine/mcp_server/pkg/logger"
)
// Client API client
type Client struct {
baseURL string
httpClient *http.Client
headers map[string]string
}
// ClientOption Client configuration options
type ClientOption func(*Client)
// WithTimeout Set timeout duration
func WithTimeout(timeout time.Duration) ClientOption {
return func(c *Client) {
c.httpClient.Timeout = timeout
}
}
// WithHeader Set request header
func WithHeader(key, value string) ClientOption {
return func(c *Client) {
c.headers[key] = value
}
}
// WithBaseURL Set base URL
func WithBaseURL(baseURL string) ClientOption {
return func(c *Client) {
c.baseURL = baseURL
}
}
// WithInsecureSkipVerify Set whether to skip certificate verification
func WithInsecureSkipVerify(skip bool) ClientOption {
return func(c *Client) {
if transport, ok := c.httpClient.Transport.(*http.Transport); ok {
if transport.TLSClientConfig == nil {
transport.TLSClientConfig = &tls.Config{}
}
transport.TLSClientConfig.InsecureSkipVerify = skip
}
}
}
// NewClient Create new API client
func NewClient(opts ...ClientOption) *Client {
transport := &http.Transport{
TLSClientConfig: &tls.Config{},
}
c := &Client{
httpClient: &http.Client{
Timeout: 30 * time.Second,
Transport: transport,
},
headers: make(map[string]string),
}
for _, opt := range opts {
opt(c)
}
return c
}
// Request Send request
func (c *Client) Request(ctx context.Context, method, path string, body interface{}, result interface{}) error {
reqURL := fmt.Sprintf("%s%s", c.baseURL, path)
var bodyReader io.Reader
if body != nil {
bodyBytes, err := json.Marshal(body)
if err != nil {
return errors.Wrap(err, "marshal request body failed")
}
bodyReader = bytes.NewReader(bodyBytes)
}
logger.With("url", reqURL).Debug("request url")
req, err := http.NewRequestWithContext(ctx, method, reqURL, bodyReader)
if err != nil {
return errors.Wrap(err, "create request failed")
}
// Set common headers
req.Header.Set("Content-Type", "application/json")
for k, v := range c.headers {
req.Header.Set(k, v)
}
resp, err := c.httpClient.Do(req)
if err != nil {
return errors.Wrap(err, "send request failed")
}
defer resp.Body.Close()
// Read response body
respBody, err := io.ReadAll(resp.Body)
if err != nil {
return errors.Wrap(err, "read response body failed")
}
// Check status code
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return errors.New(fmt.Sprintf("request failed with status %d: %s", resp.StatusCode, string(respBody)))
}
// Parse response
if result != nil {
if err := json.Unmarshal(respBody, result); err == nil {
return nil
}
var respData map[string]interface{}
if err := json.Unmarshal(respBody, &respData); err != nil {
return errors.Wrap(err, "unmarshal response failed")
}
if respData["err"] != nil || respData["msg"] != nil {
return errors.New(respData["msg"].(string))
}
}
return nil
}
// Get Send GET request
func (c *Client) Get(ctx context.Context, path string, result interface{}) error {
return c.Request(ctx, http.MethodGet, path, nil, result)
}
// Post Send POST request
func (c *Client) Post(ctx context.Context, path string, body interface{}, result interface{}) error {
return c.Request(ctx, http.MethodPost, path, body, result)
}
// Put Send PUT request
func (c *Client) Put(ctx context.Context, path string, body interface{}, result interface{}) error {
return c.Request(ctx, http.MethodPut, path, body, result)
}
// Delete Send DELETE request
func (c *Client) Delete(ctx context.Context, path string, result interface{}) error {
return c.Request(ctx, http.MethodDelete, path, nil, result)
}

View File

@@ -0,0 +1,11 @@
package api
// Response Common API response structure
type Response[T any] struct {
// Response data
Data T `json:"data"`
// Error message
Err any `json:"err"`
// Prompt message
Msg string `json:"msg"`
}

View File

@@ -0,0 +1,35 @@
package rule
import (
"context"
"github.com/chaitin/SafeLine/mcp_server/internal/api"
"github.com/chaitin/SafeLine/mcp_server/pkg/errors"
)
type CreateRuleRequest struct {
Name string `json:"name"`
IP []string `json:"ip"`
IsEnabled bool `json:"is_enabled"`
Pattern [][]api.Pattern `json:"pattern"`
Action int `json:"action"`
}
// CreateRule Create new rule
func CreateRule(ctx context.Context, req *CreateRuleRequest) (int64, error) {
if req == nil {
return 0, errors.New("request is required")
}
var resp api.Response[int64]
err := api.Service().Post(ctx, "/api/open/policy", req, &resp)
if err != nil {
return 0, errors.Wrap(err, "failed to create policy rule")
}
if resp.Err != nil {
return 0, errors.New(resp.Msg)
}
return resp.Data, nil
}

View File

@@ -0,0 +1,100 @@
package api
import (
"context"
"sync"
"time"
"github.com/chaitin/SafeLine/mcp_server/internal/config"
"github.com/chaitin/SafeLine/mcp_server/pkg/errors"
"github.com/chaitin/SafeLine/mcp_server/pkg/logger"
)
// APIClient API client implementation
type APIClient struct {
client *Client
config *config.APIConfig
}
var (
instance *APIClient
once sync.Once
)
// Init Initialize API service
func Init(cfg *config.APIConfig) error {
var err error
once.Do(func() {
instance, err = newAPIClient(cfg)
if err != nil {
logger.With("error", err).Error("failed to initialize API service")
return
}
logger.Info("API service initialized successfully")
})
return err
}
// Service Get API service instance
func Service() *APIClient {
if instance == nil {
logger.Error("API service not initialized")
panic("API service not initialized")
}
return instance
}
// newAPIClient Create new API client
func newAPIClient(config *config.APIConfig) (*APIClient, error) {
if config == nil {
return nil, errors.New("config is required")
}
if config.BaseURL == "" {
return nil, errors.New("base_url is required")
}
timeout := 30
if config.Timeout > 0 {
timeout = config.Timeout
}
opts := []ClientOption{
WithBaseURL(config.BaseURL),
WithTimeout(time.Duration(timeout) * time.Second),
WithHeader("User-Agent", "SafeLine-MCP/1.0"),
WithInsecureSkipVerify(config.InsecureSkipVerify),
}
// If token is configured, add authentication header
if config.Token != "" {
opts = append(opts, WithHeader("X-SLCE-API-TOKEN", config.Token))
}
client := NewClient(opts...)
return &APIClient{
client: client,
config: config,
}, nil
}
// Post Send POST request
func (c *APIClient) Post(ctx context.Context, path string, body interface{}, result interface{}) error {
return c.client.Request(ctx, "POST", path, body, result)
}
// Get Send GET request
func (c *APIClient) Get(ctx context.Context, path string, result interface{}) error {
return c.client.Request(ctx, "GET", path, nil, result)
}
// Put Send PUT request
func (c *APIClient) Put(ctx context.Context, path string, body interface{}, result interface{}) error {
return c.client.Request(ctx, "PUT", path, body, result)
}
// Delete Send DELETE request
func (c *APIClient) Delete(ctx context.Context, path string, result interface{}) error {
return c.client.Request(ctx, "DELETE", path, nil, result)
}

View File

@@ -0,0 +1,50 @@
package api
type PolicyRuleAction int
const (
PolicyRuleActionAllow PolicyRuleAction = iota
PolicyRuleActionDeny
PolicyRuleActionMax
)
type Key = string
const (
KeySrcIP Key = "src_ip"
KeyURI Key = "uri"
KeyURINoQuery Key = "uri_no_query"
KeyHost Key = "host"
KeyMethod Key = "method"
KeyReqHeader Key = "req_header"
KeyReqBody Key = "req_body"
KeyGetParam Key = "get_param"
KeyPostParam Key = "post_param"
)
type Op = string
const (
OpEq Op = "eq" // equal
OpNotEq Op = "not_eq" // not equal
OpMatch Op = "match" // match
OpCIDR Op = "cidr" // cidr
OpHas Op = "has" // has
OpNotHas Op = "not_has" // not has
OpPrefix Op = "prefix" // prefix
OpRe Op = "re" // regex
OpIn Op = "in" // in
OpNotIn Op = "not_in" // not in
OpNotCIDR Op = "not_cidr" // not cidr
OpExist Op = "exist" // exist
OpNotExist Op = "not_exist" // not exist
OpGeoEq Op = "geo_eq" // geo equal
OpGeoNotEq Op = "geo_not_eq" // geo not equal
)
type Pattern struct {
K Key `json:"k"`
Op Op `json:"op"`
V []string `json:"v"`
SubK string `json:"sub_k"`
}

View File

@@ -0,0 +1,118 @@
package config
import (
"os"
"strconv"
"github.com/chaitin/SafeLine/mcp_server/pkg/errors"
"gopkg.in/yaml.v3"
)
// Config Global configuration structure
type Config struct {
Server *ServerConfig `yaml:"server"`
Logger *LoggerConfig `yaml:"logger"`
API *APIConfig `yaml:"api"`
}
// APIConfig API configuration
type APIConfig struct {
// API base URL
BaseURL string `yaml:"base_url"`
// API token
Token string `yaml:"token"`
// API timeout
Timeout int `yaml:"timeout"`
// API debug mode
Debug bool `yaml:"debug"`
// API insecure skip verify
InsecureSkipVerify bool `yaml:"insecure_skip_verify"`
}
// ServerConfig Server configuration
type ServerConfig struct {
Name string `yaml:"name"`
Version string `yaml:"version"`
Port int `yaml:"port"`
Host string `yaml:"host"`
Secret string `yaml:"secret"`
}
// LoggerConfig Logger configuration
type LoggerConfig struct {
Level string `yaml:"level"`
FilePath string `yaml:"file_path"`
Console bool `yaml:"console"`
Caller bool `yaml:"caller"`
Development bool `yaml:"development"`
}
var config *Config
// getEnvString Get string value from environment variable, return default value if not exists
func getEnvString(key, defaultValue string) string {
if value, exists := os.LookupEnv(key); exists {
return value
}
return defaultValue
}
// getEnvInt Get integer value from environment variable, return default value if not exists or cannot be parsed
func getEnvInt(key string, defaultValue int) int {
if value, exists := os.LookupEnv(key); exists {
if intValue, err := strconv.Atoi(value); err == nil {
return intValue
}
}
return defaultValue
}
// Load Load configuration file
func Load(path string) error {
data, err := os.ReadFile(path)
if err != nil {
return errors.Wrap(err, "read config file failed")
}
config = &Config{}
if err := yaml.Unmarshal(data, config); err != nil {
return errors.Wrap(err, "unmarshal config failed")
}
// Override configuration from environment variables
if config.Server != nil {
config.Server.Host = getEnvString("LISTEN_ADDRESS", config.Server.Host)
config.Server.Port = getEnvInt("LISTEN_PORT", config.Server.Port)
}
if config.API != nil {
config.API.BaseURL = getEnvString("SAFELINE_ADDRESS", config.API.BaseURL)
config.API.Token = getEnvString("SAFELINE_API_TOKEN", config.API.Token)
}
return nil
}
// GetServer Get server configuration
func GetServer() *ServerConfig {
if config == nil {
return nil
}
return config.Server
}
// GetLogger Get logger configuration
func GetLogger() *LoggerConfig {
if config == nil {
return nil
}
return config.Logger
}
// GetAPI Get API configuration
func GetAPI() *APIConfig {
if config == nil {
return nil
}
return config.API
}

View File

@@ -0,0 +1,45 @@
package analyze
import (
"context"
"github.com/chaitin/SafeLine/mcp_server/internal/api/analyze"
"github.com/chaitin/SafeLine/mcp_server/pkg/logger"
)
type GetAttackEventsParams struct {
IP string `json:"ip" desc:"ip" required:"false"`
Page int `json:"page" desc:"page" required:"false" default:"1"`
PageSize int `json:"page_size" desc:"page size" required:"false" default:"10"`
Start int64 `json:"start" desc:"start unix timestamp in milliseconds" required:"false"`
End int64 `json:"end" desc:"end unix timestamp in milliseconds" required:"false"`
}
type GetAttackEvents struct{}
func (t *GetAttackEvents) Name() string {
return "get_attack_events"
}
func (t *GetAttackEvents) Description() string {
return "get attack events"
}
func (t *GetAttackEvents) Validate(params GetAttackEventsParams) error {
return nil
}
func (t *GetAttackEvents) Execute(ctx context.Context, params GetAttackEventsParams) (analyze.GetEventListResponse, error) {
resp, err := analyze.GetEventList(ctx, &analyze.GetEventListRequest{
IP: params.IP,
PageSize: params.PageSize,
Page: params.Page,
Start: params.Start,
End: params.End,
})
if err != nil {
return analyze.GetEventListResponse{}, err
}
logger.With("total", resp.Total).Info("get attack events")
return *resp, nil
}

View File

@@ -0,0 +1,41 @@
package app
import (
"context"
"github.com/chaitin/SafeLine/mcp_server/internal/api/app"
"github.com/chaitin/SafeLine/mcp_server/pkg/logger"
)
type CreateApp struct{}
type CreateAppParams struct {
ServerNames []string `json:"server_names" desc:"domain list" required:"true"`
Ports []string `json:"ports" desc:"port list" required:"true"`
Upstreams []string `json:"upstreams" desc:"upstream list" required:"true"`
}
func (t *CreateApp) Name() string {
return "create_http_application"
}
func (t *CreateApp) Description() string {
return "create a new website or app"
}
func (t *CreateApp) Validate(params CreateAppParams) error {
return nil
}
func (t *CreateApp) Execute(ctx context.Context, params CreateAppParams) (int64, error) {
id, err := app.CreateApp(ctx, &app.CreateAppRequest{
ServerNames: params.ServerNames,
Ports: params.Ports,
Upstreams: params.Upstreams,
})
if err != nil {
return 0, err
}
logger.Info("create app success", logger.Int64("id", id))
return id, nil
}

View File

@@ -0,0 +1,46 @@
package tools
import (
"context"
"github.com/chaitin/SafeLine/mcp_server/pkg/errors"
"github.com/chaitin/SafeLine/mcp_server/pkg/logger"
)
type CalculateSum struct{}
func (t *CalculateSum) Name() string {
return "calculate_sum"
}
func (t *CalculateSum) Description() string {
return "Add two numbers together"
}
type MyToolInput struct {
A int `json:"a" desc:"number a" required:"true"`
B int `json:"b" desc:"number b" required:"true"`
}
type MyToolOutput struct {
C int `json:"c"`
}
func (t *CalculateSum) Validate(params MyToolInput) error {
return nil
}
func (t *CalculateSum) Execute(ctx context.Context, params MyToolInput) (MyToolOutput, error) {
logger.With("a", params.A).
With("b", params.B).
Debug("Executing calculation")
result := MyToolOutput{
C: params.A + params.B,
}
logger.With("result", result.C).
Debug("Calculation completed")
return result, errors.New("test error")
}

View File

@@ -0,0 +1,19 @@
package tools
import (
"github.com/chaitin/SafeLine/mcp_server/internal/tools/analyze"
"github.com/chaitin/SafeLine/mcp_server/internal/tools/app"
"github.com/chaitin/SafeLine/mcp_server/internal/tools/rule"
)
func init() {
// app
AppendTool(&app.CreateApp{})
// rule
AppendTool(&rule.CreateBlacklistRule{})
AppendTool(&rule.CreateWhitelistRule{})
// analyze
AppendTool(&analyze.GetAttackEvents{})
}

View File

@@ -0,0 +1,66 @@
package rule
import (
"context"
"github.com/chaitin/SafeLine/mcp_server/internal/api"
"github.com/chaitin/SafeLine/mcp_server/internal/api/rule"
"github.com/chaitin/SafeLine/mcp_server/pkg/logger"
)
type CreateBlacklistRule struct{}
type CreateBlacklistRuleParams struct {
Name string `json:"name" desc:"name" required:"true"`
IP []string `json:"ip" desc:"ip" required:"false"`
URINoQuery []string `json:"uri_no_query" desc:"uri_no_query" required:"false"`
}
func (t *CreateBlacklistRule) Name() string {
return "create_blacklist_rule"
}
func (t *CreateBlacklistRule) Description() string {
return "create a new blacklist rule"
}
func (t *CreateBlacklistRule) Validate(params CreateBlacklistRuleParams) error {
return nil
}
func (t *CreateBlacklistRule) Execute(ctx context.Context, params CreateBlacklistRuleParams) (int64, error) {
var pattern [][]api.Pattern
if len(params.IP) > 0 {
pattern = append(pattern, []api.Pattern{
{
K: api.KeySrcIP,
Op: api.OpEq,
V: params.IP,
SubK: "",
},
})
}
if len(params.URINoQuery) > 0 {
pattern = append(pattern, []api.Pattern{
{
K: api.KeyURINoQuery,
Op: api.OpEq,
V: params.URINoQuery,
SubK: "",
},
})
}
id, err := rule.CreateRule(ctx, &rule.CreateRuleRequest{
Name: params.Name,
IP: params.IP,
IsEnabled: true,
Action: int(api.PolicyRuleActionDeny),
Pattern: pattern,
})
if err != nil {
return 0, err
}
logger.With("id", id).Info("create blacklist rule success")
return id, nil
}

View File

@@ -0,0 +1,66 @@
package rule
import (
"context"
"github.com/chaitin/SafeLine/mcp_server/internal/api"
"github.com/chaitin/SafeLine/mcp_server/internal/api/rule"
"github.com/chaitin/SafeLine/mcp_server/pkg/logger"
)
type CreateWhitelistRule struct{}
type CreateWhitelistRuleParams struct {
Name string `json:"name" desc:"name" required:"true"`
IP []string `json:"ip" desc:"ip" required:"false"`
URINoQuery []string `json:"uri_no_query" desc:"uri_no_query" required:"false"`
}
func (t *CreateWhitelistRule) Name() string {
return "create_whitelist_rule"
}
func (t *CreateWhitelistRule) Description() string {
return "create a new whitelist rule"
}
func (t *CreateWhitelistRule) Validate(params CreateWhitelistRuleParams) error {
return nil
}
func (t *CreateWhitelistRule) Execute(ctx context.Context, params CreateWhitelistRuleParams) (int64, error) {
var pattern [][]api.Pattern
if len(params.IP) > 0 {
pattern = append(pattern, []api.Pattern{
{
K: api.KeySrcIP,
Op: api.OpEq,
V: params.IP,
SubK: "",
},
})
}
if len(params.URINoQuery) > 0 {
pattern = append(pattern, []api.Pattern{
{
K: api.KeyURINoQuery,
Op: api.OpEq,
V: params.URINoQuery,
SubK: "",
},
})
}
id, err := rule.CreateRule(ctx, &rule.CreateRuleRequest{
Name: params.Name,
IP: params.IP,
IsEnabled: true,
Action: int(api.PolicyRuleActionAllow),
Pattern: pattern,
})
if err != nil {
return 0, err
}
logger.With("id", id).Info("create whitelist rule success")
return id, nil
}

View File

@@ -0,0 +1,41 @@
package tools
import (
"github.com/chaitin/SafeLine/mcp_server/pkg/logger"
"github.com/chaitin/SafeLine/mcp_server/pkg/mcp"
)
// By deferring the concretization of generic types to the Register method,
// we avoid type inference issues.
// Each Tool is wrapped in a toolWrapper that knows its concrete type,
// allowing correct passing of generic parameters during registration.
type ToolWrapper interface {
Register(s *mcp.MCPServer) error
}
var (
tools = []ToolWrapper{}
)
func AppendTool[T any, R any](tool ...mcp.Tool[T, R]) {
for _, t := range tool {
tools = append(tools, &toolWrapper[T, R]{tool: t})
}
}
func Tools() []ToolWrapper {
return tools
}
type toolWrapper[T any, R any] struct {
tool mcp.Tool[T, R]
}
func (w *toolWrapper[T, R]) Register(s *mcp.MCPServer) error {
logger.Info("Registering tool",
logger.String("name", w.tool.Name()),
logger.String("description", w.tool.Description()),
)
return mcp.RegisterTool(s, w.tool)
}

62
mcp_server/main.go Normal file
View File

@@ -0,0 +1,62 @@
package main
import (
"flag"
"fmt"
"github.com/chaitin/SafeLine/mcp_server/internal/api"
"github.com/chaitin/SafeLine/mcp_server/internal/config"
"github.com/chaitin/SafeLine/mcp_server/internal/tools"
"github.com/chaitin/SafeLine/mcp_server/pkg/logger"
"github.com/chaitin/SafeLine/mcp_server/pkg/mcp"
)
func main() {
configPath := flag.String("config", "config.yaml", "path to config file")
flag.Parse()
if err := config.Load(*configPath); err != nil {
panic(fmt.Errorf("failed to load config: %v", err))
}
logConfig := config.GetLogger()
if err := logger.Init(&logger.Config{
Level: logConfig.Level,
FilePath: logConfig.FilePath,
Console: logConfig.Console,
Caller: logConfig.Caller,
Development: logConfig.Development,
}); err != nil {
panic(fmt.Errorf("failed to init logger: %v", err))
}
logger.With("base_url", config.GetAPI().BaseURL).Info("Initializing API service...")
if err := api.Init(config.GetAPI()); err != nil {
panic(fmt.Errorf("failed to init API service: %v", err))
}
logger.Info("Starting MCP Server...")
serverConfig := config.GetServer()
s := mcp.NewMCPServer(
serverConfig.Name,
serverConfig.Version,
serverConfig.Secret,
)
logger.Info("Registering tools...")
for _, tool := range tools.Tools() {
if err := tool.Register(s); err != nil {
logger.With("error", err).
Error("Failed to register tool")
panic(err)
}
}
addr := fmt.Sprintf("%s:%d", serverConfig.Host, serverConfig.Port)
logger.With("addr", addr).Info("Starting server")
if err := s.Start(addr); err != nil {
logger.With("error", err).
Error("Server failed to start")
panic(err)
}
}

View File

@@ -0,0 +1,72 @@
package config
import (
"os"
"gopkg.in/yaml.v3"
)
// Config Global configuration structure
type Config struct {
Server ServerConfig `yaml:"server"`
Logger LoggerConfig `yaml:"logger"`
}
// ServerConfig Server configuration
type ServerConfig struct {
Name string `yaml:"name"`
Version string `yaml:"version"`
Port int `yaml:"port"`
Host string `yaml:"host"`
Secret string `yaml:"secret"`
}
// LoggerConfig Logger configuration
type LoggerConfig struct {
Level string `yaml:"level"`
FilePath string `yaml:"file_path"`
Console bool `yaml:"console"`
Caller bool `yaml:"caller"`
Development bool `yaml:"development"`
}
var (
globalConfig *Config
)
// Load Load configuration from file
func Load(filename string) error {
data, err := os.ReadFile(filename)
if err != nil {
return err
}
config := &Config{}
if err := yaml.Unmarshal(data, config); err != nil {
return err
}
globalConfig = config
return nil
}
// Get Get global configuration
func Get() *Config {
return globalConfig
}
// GetServer Get server configuration
func GetServer() ServerConfig {
if globalConfig == nil {
return ServerConfig{}
}
return globalConfig.Server
}
// GetLogger Get logger configuration
func GetLogger() LoggerConfig {
if globalConfig == nil {
return LoggerConfig{}
}
return globalConfig.Logger
}

View File

@@ -0,0 +1,136 @@
package errors
import (
"errors"
"fmt"
"runtime"
"strings"
"github.com/chaitin/SafeLine/mcp_server/pkg/logger"
)
var (
// Common errors
ErrInternal = New("internal error")
ErrInvalidParam = New("invalid parameter")
ErrNotFound = New("resource not found")
ErrUnauthorized = New("unauthorized")
ErrForbidden = New("forbidden")
ErrTimeout = New("timeout")
)
// Error Custom error structure
type Error struct {
err error
stack []string
msg string
location string
}
// Error Implement error interface
func (e *Error) Error() string {
if e.msg != "" {
return fmt.Sprintf("%s: %v (at %s)", e.msg, e.err, e.location)
}
return fmt.Sprintf("%v (at %s)", e.err, e.location)
}
// Unwrap Return original error
func (e *Error) Unwrap() error {
if e.err == nil {
return nil
}
if wrapped, ok := e.err.(*Error); ok {
return wrapped.Unwrap()
}
return e.err
}
// Stack Return error stack
func (e *Error) Stack() []string {
return e.stack
}
// Location Return error location
func (e *Error) Location() string {
return e.location
}
// getCallerLocation Get caller location
func getCallerLocation(skip int) string {
_, file, line, ok := runtime.Caller(skip)
if !ok {
return "unknown"
}
return fmt.Sprintf("%s:%d", file, line)
}
// WrapL Wrap error and print log
func WrapL(err error, msg string) error {
if err == nil {
return nil
}
// Get stack trace information
var stack []string
for i := 1; i < 32; i++ {
pc, file, line, ok := runtime.Caller(i)
if !ok {
break
}
fn := runtime.FuncForPC(pc)
if fn == nil {
break
}
name := fn.Name()
if strings.Contains(name, "runtime.") {
break
}
stack = append(stack, fmt.Sprintf("%s:%d", file, line))
}
wrappedErr := &Error{
err: err,
stack: stack,
msg: msg,
location: getCallerLocation(2),
}
// Print error information and stack using logger
logger.With("error", err).
With("location", wrappedErr.location).
With("stack", strings.Join(stack, "\n")).
Error(msg)
return wrappedErr
}
// Is Check error type
func Is(err, target error) bool {
return errors.Is(err, target)
}
// As Type assertion
func As(err error, target interface{}) bool {
return errors.As(err, target)
}
// Wrap Wrap error without printing log
func Wrap(err error, msg string) error {
if err == nil {
return nil
}
return &Error{
err: err,
msg: msg,
location: getCallerLocation(2),
}
}
// New Create new error
func New(text string) error {
return &Error{
err: errors.New(text),
location: getCallerLocation(2),
}
}

View File

@@ -0,0 +1,49 @@
package logger
import (
"go.uber.org/zap"
"go.uber.org/zap/zapcore"
)
// Field 日志字段
type Field = zapcore.Field
// String 创建字符串字段
func String(key string, val string) Field {
return zap.String(key, val)
}
// Int 创建整数字段
func Int(key string, val int) Field {
return zap.Int(key, val)
}
// Int64 创建 int64 字段
func Int64(key string, val int64) Field {
return zap.Int64(key, val)
}
// Float64 创建浮点数字段
func Float64(key string, val float64) Field {
return zap.Float64(key, val)
}
// Bool 创建布尔字段
func Bool(key string, val bool) Field {
return zap.Bool(key, val)
}
// Err 创建错误字段
func Err(err error) Field {
return zap.Error(err)
}
// Any 创建任意类型字段
func Any(key string, val interface{}) Field {
return zap.Any(key, val)
}
// Duration 创建时间段字段
func Duration(key string, val float64) Field {
return zap.Float64(key, val)
}

View File

@@ -0,0 +1,205 @@
package logger
import (
"os"
"path/filepath"
"sync"
"time"
"go.uber.org/zap"
"go.uber.org/zap/zapcore"
)
// Logger 封装 zap.Logger
type Logger struct {
zl *zap.Logger
}
var (
defaultLogger *Logger
once sync.Once
)
// Config 日志配置
type Config struct {
// 日志级别
Level string `json:"level" yaml:"level"`
// 日志文件路径
FilePath string `json:"file_path" yaml:"file_path"`
// 是否输出到控制台
Console bool `json:"console" yaml:"console"`
// 是否记录调用者信息
Caller bool `json:"caller" yaml:"caller"`
// 是否使用开发模式(更详细的日志)
Development bool `json:"development" yaml:"development"`
}
// 默认配置
var defaultConfig = Config{
Level: "info",
FilePath: "logs/mcp.log",
Console: true,
Caller: true,
Development: false,
}
// Init 初始化日志系统
func Init(cfg *Config) error {
var err error
once.Do(func() {
if cfg == nil {
cfg = &defaultConfig
}
// 确保日志目录存在
if cfg.FilePath != "" {
dir := filepath.Dir(cfg.FilePath)
if err = os.MkdirAll(dir, 0755); err != nil {
return
}
}
// 配置编码器
encoderConfig := zapcore.EncoderConfig{
TimeKey: "time",
LevelKey: "level",
NameKey: "logger",
CallerKey: "caller",
MessageKey: "msg",
StacktraceKey: "stacktrace",
LineEnding: zapcore.DefaultLineEnding,
EncodeLevel: zapcore.LowercaseLevelEncoder,
EncodeTime: func(t time.Time, enc zapcore.PrimitiveArrayEncoder) {
enc.AppendString(t.Format("2006-01-02 15:04:05.000"))
},
EncodeDuration: zapcore.SecondsDurationEncoder,
EncodeCaller: zapcore.ShortCallerEncoder,
}
// 设置日志级别
var level zapcore.Level
if err = level.UnmarshalText([]byte(cfg.Level)); err != nil {
level = zapcore.InfoLevel
}
// 创建Core
var cores []zapcore.Core
// 文件输出
if cfg.FilePath != "" {
fileWriter, err := os.OpenFile(cfg.FilePath, os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0644)
if err != nil {
return
}
cores = append(cores, zapcore.NewCore(
zapcore.NewJSONEncoder(encoderConfig),
zapcore.AddSync(fileWriter),
level,
))
}
// 控制台输出
if cfg.Console {
cores = append(cores, zapcore.NewCore(
zapcore.NewConsoleEncoder(encoderConfig),
zapcore.AddSync(os.Stdout),
level,
))
}
// 创建Logger
core := zapcore.NewTee(cores...)
zl := zap.New(core)
// 是否记录调用者信息
if cfg.Caller {
zl = zl.WithOptions(zap.AddCaller())
}
// 是否使用开发模式
if cfg.Development {
zl = zl.WithOptions(zap.Development())
}
defaultLogger = &Logger{zl: zl}
})
return err
}
// GetLogger 获取日志实例
func GetLogger() *Logger {
if defaultLogger == nil {
Init(nil)
}
return defaultLogger
}
// With 创建带有字段的新Logger
func (l *Logger) With(key string, value interface{}) *Logger {
return &Logger{zl: l.zl.With(Any(key, value))}
}
// Debug level
func (l *Logger) Debug(msg string, fields ...Field) {
l.zl.Debug(msg, fields...)
}
// Info level
func (l *Logger) Info(msg string, fields ...Field) {
l.zl.Info(msg, fields...)
}
// Warn level
func (l *Logger) Warn(msg string, fields ...Field) {
l.zl.Warn(msg, fields...)
}
// Error level
func (l *Logger) Error(msg string, fields ...Field) {
l.zl.Error(msg, fields...)
}
// Fatal level
func (l *Logger) Fatal(msg string, fields ...Field) {
l.zl.Fatal(msg, fields...)
}
// 全局函数
// With 创建带有字段的新Logger
func With(key string, value interface{}) *Logger {
l := GetLogger()
// 为链式调用创建新的logger实例并添加caller skip
return &Logger{zl: l.zl.WithOptions(zap.AddCallerSkip(1)).With(Any(key, value))}
}
// Debug level
func Debug(msg string, fields ...Field) {
l := GetLogger()
l.zl.WithOptions(zap.AddCallerSkip(1)).Debug(msg, fields...)
}
// Info level
func Info(msg string, fields ...Field) {
l := GetLogger()
l.zl.WithOptions(zap.AddCallerSkip(1)).Info(msg, fields...)
}
// Warn level
func Warn(msg string, fields ...Field) {
l := GetLogger()
l.zl.WithOptions(zap.AddCallerSkip(1)).Warn(msg, fields...)
}
// Error level
func Error(msg string, fields ...Field) {
l := GetLogger()
l.zl.WithOptions(zap.AddCallerSkip(1)).Error(msg, fields...)
}
// Fatal level
func Fatal(msg string, fields ...Field) {
l := GetLogger()
l.zl.WithOptions(zap.AddCallerSkip(1)).Fatal(msg, fields...)
}

148
mcp_server/pkg/mcp/mcp.go Normal file
View File

@@ -0,0 +1,148 @@
package mcp
import (
"context"
"encoding/json"
"fmt"
"net/http"
"strconv"
"github.com/chaitin/SafeLine/mcp_server/pkg/errors"
"github.com/chaitin/SafeLine/mcp_server/pkg/logger"
"github.com/mark3labs/mcp-go/mcp"
"github.com/mark3labs/mcp-go/server"
"github.com/mcuadros/go-defaults"
)
type Tool[T any, R any] interface {
Name() string
Description() string
Execute(ctx context.Context, params T) (R, error)
Validate(params T) error
}
type SSEServer struct {
sse *server.SSEServer
secret string
}
func (s *SSEServer) Start(addr string) error {
srv := &http.Server{
Addr: addr,
Handler: s,
}
return srv.ListenAndServe()
}
func (s *SSEServer) ServeHTTP(w http.ResponseWriter, r *http.Request) {
if s.secret == "" {
s.sse.ServeHTTP(w, r)
return
}
messagePath := s.sse.CompleteMessagePath()
if messagePath != "" && r.URL.Path == messagePath {
secret := r.Header.Get("Secret")
if secret != s.secret {
http.Error(w, "Unauthorized", http.StatusUnauthorized)
return
}
}
s.sse.ServeHTTP(w, r)
}
type MCPServer struct {
server *server.MCPServer
sse *SSEServer
}
func NewMCPServer(name, version string, secret string) *MCPServer {
s := server.NewMCPServer(
name,
version,
server.WithLogging(),
)
return &MCPServer{
server: s,
sse: &SSEServer{sse: server.NewSSEServer(s), secret: secret},
}
}
func (s *MCPServer) Start(addr string) error {
return s.sse.Start(addr)
}
func handleToolCall[T any, R any](ctx context.Context, request mcp.CallToolRequest, tool Tool[T, R]) (result *mcp.CallToolResult, err error) {
defer func() {
if r := recover(); r != nil {
err = fmt.Errorf("panic recovered: %v", r)
}
}()
var raw []byte
raw, err = json.Marshal(request.Params.Arguments)
if err != nil {
return nil, errors.Wrap(err, "marshal arguments failed")
}
var params T
defaults.SetDefaults(&params)
if err = json.Unmarshal(raw, &params); err != nil {
return nil, errors.Wrap(err, "unmarshal parameters failed")
}
if err = tool.Validate(params); err != nil {
return nil, err
}
var execResult R
execResult, err = tool.Execute(ctx, params)
if err != nil {
return nil, err
}
v := any(execResult)
switch v := v.(type) {
case string:
return mcp.NewToolResultText(v), nil
case []byte:
return mcp.NewToolResultText(string(v)), nil
case int, int8, int16, int32, int64, uint, uint8, uint16, uint32, uint64, float32, float64:
return mcp.NewToolResultText(json.Number(fmt.Sprint(v)).String()), nil
case bool:
return mcp.NewToolResultText(strconv.FormatBool(v)), nil
default:
bytes, err := json.Marshal(v)
if err != nil {
return nil, errors.New("invalid result type")
}
return mcp.NewToolResultText(string(bytes)), nil
}
}
func RegisterTool[T any, R any](s *MCPServer, tool Tool[T, R]) error {
var v T
opts, err := SchemaToOptions(v)
if err != nil {
return err
}
opts = append(opts, mcp.WithDescription(tool.Description()))
t := mcp.NewTool(tool.Name(),
opts...,
)
s.server.AddTool(t, func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
result, err := handleToolCall(ctx, request, tool)
if err != nil {
logger.With("error", err).Error("handle tool call failed")
if wrapped, ok := err.(*errors.Error); ok {
return nil, wrapped.Unwrap()
}
return nil, err
}
return result, nil
})
return nil
}

View File

@@ -0,0 +1,169 @@
package mcp
import (
"encoding/json"
"reflect"
"strconv"
"strings"
"github.com/mark3labs/mcp-go/mcp"
)
// SchemaToOptions Convert struct to MCP ToolOption list
func SchemaToOptions(schema any) ([]mcp.ToolOption, error) {
t := reflect.TypeOf(schema)
if t.Kind() == reflect.Ptr {
t = t.Elem()
}
var options []mcp.ToolOption
for i := 0; i < t.NumField(); i++ {
field := t.Field(i)
jsonTag := field.Tag.Get("json")
if jsonTag == "" {
continue
}
desc := field.Tag.Get("desc")
required := field.Tag.Get("required") == "true"
enumTag := field.Tag.Get("enum")
defaultTag := field.Tag.Get("default")
minTag := field.Tag.Get("min")
maxTag := field.Tag.Get("max")
opts := []mcp.PropertyOption{}
if desc != "" {
opts = append(opts, mcp.Description(desc))
}
if required {
opts = append(opts, mcp.Required())
}
if enumTag != "" && field.Type.Kind() == reflect.String {
enumValues := strings.Split(enumTag, ",")
opts = append(opts, mcp.Enum(enumValues...))
}
switch field.Type.Kind() {
case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64, reflect.Float32, reflect.Float64:
if defaultTag != "" {
if defaultValue, err := strconv.Atoi(defaultTag); err == nil {
opts = append(opts, mcp.DefaultNumber(float64(defaultValue)))
}
}
if minTag != "" {
if minValue, err := strconv.Atoi(minTag); err == nil {
opts = append(opts, mcp.Min(float64(minValue)))
}
}
if maxTag != "" {
if maxValue, err := strconv.Atoi(maxTag); err == nil {
opts = append(opts, mcp.Max(float64(maxValue)))
}
}
options = append(options, mcp.WithNumber(jsonTag, opts...))
case reflect.Bool:
if defaultTag != "" {
if defaultValue, err := strconv.ParseBool(defaultTag); err == nil {
opts = append(opts, mcp.DefaultBool(defaultValue))
}
}
options = append(options, mcp.WithBoolean(jsonTag, opts...))
case reflect.String:
if defaultTag != "" {
opts = append(opts, mcp.DefaultString(defaultTag))
}
options = append(options, mcp.WithString(jsonTag, opts...))
case reflect.Struct:
subSchema := reflect.New(field.Type).Interface()
subOptions, err := SchemaToOptions(subSchema)
if err != nil {
return nil, err
}
// Create a temporary Tool to get JSON Schema of sub-struct
tempTool := mcp.NewTool("temp", subOptions...)
tempJSON, _ := tempTool.MarshalJSON()
var tempMap map[string]any
if err := json.Unmarshal(tempJSON, &tempMap); err != nil {
continue
}
// Extract properties from temporary Tool
if inputSchema, ok := tempMap["inputSchema"].(map[string]any); ok {
if properties, ok := inputSchema["properties"].(map[string]any); ok {
// Check if there are required fields
if required, ok := inputSchema["required"].([]any); ok {
// Add required field information to corresponding properties
for _, req := range required {
if reqStr, ok := req.(string); ok {
if prop, ok := properties[reqStr].(map[string]any); ok {
prop["required"] = true
}
}
}
}
opts = append(opts, mcp.Properties(properties))
}
}
options = append(options, mcp.WithObject(jsonTag, opts...))
case reflect.Slice:
elemType := field.Type.Elem()
var items map[string]any
switch elemType.Kind() {
case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64, reflect.Float32, reflect.Float64:
items = map[string]any{
"type": "number",
}
case reflect.Bool:
items = map[string]any{
"type": "boolean",
}
case reflect.String:
items = map[string]any{
"type": "string",
}
case reflect.Struct:
subSchema := reflect.New(elemType).Interface()
subOptions, err := SchemaToOptions(subSchema)
if err != nil {
return nil, err
}
// Create a temporary Tool to get JSON Schema of sub-struct
tempTool := mcp.NewTool("temp", subOptions...)
tempJSON, _ := tempTool.MarshalJSON()
var tempMap map[string]any
if err := json.Unmarshal(tempJSON, &tempMap); err != nil {
continue
}
// Extract properties from temporary Tool
if inputSchema, ok := tempMap["inputSchema"].(map[string]any); ok {
if properties, ok := inputSchema["properties"].(map[string]any); ok {
// Check if there are required fields
if required, ok := inputSchema["required"].([]any); ok {
// Add required field information to corresponding properties
for _, req := range required {
if reqStr, ok := req.(string); ok {
if prop, ok := properties[reqStr].(map[string]any); ok {
prop["required"] = true
}
}
}
}
items = map[string]any{
"type": "object",
"properties": properties,
}
}
}
}
opts = append(opts, mcp.Items(items))
options = append(options, mcp.WithArray(jsonTag, opts...))
}
}
return options, nil
}

View File

@@ -0,0 +1,227 @@
package mcp
import (
"reflect"
"testing"
"github.com/mark3labs/mcp-go/mcp"
)
func TestSchemaToOptions(t *testing.T) {
tests := []struct {
name string
args any
want mcp.Tool
}{
{
name: "test number",
args: struct {
A int `json:"a" desc:"number a" required:"true"`
}{},
want: mcp.NewTool("test number",
mcp.WithNumber("a", mcp.Required(), mcp.Description("number a")),
),
},
{
name: "test number int64",
args: struct {
A int64 `json:"a" desc:"number a" required:"true"`
}{},
want: mcp.NewTool("test number int64",
mcp.WithNumber("a", mcp.Required(), mcp.Description("number a")),
),
},
{
name: "test number float64",
args: struct {
A float64 `json:"a" desc:"number a" required:"true"`
}{},
want: mcp.NewTool("test number float64",
mcp.WithNumber("a", mcp.Required(), mcp.Description("number a")),
),
},
{
name: "test number default",
args: struct {
A int `json:"a" desc:"number a" required:"true" default:"10"`
}{},
want: mcp.NewTool("test number default",
mcp.WithNumber("a", mcp.Required(), mcp.Description("number a"), mcp.DefaultNumber(10)),
),
},
{
name: "test number min max",
args: struct {
A int `json:"a" desc:"number a" required:"true" min:"10" max:"20"`
}{},
want: mcp.NewTool("test number min max",
mcp.WithNumber("a", mcp.Required(), mcp.Description("number a"), mcp.Min(10), mcp.Max(20)),
),
},
{
name: "test number optional",
args: struct {
A int `json:"a" desc:"number a"`
}{},
want: mcp.NewTool("test number optional",
mcp.WithNumber("a", mcp.Description("number a")),
),
},
{
name: "test boolean",
args: struct {
A bool `json:"a" desc:"boolean a" required:"true"`
}{},
want: mcp.NewTool("test boolean",
mcp.WithBoolean("a", mcp.Required(), mcp.Description("boolean a")),
),
},
{
name: "test string",
args: struct {
A string `json:"a" desc:"string a" required:"true"`
}{},
want: mcp.NewTool("test string",
mcp.WithString("a", mcp.Required(), mcp.Description("string a")),
),
},
{
name: "test string default",
args: struct {
A string `json:"a" desc:"string a" required:"true" default:"hello"`
}{},
want: mcp.NewTool("test string default",
mcp.WithString("a", mcp.Required(), mcp.Description("string a"), mcp.DefaultString("hello")),
),
},
{
name: "test string enum",
args: struct {
A string `json:"a" desc:"string a" required:"true" enum:"1,2,3"`
}{},
want: mcp.NewTool("test string enum",
mcp.WithString("a", mcp.Required(), mcp.Description("string a"), mcp.Enum("1", "2", "3")),
),
},
{
name: "test object",
args: struct {
A struct {
B int `json:"b" desc:"number b" required:"true"`
} `json:"a" desc:"object a" required:"true"`
}{},
want: mcp.NewTool("test object",
mcp.WithObject("a", mcp.Required(), mcp.Description("object a"),
mcp.Properties(map[string]any{
"b": map[string]any{
"type": "number",
"description": "number b",
"required": true,
},
}),
),
),
},
{
name: "test object optional",
args: struct {
A struct {
B int `json:"b" desc:"number b" required:"true"`
C int `json:"c" desc:"number c"`
} `json:"a" desc:"object a" required:"true"`
}{},
want: mcp.NewTool("test object optional",
mcp.WithObject("a", mcp.Required(), mcp.Description("object a"),
mcp.Properties(map[string]any{
"b": map[string]any{
"type": "number",
"description": "number b",
"required": true,
},
"c": map[string]any{
"type": "number",
"description": "number c",
},
}),
),
),
},
{
name: "test nested object",
args: struct {
A struct {
B struct {
C int `json:"c" desc:"number c" required:"true"`
} `json:"b" desc:"object b" required:"true"`
} `json:"a" desc:"object a" required:"true"`
}{},
want: mcp.NewTool("test nested object",
mcp.WithObject("a", mcp.Required(), mcp.Description("object a"),
mcp.Properties(map[string]any{
"b": map[string]any{
"type": "object",
"description": "object b",
"required": true,
"properties": map[string]any{
"c": map[string]any{
"type": "number",
"description": "number c",
"required": true,
},
},
},
}),
),
),
},
{
name: "test array",
args: struct {
A []int `json:"a" desc:"array a" required:"true"`
}{},
want: mcp.NewTool("test array",
mcp.WithArray("a", mcp.Required(), mcp.Description("array a"),
mcp.Items(map[string]any{
"type": "number",
}),
),
),
},
{
name: "test array of object",
args: struct {
A []struct {
B int `json:"b" desc:"number b" required:"true"`
} `json:"a" desc:"array of object a" required:"true"`
}{},
want: mcp.NewTool("test array of object",
mcp.WithArray("a", mcp.Required(), mcp.Description("array of object a"),
mcp.Items(map[string]any{
"type": "object",
"properties": map[string]any{
"b": map[string]any{
"type": "number",
"description": "number b",
"required": true,
},
},
}),
),
),
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, err := SchemaToOptions(tt.args)
if err != nil {
t.Errorf("SchemaToOptions() error = %v", err)
return
}
s1, _ := mcp.NewTool(tt.name, got...).MarshalJSON()
s2, _ := tt.want.MarshalJSON()
if !reflect.DeepEqual(s1, s2) {
t.Errorf("\n got %v\n want %v", string(s1), string(s2))
}
})
}
}

5
version.json Normal file
View File

@@ -0,0 +1,5 @@
{
"latest_version": "v9.2.7",
"rec_version": "v9.2.7",
"lts_version": "v9.1.0-lts"
}