Content
# Tool List
## mcp-server
A scalable MCP (Model Context Protocol) server implemented in Go, communicating with MCP clients (such as Claude Code, Claude Desktop, etc.) through stdio. The project adopts a tool registry center model + driver interface abstraction, with each MCP tool integrated as an independent subpackage, and database-related tools supporting multiple dialects through the `Driver` interface.
Current version: `0.2.0`. Built-in tools:
- `add` — Add two numbers
- `db_list_tables` — List user tables under the data source
- `db_describe_table` — Return table columns and index information
- `db_query` — Execute read-only SQL (supporting parameterization, max_rows limit, and dual protection in r mode)
- `db_execute` — Execute write SQL (only available for rw sources)
## Prerequisites
- Go 1.21 or higher
- Accessible Go module proxy (for domestic network environments, use `https://goproxy.cn`)
- At least one reachable database instance (the first version supports MySQL)
## Directory Structure
```
go-mcp-server/
├── main.go # Entry: flag → config → pool → server → tools → stdio
├── config.yaml # Real configuration (.gitignore, not submitted)
├── config.example.yaml # Configuration template, submitted
├── bin/ # All build products (already gitignored)
├── internal/
│ ├── config/
│ │ └── config.go # YAML configuration + verification
│ ├── server/
│ │ └── server.go # Thin wrapper for MCP server
│ └── tools/
│ ├── registry.go # Tool registry center
│ ├── add/
│ │ └── add.go # add tool
│ └── db/
│ ├── driver/ # Driver interface layer (dialect-agnostic)
│ │ ├── driver.go # Driver / ReadOnlyExec interface
│ │ ├── types.go # TableSchema / ColumnInfo / IndexInfo
│ │ └── registry.go # Register / Get / Names
│ ├── drivers/ # Driver implementation (one driver per subpackage)
│ │ └── mysql/
│ │ └── mysql.go # MySQL driver implementation + init() self-registration
│ ├── pool.go # Connection pool
│ ├── guard.go # SQL guardian (first token whitelist + multi-statement rejection)
│ ├── list_tables.go # db_list_tables
│ ├── describe_table.go # db_describe_table
│ ├── query.go # db_query
│ ├── execute.go # db_execute
│ ├── render.go # JSON result / error helper
│ └── register.go # blank import drivers/** + Register
```
## Build
```bash
go mod tidy
# Windows
go build -o bin/mcp-server.exe .
# macOS / Linux
go build -o bin/mcp-server .
```
Output products are uniformly placed in `bin/`. If dependency pulling times out, add a domestic proxy:
```bash
GOPROXY=https://goproxy.cn,direct go mod tidy
GOPROXY=https://goproxy.cn,direct go build -o bin/mcp-server.exe .
```
## Configuration
A YAML configuration is required for service startup. Specify it using `--config <path>`, with a default path of `./config.yaml`. Copy from `config.example.yaml` to get started:
```bash
cp config.example.yaml config.yaml
# Edit config.yaml to fill in real connection parameters
./bin/mcp-server.exe --config ./config.yaml
```
### Field Description (General)
| Field | Required | Default | Description |
| --- | --- | --- | --- |
| `defaults.max_rows` | No | `100` | `db_query` row limit; caller `max_rows` parameter can only decrease, not increase |
| `defaults.query_timeout` | No | `30s` | Single SQL timeout (Go `time.Duration` format) |
| `databases.<key>` | Yes | — | Map key is the data source identifier; LLM call `source: "<key>"` reference |
| `databases.<key>.driver` | Yes | — | Driver name; currently supports `mysql`, can be extended in the future |
| `databases.<key>.host` | Yes | — | Database host |
| `databases.<key>.database` | Yes | — | Database name (note the distinction from map key) |
| `databases.<key>.username` | Yes | — | |
| `databases.<key>.password` | No | `""` | |
| `databases.<key>.mode` | Yes | — | `r` (read-only) or `rw` (read-write) |
| `databases.<key>.params` | No | `{}` | `map[string]string`, pass-through driver-specific DSN parameters; prohibit `multiStatements: "true"` |
### Driver-Specific Fields (MySQL)
| Field | Required | Default | Mapping Target |
| --- | --- | --- | --- |
| `port` | No | `3306` | DSN TCP port |
| `charset` | No | `utf8mb4` | DSN `charset` |
| `collation` | No | Server default | DSN `collation` |
| `timezone` | No | `UTC` | DSN `loc` parameter (must be a Go `time.Location` name, e.g., `UTC` / `Local` / `Asia/Shanghai` / `Etc/GMT+5`; offset strings like `+08:00` are not supported) |
| `params.parseTime` | No | `"true"` | Parse DATETIME column as `time.Time` |
### Connection Pool
| Field | Default | Mapping to Go `*sql.DB` |
| --- | --- | --- |
| `pool.max_connections` | `10` | `SetMaxOpenConns` |
| `pool.min_connections` | `0` | `SetMaxIdleConns` (see footnote below) |
| `pool.connect_timeout` | `10s` | Startup Ping timeout + MySQL DSN `timeout` |
| `pool.max_idle_time` | `0` (unlimited) | `SetConnMaxIdleTime` |
> **Footnote on `min_connections` difference with hyperf**: Under hyperf swoole, `min_connections` is the number of connections pre-built at startup; Go `database/sql` has lazy connection, no "preheating" semantics, mapping it to `SetMaxIdleConns` (idle connection limit). Both have the same steady-state QPS, but the first batch of requests is slightly slower on Go.
### Multi-Source and Read-Write Separation Example
```yaml
defaults:
max_rows: 100
query_timeout: 30s
databases:
master:
driver: mysql
host: master.db.example
database: app
username: app_rw
password: <fill in>
mode: rw
replica:
driver: mysql
host: replica.db.example
database: app
username: app_ro
password: <fill in>
mode: r
```
LLM call uses `source: "master"` / `source: "replica"` to switch.
## Database Tools
### `db_list_tables`
Parameters:
```json
{ "source": "default" }
```
Returns (JSON in text):
```json
{ "source": "default", "tables": ["users", "orders", "..."] }
```
### `db_describe_table`
Parameters:
```json
{ "source": "default", "table": "users" }
```
`table` must match `^[A-Za-z0-9_$]+$`, otherwise blocked by the tool layer whitelist (does not reach the database).
Returns:
```json
{
"source": "default",
"table": "users",
"columns": [
{"name": "id", "type": "bigint(20)", "nullable": false, "key": "PRI", "default": null},
...
],
"indexes": [
{"name": "PRIMARY", "columns": ["id"], "unique": true},
...
]
}
```
### `db_query`
Parameters:
```json
{ "source": "default", "sql": "SELECT * FROM users WHERE id = ?", "args": [1], "max_rows": 50 }
```
- `args` parameter binding through `?` placeholder (MySQL), never spliced into SQL string
- `max_rows` optional; cannot increase global limit `defaults.max_rows`, only decrease
Returns:
```json
{
"source": "default",
"columns": ["id", "email"],
"rows": [[1, "a@x.com"], [2, "b@x.com"]],
"row_count": 2,
"truncated": false
}
```
### `db_execute`
Parameters:
```json
{ "source": "master", "sql": "INSERT INTO users (email) VALUES (?)", "args": ["a@x.com"] }
```
- Only available for sources with `mode=rw`
- First keyword cannot be `SELECT`/`SHOW`/`DESCRIBE`/`DESC`/`EXPLAIN` (use `db_query` instead)
Returns:
```json
{ "source": "master", "rows_affected": 1, "last_insert_id": 42 }
```
## SQL Audit Log
After each `db_query` / `db_execute` call (regardless of success or failure), a JSON record is appended to the local audit log. Used for post-incident investigation, security audit, and slow query analysis.
- **Location**: Under the process startup cwd, `./logs/sql-YYYY-MM-DD.log` (relative path; `./logs/` automatically created on first write)
- **Format**: [JSONL](https://jsonlines.org/) (one JSON per line, separated by `\n`)
- **Rotation**: By **local time zone** date; after crossing the date, the next write automatically switches to a new file
- **Concurrency Safety**: Multiple MCP requests concurrently calling each line atomically complete
- **Log Write Failure Does Not Affect MCP Response**: Write failure only prints a warning line in stderr (without `sql` / `args`), and the MCP call returns normally
### Field Table
| Field | Type | Occurrence Timing | Description |
| --- | --- | --- | --- |
| `ts` | string | Always | `YYYY-MM-DD HH:MM:SS` (local time zone, second-level, without time zone suffix) |
| `source` | string | Always | Data source key (if source is not recognized, the original input is still recorded) |
| `mode` | string | Always | `r` / `rw`; if source is not hit, it is `""` |
| `tool` | string | Always | `db_query` / `db_execute` |
| `sql` | string | Always | User's original SQL (including `?` placeholder, **not inline**) |
| `sql_rendered` | string | Always | Driver dialect args inline after readable form; **only for human reading, not executable** |
| `args` | array | Always | Placeholder binding value array (bytes base64) |
| `duration_ms` | integer | Always | Handler total time consumption |
| `ok` | boolean | Always | Handler is not marked as isError |
| `rows` | integer | `db_query` success | Actual returned rows |
| `truncated` | boolean | `db_query` success | Whether truncated due to `max_rows` |
| `rows_affected` | integer | `db_execute` success | |
| `last_insert_id` | integer | `db_execute` success | |
| `err` | string | `ok=false` | Single-line error summary (consistent with MCP error response text) |
### Example Line
```json
{"ts":"2026-05-06 10:44:24","source":"default","mode":"r","tool":"db_query","sql":"SELECT 1 AS one, ? AS two","sql_rendered":"SELECT 1 AS one, 42 AS two","args":[42],"duration_ms":266,"rows":1,"truncated":false,"ok":true}
{"ts":"2026-05-06 10:44:24","source":"default","mode":"r","tool":"db_query","sql":"UPDATE t SET x = ? WHERE id = ?","sql_rendered":"UPDATE t SET x = 'v' WHERE id = 1","args":["v",1],"duration_ms":0,"ok":false,"err":"source=default mode=r: The source only allows read-only statements..."}
```
### jq Analysis Example
```bash
# Total calls for a day
jq -s 'length' logs/sql-2026-05-06.log
# Failed calls
jq 'select(.ok==false)' logs/sql-2026-05-06.log
# Top 10 by time consumption in descending order
jq -s 'sort_by(-.duration_ms) | .[0:10]' logs/sql-2026-05-06.log
# Frequency of SELECT statements for a source
jq 'select(.source=="default" and .tool=="db_query") | .sql_rendered' logs/sql-2026-05-06.log
```
> ⚠ **Log contains real SQL and args values, treated as sensitive files**: `.gitignore` already includes `/logs/`; suggest regular rotation, archiving, or encryption for static storage, and access permissions minimized. See "Security Model > Audit Log" for details.
## Apipost Tool
Optional access to [Apipost Open API V2](docs/apipost%20%E5%BC%80%E6%94%BE%E6%8E%A5%E5%8F%A3%E6%96%87%E6%A1%A3%20V2%E7%89%88%E6%9C%AC%EF%BC%88saas%E7%89%88%EF%BC%89.md), allowing LLM to directly read and write Apipost API entries when writing handlers or modifying interface definitions. When `config.yaml` contains a valid `apipost` section, 9 tools are registered; otherwise, a line is printed to stderr: `apipost not configured; skipping apipost tools`, without blocking process startup.
### Configuration
```yaml
apipost:
base_url: https://v2-openapi.apipost.net # Official cloud / self-hosted domain
api_token: apt_xxxxxxxxxxxxxxxxxxxxxxxx # Personal / team Token
project_name: My Project # Apipost global Header required
team_id: 1bfd2a3823426001 # Optional, default team for list_projects
project_id: 1bfd2a779bc26001 # Optional, default project for other tools
request_timeout: 30s # Single HTTP timeout
max_response_bytes: 10485760 # Response body byte limit (10 MiB)
```
| Field | Required | Default | Description |
| --- | --- | --- | --- |
| `base_url` | Yes | — | Must start with `http://` or `https://` |
| `api_token` | Yes | — | Injected as `api-token` Header; **will not** appear in any logs / errors / responses |
| `project_name` | Yes | — | Injected as `project_name` Header; required for all Apipost open APIs |
| `team_id` | No | `""` | Optional default team, used if not provided by tool |
| `project_id` | No | `""` | Optional default project, used if not provided by tool |
| `request_timeout` | No | `30s` | |
| `max_response_bytes` | No | `10485760` | Truncated responses marked with `truncated: true` |
### Tool List
| Tool | Method | Endpoint | Key Parameters |
| --- | --- | --- | --- |
| `apipost_list_projects` | GET | `/open/project/list` | `team_id?` / `action?` (default `0`) |
| `apipost_get_project` | GET | `/open/project/info` | `project_id?` |
| `apipost_list_apis` | GET | `/open/apis/list` | `project_id?` |
| `apipost_get_apis` | POST | `/open/apis/details` | `target_ids[]` (single resource: `[id]`) |
| `apipost_create_http_api` | POST | `/open/apis/create` | `body` = HTTP API complete definition (`target_type` auto-filled `api`) |
| `apipost_update_api` | POST | `/open/apis/update` | `body` with `target_id` **complete** definition |
| `apipost_delete_apis` | POST | `/open/apis/delete` | `target_ids[]` |
| `apipost_list_api_cases` | GET | `/open/apis/sample` | Optional `target_ids[]` / `sample_ids[]` |
| `apipost_create_api_case` | POST | `/open/apis/sample/create` | `body` with `target_id` (belonging API) / `name` / `method` / `url` / `request` |
### ⚠ Pitfalls of Modifying APIs
`apipost_update_api` has a design quirk: non-required fields not sent will be set to default values. This means sending only patch fields (e.g., only changing `name`) will **clear entire fields like `request` / `response`**.
Correct approach: **first `apipost_get_apis` to get complete definition → make local changes on returned data → send as `body` to `apipost_update_api`**.
### Response Format
Success:
```json
{
"tool": "apipost_get_apis",
"http_status": 200,
"data": { "code": 0, "msg": "Success", "data": [ ... Apipost original data ... ] }
}
```
Failure (MCP `isError: true`):
```json
{
"tool": "apipost_update_api",
"http_status": 200,
"error": "apipost: code=10020 msg=target_id does not exist"
}
```
`data` field **passed through** Apipost response body (including `{code, msg, data}` structure), not recomposed.
### Minimum Token Permissions
Apipost Token is the sole authorization credential — misusing `apipost_delete_apis` will have direct effects. In production, consider a **read-only or project-scoped token** to avoid unintended impacts across projects or teams.
## Security Model
This project assumes LLM-generated SQL is not entirely trustworthy and has designed multiple layers of defense:
1. **Account permissions (user-assured)**: In production, recommend **minimum permission accounts** for each source; read-only sources use database-level read-only users, not relying on tool-level exclusive protection
2. **`r` mode dual protection**:
- **Tool-level token whitelist**: SQL comment-stripped first keyword must be ∈ `{SELECT, SHOW, DESCRIBE, DESC, EXPLAIN, WITH, VALUES}`
- **Driver-level read-only context**: MySQL driver's `BeginReadOnly` returns `BeginTx(ReadOnly=true)`; even if the whitelist is bypassed, `FOR UPDATE` / DML / DDL will be directly rejected by the database
3. **Multi-statement prohibition**: DSN does not enable `multiStatements`, and tool-level string checks also reject; `params` prohibit `multiStatements: "true"`
4. **Parameterization**: `args` are bound as placeholders, and the driver is responsible for escaping, never string concatenation
5. **Timeout**: All SQL calls are constrained by `defaults.query_timeout`, and timeouts will cancel underlying queries
6. **Row limit**: `db_query` returned `rows` are at most `defaults.max_rows`, which LLM cannot bypass
7. **Desensitization**: `password` and assembled DSN do not appear in any logs or error outputs
### Recommended Database Account Configuration
- `r` source: only grant `SELECT`, not `INSERT/UPDATE/DELETE/DROP/CREATE`, etc.
- `rw` source: grant minimum permissions required by business; avoid sensitive permissions like `SUPER`, `FILE` ( `INTO OUTFILE` is SELECT but writes files)
- Use strong passwords; `params.tls` configures TLS connections (recommended for production)
### Audit Logs
See "SQL Audit Logs" section above. Key points:
- Logs **enabled by default**, cannot be disabled (v1 has no config switch; can be opened with subsequent changes)
- File path `./logs/sql-*.log` is ignored by `.gitignore` by default to prevent business SQL / parameter values from being accidentally committed
- Treat as sensitive data: recommend **periodic rotation**, **offline archiving**, and **encrypted static storage**; file system permissions `0640` (owner readable, group read-only)
- Log write failures **will not** cause MCP requests to fail: disk full / directory read-only degrade to stderr one-line alerts, without SQL or args content
### Apipost Token Handling
- `api_token` injected as `api-token` HTTP Header; **does not appear** in stderr startup logs, MCP responses, tool error text, or process outputs
- All HTTP error paths sanitized before returning to replace Token plaintext with `***`
- `project_name` as Apipost global required Header, also injected uniformly from config; project name generally not considered secret but similarly not actively echoed
- Response body size limit (default 10 MiB) prevents exporting entire projects or accidentally checking large datasets from bursting LLM context
## Adding New Drivers
Example: adding PostgreSQL, three steps:
**1. New implementation package** `internal/tools/db/drivers/pgsql/pgsql.go`, implementing `driver.Driver` interface:
```go
package pgsql
import (
"context"
"database/sql"
_ "github.com/jackc/pgx/v5/stdlib" // blank import underlying database/sql driver
"example.com/mcp-server/internal/config"
"example.com/mcp-server/internal/tools/db/driver"
)
func init() { driver.Register(&Driver{}) }
type Driver struct{}
func (Driver) Name() string { return "pgsql" }
func (Driver) SQLDriverName() string { return "pgx" }
func (Driver) BuildDSN(cfg config.SourceConfig) (string, error) {
// assemble PG DSN: "postgres://user:pass@host:port/db?..."
}
func (Driver) QuoteIdentifier(name string) string {
return `"` + strings.ReplaceAll(name, `"`, `""`) + `"`
}
func (Driver) ListTables(ctx context.Context, db *sql.DB) ([]string, error) {
// e.g., SELECT tablename FROM pg_tables WHERE schemaname = current_schema()
}
func (Driver) DescribeTable(ctx context.Context, db *sql.DB, table string) (*driver.TableSchema, error) {
// query information_schema.columns / pg_indexes, map to TableSchema
}
func (Driver) BeginReadOnly(ctx context.Context, db *sql.DB) (driver.ReadOnlyExec, error) {
tx, err := db.BeginTx(ctx, &sql.TxOptions{ReadOnly: true})
// PG natively supports read-only transactions
}
// RenderSQL puts ? placeholders inline as literals for the dialect, for audit log sql_rendered field / human reading;
// cannot be used as SQL execution. Simplest implementation can directly return sql (then audit keeps placeholder form).
func (Driver) RenderSQL(sql string, args []any) string {
// e.g., PG uses $1/$2 placeholders: this project tool layer uniformly uses ?, PG driver can implement ? → literal rendering
return sql
}
```
**Unsupported read-only transactions** (e.g., ClickHouse) `BeginReadOnly`:
```go
func (Driver) BeginReadOnly(ctx context.Context, db *sql.DB) (driver.ReadOnlyExec, error) {
return &fakeROExec{db: db}, nil // return a wrapper on *sql.DB, Commit/Rollback are no-ops
}
// ⚠ then read-only guarantee completely relies on database account permissions; must indicate in driver package README
```
**2. Activate driver**: in `internal/tools/db/register.go`, add a blank import:
```go
import (
_ "example.com/mcp-server/internal/tools/db/drivers/mysql"
_ "example.com/mcp-server/internal/tools/db/drivers/pgsql" // ← add
)
```
**3. User configuration**: write `driver: pgsql` in `config.yaml` to use.
Tool layer ( `list_tables.go` / `describe_table.go` / `query.go` / `execute.go` / `pool.go` / `guard.go` ) **no modifications needed**, they only depend on `driver.Driver` interface.
## Running
Service listens on stdio, usually launched by MCP client as a subprocess; running directly in terminal will enter waiting for input state.
Manual smoke test:
```bash
printf '%s\n' \
'{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2024-11-05","capabilities":{},"clientInfo":{"name":"t","version":"0"}}}' \
'{"jsonrpc":"2.0","method":"notifications/initialized"}' \
'{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"add","arguments":{"a":2,"b":3}}}' \
| ./bin/mcp-server.exe --config ./config.yaml
```
Expect to see `"text":"5"` tool call result + `serverInfo.name == "mcp-server"` / `"version":"0.2.0"` `initialize` response.
## Registering in MCP Client
Example: Claude Code's `.mcp.json`:
### Windows
```json
{
"mcpServers": {
"mcp-server": {
"command": "D:\\absolute\\path\\to\\go-mcp-server\\bin\\mcp-server.exe",
"args": ["--config", "D:\\absolute\\path\\to\\go-mcp-server\\config.yaml"]
}
}
}
```
### macOS / Linux
```json
{
"mcpServers": {
"mcp-server": {
"command": "/absolute/path/to/go-mcp-server/bin/mcp-server",
"args": ["--config", "/absolute/path/to/go-mcp-server/config.yaml"]
}
}
}
```
⚠ **Explicitly pass `--config` with absolute path**: MCP client's launched process cwd may not be the project root, `./config.yaml` default path may not resolve.
## Upgrading from 0.1.x to 0.2.0
0.2.0 is a structural refactoring + addition of database tools, with the following BREAKING changes:
| Item | 0.1.x | 0.2.0 |
| --- | --- | --- |
| Go module name | `example.com/mcp-add` | `example.com/mcp-server` |
| MCP service name (initialize response) | `mcp-add` | `mcp-server` |
| Version number | `0.1.0` | `0.2.0` |
| Executable file name | `mcp-add` / `mcp-add.exe` (root directory) | `mcp-server` / `mcp-server.exe` (`bin/`) |
| Startup dependency | None | must `--config <path>` point to YAML config file |
| `.mcp.json` `command` | points to root directory old binary | points to `bin/mcp-server(.exe)` |
| `.mcp.json` `args` | `[]` | **must** add `["--config", "<absolute path>/config.yaml"]` |
| New tools | — | `db_list_tables` / `db_describe_table` / `db_query` / `db_execute` |
Tool layer `add` behavior (name, parameters, return format, error handling) remains unchanged.
## How to Add a Regular Tool (Non-Database)
Example: adding `sub` (subtracting two numbers):
1. **New subpackage**: `internal/tools/sub/sub.go`, package name `sub`
2. **Implement `Register`**: define `func Register(r *tools.Registry)` in `sub.go`, construct tool with `mcp.NewTool("sub", ...)` and register with `r.Add(tool, handler)`
3. **Call in `main.go`**: add a line `sub.Register(reg)` and import `"example.com/mcp-server/internal/tools/sub"`
`add` package can be used as a template. Database tools involve dialect differences, follow a different process (see "Adding New Drivers" section).
## Tool Specifications (Supplement)
### `add`
| Field | Description |
| --- | --- |
| `name` | `add` |
| `description` | Returns the sum of two numbers |
| Parameter `a` | Required, `number` type |
| Parameter `b` | Required, `number` type |
| Return value | Text content, shortest decimal representation of `a + b` (e.g., `5`, `3.75`, `-6`) |
**Numeric precision**: uses IEEE-754 double precision floating-point (`float64`), suitable within ±2^53 (about ±9.007×10^15). Exceeding this range may lose precision.
MCP Config
Below is the configuration for this MCP Server. You can copy it directly to Cursor or other MCP clients.
mcp.json
Connection Info
You Might Also Like
No recommendations available