대상: Ubuntu 24.04 LTS / .NET 10 / ASP.NET Core / MySQL 8.x / 공식 C# MCP SDK. MySQL에 대한 조회 전용 MCP 서버 구축 예제입니다. 기존 MySQL 데이터베이스를 유지하면서 ChatGPT에 허용된 테이블과 컬럼만 노출하도록 설계합니다.
1. 아키텍처
ChatGPT → OpenAI Secure MCP Tunnel(HTTPS 443 아웃바운드) → ASP.NET Core MCP(127.0.0.1:5080/mcp) → MySQL(127.0.0.1:3306). MySQL과 MCP HTTP 포트는 외부 공개하지 않습니다.
2. Ubuntu 설치 및 C# 프로젝트 생성
sudo apt update
sudo apt install -y dotnet-sdk-10.0 default-mysql-client curl ca-certificates
dotnet --info
mkdir -p ~/projects && cd ~/projects
dotnet new web -n MySqlMcpServer -f net10.0
cd MySqlMcpServer
dotnet add package ModelContextProtocol.AspNetCore
dotnet add package MySqlConnector
dotnet restore
mkdir -p ~/projects && cd ~/projects
dotnet new web -n MySqlMcpServer -f net10.0
cd MySqlMcpServer
dotnet add package ModelContextProtocol.AspNetCore
dotnet add package MySqlConnector
dotnet restore
명령은 Ubuntu 24.04 LTS 기준입니다. 지원 리포지토리가 없는 환경에서는 Microsoft의 배포별 .NET 설치 가이드를 확인합니다. NuGet 버전은 구축 시점에 검증된 버전으로 고정하세요.
3. MySQL 테스트 테이블 및 읽기 전용 사용자
CREATE DATABASE IF NOT EXISTS sampledb CHARACTER SET utf8mb4;
USE sampledb;
CREATE TABLE IF NOT EXISTS mcp_demo_items (
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
name VARCHAR(100) NOT NULL,
price DECIMAL(12,2) NOT NULL,
stock INT NOT NULL DEFAULT 0
) ENGINE=InnoDB;
INSERT INTO mcp_demo_items(name,price,stock) VALUES
('키보드',35000,10),('마우스',15000,20);
CREATE USER 'chatgpt_ro'@'127.0.0.1'
IDENTIFIED BY 'CHANGE_TO_STRONG_PASSWORD';
GRANT SELECT ON sampledb.mcp_demo_items
TO 'chatgpt_ro'@'127.0.0.1';
SHOW GRANTS FOR 'chatgpt_ro'@'127.0.0.1';
이 계정은 SELECT만 허용합니다. CREATE·ALTER·DROP·UPDATE·INSERT·DELETE 권한을 부여하지 않습니다. 실제 운영 시에는 개인정보가 포함된 테이블을 공개하지 말고 가능하면 컬럼 단위 SELECT 권한으로 더 제한하세요.
4. Program.cs 전체 예제
기본 Program.cs를 아래 소스로 교체합니다. 한 개 샘플 테이블(mcp_demo_items)만 허용하고, 도구 3개를 노출합니다. 다른 테이블을 추가할 때는 허용 목록과 컬럼 정책을 코드 또는 별도 정책 파일로 명시적으로 확장해야 합니다.
using System.ComponentModel;
using System.Text.Json;
using ModelContextProtocol.AspNetCore;
using ModelContextProtocol.Server;
using MySqlConnector;
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddSingleton<ReadService>();
builder.Services.AddMcpServer()
.WithHttpTransport(options =>
{
options.SessionMode = HttpServerSessionMode.Stateless;
})
.WithTools<MySqlTools>();
var app = builder.Build();
app.MapGet("/health", () => Results.Ok(new { status = "ok" }));
app.MapMcp("/mcp");
app.Run("http://127.0.0.1:5080");
public sealed class ReadService(IConfiguration config)
{
private readonly string _connection =
config.GetConnectionString("MySql")
?? throw new InvalidOperationException("Connection missing");
// This tutorial exposes exactly one demo table and four columns.
private const string Table = "mcp_demo_items";
private const string Columns = "id,name,price,stock";
public object ListTables() => new
{
tables = new[] { Table },
read_only = true
};
public async Task<string> DescribeAsync(string table,
CancellationToken ct)
{
Check(table);
const string sql = """
SELECT COLUMN_NAME, DATA_TYPE, IS_NULLABLE
FROM INFORMATION_SCHEMA.COLUMNS
WHERE TABLE_SCHEMA = DATABASE()
AND TABLE_NAME = @table
AND COLUMN_NAME IN ('id','name','price','stock')
ORDER BY ORDINAL_POSITION
""";
await using var db = new MySqlConnection(_connection);
await db.OpenAsync(ct);
await using var cmd = new MySqlCommand(sql, db)
{
CommandTimeout = 5
};
cmd.Parameters.AddWithValue("@table", Table);
await using var reader = await cmd.ExecuteReaderAsync(ct);
var rows = new List<object>();
while (await reader.ReadAsync(ct))
rows.Add(new {
column = reader.GetString(0),
type = reader.GetString(1),
nullable = reader.GetString(2)
});
return JsonSerializer.Serialize(new { table, columns = rows });
}
public async Task<string> SampleAsync(string table, int limit,
CancellationToken ct)
{
Check(table);
limit = Math.Clamp(limit, 1, 10);
string sql = $"SELECT {Columns} FROM {Table} ORDER BY id DESC LIMIT @limit";
await using var db = new MySqlConnection(_connection);
await db.OpenAsync(ct);
await using var cmd = new MySqlCommand(sql, db)
{
CommandTimeout = 5
};
cmd.Parameters.AddWithValue("@limit", limit);
await using var reader = await cmd.ExecuteReaderAsync(ct);
var rows = new List<Dictionary<string, string?>>();
while (await reader.ReadAsync(ct))
{
var row = new Dictionary<string, string?>();
for (int i = 0; i < reader.FieldCount; i++)
{
string? value = reader.IsDBNull(i)
? null : Convert.ToString(reader.GetValue(i),
System.Globalization.CultureInfo.InvariantCulture);
row[reader.GetName(i)] = value is { Length: > 200 }
? value[..200] + "..." : value;
}
rows.Add(row);
}
return JsonSerializer.Serialize(new { table, rows });
}
private static void Check(string table)
{
if (!string.Equals(table, Table, StringComparison.Ordinal))
throw new ArgumentException("Table is not allowed");
}
}
[McpServerToolType]
public static class MySqlTools
{
[McpServerTool(Name = "list_tables", ReadOnly = true, OpenWorld = false)]
[Description("List approved MySQL tables; no writes.")]
public static object ListTables(ReadService service)
=> service.ListTables();
[McpServerTool(Name = "describe_table", ReadOnly = true, OpenWorld = false)]
[Description("Describe allowed columns in the approved table.")]
public static Task<string> DescribeTable(ReadService service,
string table, CancellationToken cancellationToken)
=> service.DescribeAsync(table, cancellationToken);
[McpServerTool(Name = "sample_rows", ReadOnly = true, OpenWorld = false)]
[Description("Read up to 10 rows of approved columns.")]
public static Task<string> SampleRows(ReadService service,
string table, int limit, CancellationToken cancellationToken)
=> service.SampleAsync(table, limit, cancellationToken);
}
5. 접속 환경 파일
코드와 접속 비밀번호를 분리합니다. 실제 비밀번호로 변경합니다. DB와 MCP 서버가 같은 Ubuntu 호스트에서 루프백으로 통신하는 예제입니다.
sudo mkdir -p /etc/mysql-mcp
sudo nano /etc/mysql-mcp/read.env
# 내용:
# ConnectionStrings__MySql="Server=127.0.0.1;Port=3306;Database=sampledb;User ID=chatgpt_ro;Password=CHANGE_TO_STRONG_PASSWORD;Connection Timeout=5;SslMode=None"
sudo chown root:root /etc/mysql-mcp/read.env
sudo chmod 600 /etc/mysql-mcp/read.env
6. 빌드·배포·systemd
dotnet build -c Release
dotnet publish -c Release -o ./publish
sudo useradd --system --home-dir /nonexistent --shell /usr/sbin/nologin mcpreader
sudo install -d -m 755 /opt/mysql-mcp-dotnet
sudo cp -a ./publish/. /opt/mysql-mcp-dotnet/
sudo chown -R root:root /opt/mysql-mcp-dotnet
sudo nano /etc/systemd/system/mysql-mcp-dotnet.service
[Unit]
Description=ASP.NET Core MySQL Read Only MCP
After=network.target
[Service]
Type=simple
User=mcpreader
Group=mcpreader
WorkingDirectory=/opt/mysql-mcp-dotnet
EnvironmentFile=/etc/mysql-mcp/read.env
ExecStart=/usr/bin/dotnet /opt/mysql-mcp-dotnet/MySqlMcpServer.dll
Restart=on-failure
RestartSec=5
NoNewPrivileges=true
ProtectSystem=strict
ProtectHome=true
PrivateTmp=true
[Install]
WantedBy=multi-user.target
sudo systemctl daemon-reload
sudo systemctl enable --now mysql-mcp-dotnet
sudo systemctl status mysql-mcp-dotnet
sudo journalctl -u mysql-mcp-dotnet -n 50 --no-pager
curl -sS http://127.0.0.1:5080/health
7. MCP 도구 조회 및 데이터 테스트
curl -sS http://127.0.0.1:5080/mcp \
-H 'Content-Type: application/json' \
-H 'Accept: application/json, text/event-stream' \
-H 'MCP-Protocol-Version: 2026-07-28' \
-d '{"jsonrpc":"2.0","id":1,"method":"tools/list","params":{}}'
curl -sS http://127.0.0.1:5080/mcp \
-H 'Content-Type: application/json' \
-H 'Accept: application/json, text/event-stream' \
-H 'MCP-Protocol-Version: 2026-07-28' \
-d '{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"sample_rows","arguments":{"table":"mcp_demo_items","limit":5}}}'
정상이라면 list_tables, describe_table, sample_rows가 표시됩니다. /health는 서버 생존만 검사하므로 반드시 실제 도구 호출까지 확인해야 합니다.
8. Secure MCP Tunnel 연결
OpenAI Platform Tunnels 설정에서 tunnel_id 및 전용 Runtime API Key를 발급하고, 공식 릴리스의 tunnel-client를 설치합니다. TCP 5080 또는 MySQL 3306을 인터넷에 열지 않습니다.
export CONTROL_PLANE_API_KEY="YOUR_RUNTIME_API_KEY"
tunnel-client help quickstart
tunnel-client init --profile local-dotnet --tunnel-id YOUR_TUNNEL_ID \
--mcp-server-url http://127.0.0.1:5080/mcp
tunnel-client doctor --profile local-dotnet --explain
tunnel-client run --profile local-dotnet
검증 후 tunnel-client도 systemd로 실행할 수 있습니다. 실행 시에는 프로필 파일 및 API 키의 파일 권한을 제한하고, 터널이 로컬 MCP 서비스보다 먼저 실행되지 않도록 순서를 설정하세요.
9. ChatGPT 연결과 테스트
지원되는 ChatGPT 워크스페이스에서 Plugins → + → Add custom MCP server → Tunnel을 선택하고 tunnel_id를 지정합니다. 연결 후 “MySQL에서 조회 가능한 테이블 목록과 5개 샘플 행을 보여줘”처럼 요청합니다. 플랜·워크스페이스 권한에 따라 사용자 정의 MCP 연결이 제한될 수 있습니다.
10. 운영 보안과 확장
MySQL SELECT 전용 계정, 컬럼 허용 목록, 최대 10행, 짧은 CommandTimeout, 방화벽, 인증/접근 권한, 로그 및 정기 백업을 적용하세요. 쿼리의 ReadOnly 메타데이터 자체가 보안을 보장하지는 않습니다. 이 예제는 학습·테스트용이며 실제 서버에서 컴파일·실행 검증한 결과는 아닙니다.
함께 읽기
Python MCP 서버로 MySQL 조회·수정·SSH 승인 구현하기