chatGPT와 인공지능AI

mysql MCP 서버 개발(PHP)

Cray Fall 2025. 7. 5. 19:37

Windsurf AI에서 사용하는 MCP, 개발을 도전해보았습니다.
처음에는 바이브 코딩으로 하면 쉽게 되려니 생각했는데요. 
웬걸 다 되었다고 하는데 정작 해보면 안되는 겁니다.
퇴근 후 여가 시간만 투자한다지만 그래도 며칠 붙잡고 씨름했네요 ㅎ..
하지만 결국 '크하하, 완성'!

비개발자분에게는 좀 지루한 내용일 수도 있으니 사전 양해 바랍니다.
스크롤 압박이 심하니 마지막 '하이라이트'로 바로 가셔도 됩니다 ㅎㅎ

기본적으로 mysql 테이블 목록과 테이블 구조 조회,
그리고 SQL문 실행이 가능한데요.
안전장치까지는 만들지 못해 삭제같은 것도 할 수 있으니 개인 용도로만 활용해 주시기 바랍니다.

관련 IT 지식이 많이 필요합니다. 기초부터 설명드리긴 힘들고..
MYSQL 에 대한 정보는 어느 정도 알고 계신다는 기준으로 설명드리겠습니다.

로컬 내 PC에서 작동하는 MCP이기 때문에 xampp 가 설치되어 있어야 합니다.
C:\xampp\htdocs\mysqlmcp 폴더를 생성하고

아래 순서로 소스를 저장해 주세요.

config.php

<?php
$db_name = "데이터베이스이름"; 
$servername = "DB서버"; // cafe24의 경우 보통 내아이디.mycafe24.com
$username = "아이디";
$password = "패스워드";

response.php

<?php
// 오류 응답 생성 함수
function createErrorResponse($id, $code, $message, $data = null) {
    $response = [
        "jsonrpc" => "2.0",
        "id" => $id,
        "error" => [
            "code" => $code,
            "message" => $message
        ]
    ];
    
    if ($data !== null) {
        $response["error"]["data"] = $data;
    }
    
    return $response;
}

// 성공 응답 생성 함수
function createSuccessToolListResponse($id, $result) {
    return [
        "jsonrpc" => "2.0",
        "id" => $id,
        "result" => $result
    ];
}

// 성공 응답 생성 함수
function createSuccessResponse($id, $result) {
    return [
        "jsonrpc" => "2.0",
        "id" => $id,
        "result" => [
            "isError" => false,
            "content" => $result 
        ]
    ];
}

mysql_mcp.php

<?php
// PHP MCP 에코 서버 - JSON-RPC 2.0 지원 버전
// made by cray
// https://itadventure.tistory.com/

include "config.php";
include "response.php";

$client_type='codeium'; // 윈드서프

// 초기화 메소드를 처리할 함수
function handleInitialize($params) {
    global $client_type;
    // 클라이언트 정보와 기능(capabilities) 저장 로직을 추가할 수 있음
    $protocolVersion = $params['protocolVersion'] ?? '2024-11-05';
    $clientInfo = $params['clientInfo'] ?? [];

    // 윈드서프
    if($clientInfo['name']=='codeium-client'){
        $client_type='codeium';
    }

    // 커서
    // {"name":"cursor-vscode","version":"1.0.0"}
    if($clientInfo['name']=='cursor-vscode'){
        $client_type='cursor';
    }

    // 클로드
    // claude-ai
    if($clientInfo['name']=='claude-ai'){
        $client_type='claude';
    }
    
    // 서버 정보와 기능 반환
    return [
        'protocolVersion' => $protocolVersion,        
        'serverInfo' => [
            'name' => 'mysql-mcp-server',
            'version' => '1.0.0'
        ],
        'capabilities' => [
            "tools" => [
                "listChanged" => true
            ],
            "prompts" => [
                "listChanged" => false
            ],
            "resources" => [
                "listChanged" => false  
            ],
            "logging" => [
                "level" => "info"
            ]
        ]
    ];
}

// notifications/initialized 메소드를 처리할 함수
function handleNotificationsInitialized($params) {
    // 클라이언트 초기화 완료 알림 처리
    // 이 메소드는 일반적으로 응답이 필요 없는 알림(notification)이지만
    // 필요한 경우 내부 상태를 업데이트할 수 있음
    return null; // 알림에는 응답이 없으므로 null 반환
}


// 데이터베이스 연결 함수
function connectDB() {
    global $servername, $username, $password;
    try {
        $conn = new PDO("mysql:host=$servername", $username, $password);
        $conn->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
        $conn->exec("SET NAMES utf8");
        return $conn;
    } catch(PDOException $e) {
        error_log("DB 연결 오류: " . $e->getMessage());
        return null;
    }
}

function formatQueryResults($results) {
    if (empty($results)) {
        return "검색 결과가 없습니다.";
    }
    
    $output = "검색 결과 (" . count($results) . "건):\n\n";
    
    foreach ($results as $index => $row) {
        $output .= "=== " . ($index + 1) . "번째 결과 ===\n";
        foreach ($row as $column => $value) {
            $output .= "$column: $value\n";
        }
        $output .= "\n";
    }
    
    return $output;
}

function executeQuery($args) {
    $query = $args['executeQuery'] ?? '';
    if (empty($query)) {
        return [
            "code" => -32602,
            "message" => "Query parameter is required",
            "data" => null
        ];
    }
    
    // 쿼리 실행
    // 데이터베이스 선택
    global $db_name;
    $conn = connectDB();
    $conn->exec("USE `$db_name`");
        
    // 쿼리 실행
    $stmt = $conn->query($query);

    $results = [];
    if(strpos(strtoupper($query), 'SELECT') === 0) {
        while ($row = $stmt->fetch(PDO::FETCH_NUM)) {
            $results[] = [
                "type" => "text",
                "text" => json_encode($row)
            ];
        }
    }
    else {
        $results[] = [
            "type" => "text",
            "text" => "Query executed successfully"
        ];
    }
    
    // 결과 포맷팅
    $formattedResults = formatQueryResults($results);
    
    return [
        [
            'type' => 'text',
            'text' => $formattedResults,
            'stmt' => $stmt
        ]
    ];        
}

// 테이블 목록 가져오기 함수
function listTables($params) {
    $dbName = $params["database"] ?? "";
    if (empty($dbName)) {
        return [
            "code" => -32602,
            "message" => "데이터베이스 이름이 필요합니다.",
            "data" => null
        ];
    }
    
    $conn = connectDB();
    if (!$conn) {
        return [
            "code" => -32603,
            "message" => "데이터베이스 연결에 실패했습니다.",
            "data" => null
        ];
    }
    
    try {
        // 데이터베이스 선택
        $conn->exec("USE `$dbName`");
        
        // 테이블 목록 조회
        $stmt = $conn->query("SHOW TABLES");
        $tables = [];
        
        while ($row = $stmt->fetch(PDO::FETCH_NUM)) {
            $tables[] = ["type" => "text", "text" => $row[0]];
        }
        
        return $tables;
    } catch(PDOException $e) {
        return [
            "code" => -32603,
            "message" => $e->getMessage(),
            "data" => null
        ];
    } finally {
        $conn = null;
    }
}


// 테이블 구조 조회 함수
function describeTable($params) {
    global $db_name;
    $tableName = $params["table"] ?? "";
    
    if (empty($tableName)) {
        return [
            "code" => -32602,
            "message" => "테이블 이름이 필요합니다.",
            "data" => null
        ];
    }
    
    $conn = connectDB();
    if (!$conn) {
        return [
            "code" => -32603,
            "message" => "데이터베이스 연결에 실패했습니다.",
            "data" => null
        ];
    }
    
    try {
        // 데이터베이스 선택
        $conn->exec("USE `$db_name`");
        
        // 테이블 구조 조회
        $stmt = $conn->query("DESCRIBE `$tableName`");
        $columns = [];
        
        while ($row = $stmt->fetch(PDO::FETCH_ASSOC)) {
            $columns[] = [
                "Field" => $row["Field"],
                "Type" => $row["Type"],
                "Null" => $row["Null"],
                "Key" => $row["Key"],
                "Default" => $row["Default"],
                "Extra" => $row["Extra"]
            ];
        }

        $text = "Table: $tableName\nColumns:\n";
        foreach ($columns as $column) {
            $text .= "- " . $column["Field"] . ": " . $column["Type"] . ", " . $column["Null"] . ", " . $column["Key"] . ", " . $column["Default"] . ", " . $column["Extra"] . "\n";
        }
        return [[
            "type" => "text",
            "text" => $text
        ]];
        // return $columns;
    } catch(PDOException $e) {
        return [
            "code" => -32603,
            "message" => $e->getMessage(),
            "data" => null
        ];
    } finally {
        $conn = null;
    }
}


// 도구 정의 함수
function getTools() {
    global $db_name;
    // JSON-RPC 2.0 형식의 도구 정의
    return [
        "tools" => [
            [
                "name" => "listTables",
                "description" => "데이터베이스의 테이블 목록을 조회합니다.",
                "inputSchema" => [
                    "type" => "object",
                    "properties" => [
                        "database" => [
                            "type" => "string",
                            "description" => "조회할 데이터베이스 이름. 기본값 ".$db_name
                        ]
                    ],
                    "required" => ["database"],
                    "additionalProperties" => false 
                ]
            ],
            [
                "name" => "describeTable",
                "description" => "테이블의 구조를 조회합니다.",
                "inputSchema" => [
                    "type" => "object",
                    "properties" => [
                        "database" => [
                            "type" => "string",
                            "description" => "조회할 데이터베이스 이름"
                        ],
                        "table" => [
                            "type" => "string",
                            "description" => "조회할 테이블 이름"
                        ]
                    ],
                    "required" => ["database", "table"],
                    "additionalProperties" => false 
                ]
            ],
            [
                "name" => "executeQuery",
                "description" => 
                    "자연어 질문을 SQL로 작성하여 전송해 주세요.\n\n".
                    "테이블 정보:\n".
                    "- coffees: 커피 (id, Name)\n".
                    "- books: 책 (id, bookName, pages, likes)\n\n".
                    "질문 예시:\n".
                    "- '해리포터 책' → books 테이블에서 bookName이 '해리포터'인 데이터 조회\n".
                    "- '4000원 넘는 커피' → coffees 테이블에서 price > 4000 조건으로 검색\n",
                "inputSchema" => [
                  "type" => "object",
                  "properties" => [
                    "executeQuery" => [
                      "type" => "string",
                      "description" => 
                        "질문을 SQL문으로 작성하여 전송해 주세요."
                        // ." select 만 허용."
                    ]
                  ],
                  "required" => ["executeQuery"],
                  "additionalProperties" => false 
                ]
            ]
        ]
    ];
}


// 메인 루프
while (!feof(STDIN)) {
    $input = fgets(STDIN);
    if (!$input) {
        usleep(100000);
        continue;
    }

    $input = trim($input);
    
    $req = json_decode($input, true);

    if (!$req) {
        // JSON 파싱 오류
        $errorResponse = createErrorResponse(null, -32700, "파싱 오류: 잘못된 JSON");
        echo json_encode($errorResponse) . "\n";
        flush();
        continue;
    }
    
    // JSON-RPC 2.0 필수 필드 확인
    $id = $req["id"] ?? null;
    $method = $req["method"] ?? "";
    $params = $req["params"] ?? [];
    $jsonrpc = $req["jsonrpc"] ?? "1.0";
    
    // 메서드 이름이 없는 경우
    if (empty($method)) {
        $errorResponse = createErrorResponse($id, -32600, "잘못된 요청: 메서드 이름이 없습니다.");
        echo json_encode($errorResponse) . "\n";
        flush();
        continue;
    }
    
    // 결과 변수 초기화
    $result = null;
    $error = null;
    
    // 이미 응답을 보냈는지 확인
    $alreay_response = false;
    $response_type = 2;
    // 메서드에 따라 다른 동작 수행    
    try {
        switch ($method) {
            case "initialize":
                $result = handleInitialize($params);
                $response_type = 2;
                if($client_type == 'cursor' || $client_type == 'claude')
                {
                    $response_type = 1;
                }
                break;
            case "getTools":
            case "list_tools":
            case "tools/list":
                $result = getTools();
                $response_type = 1;
                break;
            case "tools/call":
                switch($params["name"]){
                    case "listTables":
                        $result = listTables($params["arguments"]);    
                        break;
                    case "describeTable":
                        $result = describeTable($params["arguments"]);    
                        break;
                    case "executeQuery":
                        $result = executeQuery($params["arguments"]);
                        break;
                }
                $response_type = 2;
                break;
                
            case "listTable":
            case "listTables":
                $result = listTables($params);
                // 오류 확인
                if (isset($result["code"])) {
                    $error = $result;
                    $result = null;
                }
                $response_type = 2;
                break;
                
            case "mcp0_describeTable":
            case "describeTable":
            case "describe":
                $result = describeTable($params);
                // 오류 확인
                if (isset($result["code"])) {
                    $error = $result;
                    $result = null;
                }
                $response_type = 2;
                break;
                
            case "echo":
                $result = ["context" => "Received: " . ($params["prompt"] ?? "")];
                $response_type = 2;
                break;
                                
            default:
                $error = [
                    "code" => -32601,
                    "message" => "메서드를 찾을 수 없습니다: " . $method
                ];
                break;
        }
    } catch (Exception $e) {
        $error = [
            "code" => -32603,
            "message" => "내부 오류: " . $e->getMessage()
        ];
    }
    
    if($alreay_response == false)
    {
        // 응답 생성 및 출력
        if ($error !== null) {
            $response = createErrorResponse($id, $error["code"], $error["message"], $error["data"] ?? null);
        } else if ($response_type == 1) {
            $response = createSuccessToolListResponse($id, $result);
        } else if ($response_type == 2) {
            $response = createSuccessResponse($id, $result);
        }
        
        echo json_encode($response) . "\n";
    }
    flush();
}

테이블 ( 2개 ) 데이터는 대충 넣어 주세요.

CREATE TABLE `books` (
  `id` int(11) NOT NULL AUTO_INCREMENT,
  `bookName` varchar(30) DEFAULT NULL COMMENT '책이름',
  `pages` int(11) DEFAULT 0 COMMENT '페이지수',
  `like` int(11) DEFAULT 0 COMMENT '좋아요 받은 횟수',
  `price` int(11) DEFAULT NULL,
  PRIMARY KEY (`id`)
) ENGINE=InnoDB AUTO_INCREMENT=12 DEFAULT CHARSET=utf8mb3 COLLATE=utf8mb3_general_ci;

CREATE TABLE `coffees` (
  `id` int(11) NOT NULL AUTO_INCREMENT,
  `Name` varchar(30) DEFAULT '' COMMENT '커피 이름',
  `Price` int(11) DEFAULT 0 COMMENT '가격',
  PRIMARY KEY (`id`)
) ENGINE=InnoDB AUTO_INCREMENT=14 DEFAULT CHARSET=utf8mb3 COLLATE=utf8mb3_general_ci;

다음으로 윈드서프에서 설정은 아래와 같이 잡아주시면 됩니다.

채팅창 아래 MCP 도구 아이콘, Configure 클릭

view raw config 클릭

설정 JSON 파일이 보일텐데요.
아래 내용을 추가해주시면 됩니다.

{
  "mcpServers": {
       :
    "mysql_mcp": {
      "command": "php",
      "args": [
        "C:\\xampp\\htdocs\\mysqlmcp\\mysql_mcp.php"
      ],
      "env": {}
    }
  }
}

이제 mcp 서버를 적용하려면, Windsurf 를 재시작하거나,
또는 채팅창 아래 MCP 아이콘 근방의 Refresh를 클릭하셔도 됩니다.
mysql_mcp 3 tools 가 뜨면 제대로 구성된 것입니다.

지금 구성한 mysql 서버는 3개의 기능을 제공하는데요.
첫번째는 테이블 목록 조회입니다.

Windsurf 채팅창에 아래와 같이 입력하면

테이블 목록 보여줘
테이블 목록 나열해줘

데이터베이스 내의 테이블 목록을 안내합니다.

도서 테이블 구조가 궁금하다면 아래와 같이 프롬프트를 날리면 됩니다.

도서 테이블 구조 알려줘
도서 테이블 구조 보여줘
도서 테이블 스키마 보여줘

이렇게 친절하게 알려주지요.

하이라이트!

다음은 강력한 SQL 실행 기능입니다.
처음에는 mysql 이라는 단어를 한번은 넣어 주어야 합니다.

mysql에서 아메리카노 커피가 얼마지?

그러면 Windsurf 가 이런 쿼리를 만들어서 서버에 전송하고

select price from coffees where Name = '아메리카노';

결과를 알려 줍니다.

아메리카노 커피의 가격은 1,500원입니다.

2번째는 mysql을 명시 안해도 됩니다.

3000 이하 커피 보여줘

그러면 역시 이런 쿼리를 만들고,

select Name, price from coffees where price <= 3000;

결과를 알려줍니다.

3000원 이하의 커피는 다음과 같습니다:

아메리카노: 1500원
카페라떼: 3000원
카푸치노: 3000원
아포카토 커피 150: 0원
아포카토 커피 150: 0원
아포카토 150: 0원
아포카토 를 100: 0원

이번에는 커피 대신 책을 찾아보겠습니다.

마틸다 책 찾아줘

쿼리문에서 테이블명이 바뀌었네요

select * from books where bookName like '%마틸다%';
'마틸다' 책 정보를 찾았습니다.

책 이름: 마틸다
페이지 수: 300
좋아요 수: 11

사실 이게 가능한 건, "executeQuery" 라는 도구 설명에 아래 내용이 들어 있기 때문입니다.
AI가 이 내용을 읽고 커피와 책 테이블에 대한 구조를 잘 알고 있는 거지요.

"description" => 
    "자연어 질문을 SQL로 작성하여 전송해 주세요.\n\n".
    "테이블 정보:\n".
    "- coffees: 커피 (id, Name)\n".
    "- books: 책 (id, bookName, pages, likes)\n\n".
    "질문 예시:\n".
    "- '해리포터 책' → books 테이블에서 bookName이 '해리포터'인 데이터 조회\n".
    "- '4000원 넘는 커피' → coffees 테이블에서 price > 4000 조건으로 검색\n".
    "- '전설라떼 커피 4000원 추가해줘' → insert into coffees (Name, price) values ('전설라떼', 4000);\n",

몇가지 더 시도해보았는데 잘 되더라구요~

아마존의 비밀 책 주가해줘. 333 페이지야.

철수의 기쁨 책은 삭제해줘

책에 가격이 없네. 가격을 추가해줘

책 가격은 일단 모두 10000원으로 바꿔줘

책은 전부 보여줘

주의하실 건 안전장치가 없어, 아래와 같은 명령도 작동됩니다.
꼭 개인용으로만 활용하셔야 해요!

책 테이블을 삭제해줘.

활용도가 무궁무진할 것 같습니다.
이를테면 '이번달 매출 전표를 출력해줘'라는 것도 가능하지 않을까 싶네요.

※ 커서에서도 작동하도록 금방 기능을 추가했습니다.

File - Preference - Cursor Settings 메뉴 진입 후,

Tools & Integrations 탭 선택, New MCP Server 클릭 후

JSON 설정을 잡아주시면 됩니다.

{
  "mcpServers": {
          :
    "mysql_mcp": {
      "command": "php",
      "args": [
        "C:\\xampp\\htdocs\\mysqlmcp\\mysql_mcp.php" 
      ],
      "env": {}
    }
  }
}

※ 클로드(Claude Desktop)도 기능을 추가했습니다.

클로드 데스크톱은 설정 파일을 직접 열어 수정해주어야 합니다.
윈도우 탐색기를 열고 주소창에 '
%appdata%' 입력, Enter 치시면,

앱 데이터 폴더로 이동하는데요. Claude 폴더에 들어가신 다음,

claude_desktop_config.json 파일을 열어주시면 됩니다.

그리고 기존 설정에 아래 내용을 추가해 주시면 됩니다.

{
    "mcpServers": {
            :
        "mysql_mcp": {
          "command": "cmd",
          "args": [
          "/c",
          "C:\\xampp\\php\\php.exe",
          "C:\\xampp\\htdocs\\mysqlmcp\\mysql_mcp.php" 
          ],
          "env": {}
      }
    }
}

아무쪼록 개발 관련 지식을 쌓으시는 분께 도움되시기를 바랍니다.
오늘도 방문 감사합니다!