ABOUT ME

-

Today
-
Yesterday
-
Total
-
  • JSON-RPC 2.0이 정의하는 건 봉투 6단어뿐: MCP 사례로 그 안과 밖을 가른다
    IT 2026. 6. 12. 21:00
    JSON-RPC 2.0이 정의하는 건 봉투 6단어뿐: MCP 사례로 그 안과 밖을 가른다

    MCP(Model Context Protocol, AI 모델과 외부 도구·데이터를 잇는 공개 표준 — 흔히 "AI용 USB-C 포트"라 불린다) 서버를 직접 개발하려고 명세를 펼쳐보면, 와이어 포맷이 두 종류의 약속이 포개진 모양을 하고 있다. 가장 바깥은 JSON-RPC 2.0이라는 호출 규약, 그 안은 MCP가 메서드별로 정한 구조다. inputSchema·content·tools/call 같은 이름이 어느 쪽 약속에 속하는지부터 헷갈리는 게 보통이다. 결론부터 적으면 JSON-RPC는 봉투 6단어만 정의하고, 그 안의 내용물은 전부 MCP가 채운다. MCP를 다루기 전에 그 봉투부터 먼저 짚고 넘어가자 — 이 글은 JSON-RPC 2.0의 봉투 6단어를 MCP 도구 호출 사례로 풀어본다.

    배경: JSON-RPC 2.0이 정의하는 것의 전부

    JSON-RPC 2.0은 원격 함수 호출(Remote Procedure Call, 다른 프로세스의 함수를 마치 로컬 함수처럼 호출하는 패턴) 규약 중 가장 가벼운 축에 든다. HTTP REST가 URL·메서드(GET/POST)·헤더로 의미를 표현하는 반면, JSON-RPC는 그런 거 다 빼고 모든 호출을 단일 JSON 객체로 통일한다. 전송 계층(transport)도 정해두지 않는다 — HTTP·stdio(표준 입출력)·WebSocket 무엇이든 좋다. MCP는 주로 stdio를 쓴다.

    그래서 JSON-RPC가 정의하는 건 메시지 두 가지 모양뿐이다 — 요청(Request)응답(Response). 각각 필드 네다섯 개로 끝난다.

    요청(Request) — 4필드

    {
      "jsonrpc": "2.0",       // 규약 버전 — 항상 "2.0" 문자열
      "id": 7,                // 이 호출의 고유 번호 (응답에서 그대로 돌아온다)
      "method": "tools/call", // 호출할 메서드 이름
      "params": { ... }       // 메서드 인자 (객체 또는 배열, 선택)
    }

    코드 설명: 네 필드 중 jsonrpc·method는 필수, id는 응답을 짝지을 때 필수(생략하면 "알림"이 된다 — 뒤에서 다룬다), params는 메서드가 인자를 받을 때만 둔다. method 값이 tools/call인지 resources/read인지 JSON-RPC는 모른다 — 그건 윗 계층(MCP)이 정한 이름이고, JSON-RPC 입장에서는 그냥 문자열이다.

    응답(Response) — 성공이면 result, 실패면 error

    // 성공
    {
      "jsonrpc": "2.0",
      "id": 7,            // 요청과 같은 번호
      "result": { ... }   // 성공 시의 반환값 (임의 JSON)
    }
    
    // 실패
    {
      "jsonrpc": "2.0",
      "id": 7,
      "error": {
        "code": -32602,    // 표준 에러 코드
        "message": "Invalid params",
        "data": { ... }    // 부가 정보 (선택)
      }
    }

    코드 설명: 성공이면 result가, 실패면 error가 들어온다 — 둘은 동시에 존재할 수 없다. id는 요청 때 보낸 값을 그대로 돌려주므로 클라이언트는 응답이 어느 호출에 대응되는지 안다(stdio처럼 응답 순서가 보장되지 않는 전송에서 특히 중요). error.code의 -32700~-32603은 JSON-RPC가 예약한 표준값이고, -32099~-32000은 구현이 자유롭게 정의하는 범위다.

    왜 중요한가: JSON-RPC 규격으로 외울 건 이게 전부다. jsonrpc·id·method·params·result·error — 봉투 6단어. 그 안의 내용물이 어떤 모양이어야 하는지는 JSON-RPC가 일절 말하지 않는다.

    사례 1: tools/list — "어떤 도구가 있나요?"

    LLM(코딩 어시스턴트)이 MCP 서버에 "지금 쓸 수 있는 도구가 뭐가 있나요?"를 묻는, 가장 흔한 호출부터 보자. 메서드 이름은 tools/list다.

    diagram

    다이어그램 설명: 클라이언트는 id:1을 붙여 보내고, 서버는 같은 id:1을 응답에 박아 돌려준다. JSON-RPC가 보장하는 건 이 봉투의 짝맞춤(id)까지다 — result 안의 tools 배열 모양은 JSON-RPC 명세에 없는 MCP의 별도 약속이다.

    // 실제 와이어 (요청)
    { "jsonrpc": "2.0", "id": 1, "method": "tools/list" }
    
    // 실제 와이어 (응답)
    {
      "jsonrpc": "2.0",
      "id": 1,
      "result": {
        "tools": [
          {
            "name": "build-app",
            "description": "프로젝트를 빌드한다",
            "inputSchema": {
              "type": "object",
              "properties": {
                "project_dir":   { "type": "string" },
                "configuration": { "type": "string", "enum": ["Debug", "Release"] },
                "arch":          { "type": "string", "enum": ["arm64", "x86_64"] }
              },
              "required": ["project_dir", "arch"]
            }
          },
          {
            "name": "lint-check",
            "description": "코드 린트 검사를 실행한다",
            "inputSchema": { "type": "object", "properties": {} }
          }
        ]
      }
    }

    코드 설명: 봉투(jsonrpc·id·result)는 JSON-RPC가 정한 모양이다. 그러나 result.tools[].name·description·inputSchema는 JSON-RPC와 무관하다 — MCP 명세가 "tools/list의 응답은 이런 모양이어야 한다"고 별도로 못 박은 약속이다. 흥미로운 점은 인자 명세(inputSchema, JSON Schema 형식)까지 이 한 번의 응답에 통째로 실려 온다는 것이다 — LLM 클라이언트는 tools/list 한 라운드트립으로 "어떤 도구가 있나"와 "어떤 인자로 불러야 하나"를 동시에 안다. 별도 describe 호출 단계가 없다.

    사례 2: tools/call — "이 도구를 이 인자로 실행해줘"

    실제 도구를 실행하는 호출이다. methodtools/call로 고정되고, 어떤 도구를 어떤 인자로 부르는지는 params 안에 한 번 더 객체로 들어간다.

    // 요청
    {
      "jsonrpc": "2.0",
      "id": 7,
      "method": "tools/call",
      "params": {
        "name": "build-app",
        "arguments": {
          "project_dir": "./app",
          "configuration": "Release",
          "arch": "arm64"
        }
      }
    }
    
    // 응답
    {
      "jsonrpc": "2.0",
      "id": 7,
      "result": {
        "structuredContent": {
          "exit_code": 0,
          "output": "app-release-arm64.pkg"
        },
        "isError": false
      }
    }

    코드 설명: 요청에서 method는 항상 tools/call이고, 실제 호출할 도구 이름은 params.name에 들어간다. 즉 JSON-RPC 봉투의 method는 "지금 너에게 시킬 동작 카테고리"이고, 실제 도구 이름은 MCP가 한 단계 더 안쪽에서 다룬다 — JSON-RPC 메서드 이름이 도구마다 늘어나지 않도록 한 설계다. 응답 result 안도 MCP 약속이다 — structuredContent라는 객체 필드에 우리 도구의 JSON이 구조 그대로 실린다. 문자열로 직렬화하지 않으니 클라이언트는 곧바로 result.structuredContent.exit_code처럼 점 접근으로 값을 꺼낸다.

    왜 중요한가: 봉투 안의 봉투 구조다. 가장 바깥은 JSON-RPC가 정한 result, 그 안은 MCP가 정한 structuredContent, 다시 그 안은 우리가 정한 { exit_code, output }. 세 층이 각자 한 가지 결정만 책임진다 — 그리고 그 세 층이 다 같은 JSON 트리 안에 자연스럽게 중첩된다.

    핵심 질문: 어디까지가 JSON-RPC이고 어디부터가 MCP인가

    diagram
    그림: 세 계층은 각자 다른 표준이 책임진다

    다이어그램 설명: 가장 바깥 회색이 JSON-RPC 2.0이 정의하는 6단어다. 그 안 파란 영역이 MCP가 추가한 약속 — 메서드 이름의 값(tools/list·tools/call 등), params 안에 다시 들어가는 name·arguments 구조, result 안에 들어가는 structuredContent·tools·inputSchema 구조. 가장 안쪽 초록은 MCP 서버를 만드는 사람이 자유롭게 정하는 도구별 출력(exit_code·output·job_id 등 — 위 예시는 한 가지 설계안)이고, structuredContent 객체에 그대로 실려 들어간다. 세 층은 각자 한 가지 결정만 책임진다 — 봉투(JSON-RPC) / 도메인 계약(MCP) / 도구별 페이로드(서버 구현자).

    왜 중요한가: 같은 JSON-RPC 봉투 위에 MCP·Ethereum JSON-RPC API·Language Server Protocol(LSP)이 전부 얹힐 수 있다 — 봉투는 공유하고 메서드 이름·파라미터·결과 구조만 도메인별로 갈린다. 봉투를 일부러 좁게 둔 덕에 윗 계층이 자유롭게 진화한다.

    사례 3: 에러 응답 — params가 잘못됐을 때

    LLM이 arch에 스키마가 허용하지 않는 값("ARM")을 넣어 보냈다고 하자. 서버는 도구를 실행하기 전에 inputSchema로 검증하고 에러를 돌려준다.

    // 요청 (arch 값이 enum에 없음)
    {
      "jsonrpc": "2.0",
      "id": 8,
      "method": "tools/call",
      "params": {
        "name": "build-app",
        "arguments": { "project_dir": "./app", "arch": "ARM" }
      }
    }
    
    // 응답
    {
      "jsonrpc": "2.0",
      "id": 8,
      "error": {
        "code": -32602,
        "message": "Invalid params",
        "data": {
          "field": "arch",
          "expected": ["arm64", "x86_64"],
          "received": "ARM"
        }
      }
    }

    코드 설명: 응답에 result 대신 error가 온다. code: -32602는 JSON-RPC가 예약한 "Invalid params" 표준 코드 — 어떤 구현이든 이 숫자는 같은 의미다. message도 표준 문구가 권장된다. 진짜 디버깅 정보는 data 필드에 들어가는데, 이 안쪽 구조는 다시 구현 자유다(MCP가 약속을 더할 수도, 안 정해두면 서버 마음대로).

    JSON-RPC가 예약한 표준 에러 코드는 다섯 개로 짧다.

    -32700  Parse error      — JSON 자체가 깨졌다
    -32600  Invalid Request  — 봉투 모양이 틀렸다 (jsonrpc 필드 누락 등)
    -32601  Method not found — method 이름이 등록 안 됨
    -32602  Invalid params   — params가 메서드가 요구하는 모양 아님
    -32603  Internal error   — 서버 내부 버그
    -32099 ~ -32000          — 구현이 자유롭게 쓰는 Server error 범위

    왜 중요한가: 에러 코드가 표준이라는 건 LLM 입장에서 분기 비용이 0이라는 뜻이다. -32602가 오면 "내가 인자를 잘못 보냈군"이라는 결론이 즉시 나온다 — 자연어 메시지를 정규식으로 파싱할 필요가 없다. JSON-RPC가 자연어 대신 숫자 코드를 못 박아둔 동기가 여기 있다.

    사례 4: 알림(Notification) — 응답을 기대하지 않는 호출

    요청에서 id를 빼면 그 메시지는 "알림(notification)"이 된다. 서버는 응답을 돌려주지 않는다. MCP에서는 초기화 완료 같은 일방향 신호에 주로 쓴다.

    diagram

    다이어그램 설명: 위 두 줄은 일반 호출 — id로 요청·응답이 짝지어진다. 아래 한 줄은 알림 — id가 없다는 점 하나로 메시지 의미가 바뀌고, 서버는 화살표를 되돌려 보내지 않는다. JSON-RPC는 id 유무 하나로 두 패턴을 구분한다.

    // 클라이언트가 서버에게 "나 준비 끝" 알리는 알림
    {
      "jsonrpc": "2.0",
      "method": "notifications/initialized"
    }
    // id가 없다 → 서버는 응답 안 함

    코드 설명: 서버가 알림 처리에 실패해도 클라이언트는 알 길이 없다 — 알림은 "받았으면 좋고 안 받았어도 안 망가지는" 종류의 신호에 한정해 써야 한다. MCP는 초기화 완료·progress 갱신·cancel 요청 같은 곳에서 이 패턴을 쓴다. notifications/라는 prefix 자체도 MCP 컨벤션이지 JSON-RPC가 강제한 건 아니다 — JSON-RPC는 그냥 "method 문자열"로만 본다.

    핵심 통찰: 봉투를 일부러 좁게 둔 이유

    JSON-RPC가 봉투 6단어로 끝나는 건 모자란 게 아니라 의도다. 봉투는 전송 무관·도메인 무관한 범용 짝맞춤만 책임지고(id로 요청·응답 페어링, method로 디스패치, error로 실패 신호), 내용물은 도메인이 알아서 정한다.

    그래서 같은 JSON-RPC 위에 세 가지가 평화롭게 공존한다 — MCP(AI 도구 호출), Ethereum JSON-RPC API(블록체인 RPC), LSP(IDE-언어서버 통신). 봉투는 공유하고 메서드 이름·파라미터·결과 구조만 도메인별로 갈린다. 메서드 이름이 tools/call이든 eth_getBalancetextDocument/completion이든, JSON-RPC 입장에서는 그냥 디스패치 키일 뿐이다.

    MCP 서버 개발자 관점에서 책임이 어떻게 나뉘는지 정리하면 이렇다.

    JSON-RPC 2.0이 정한 것 (이 글의 범위):
      jsonrpc · id · method · params · result · error
      표준 에러 코드 -32700 ~ -32603
      id 유무로 알림 구분
    
    MCP가 정한 것 (다음 글의 범위):
      method 값들: tools/list, tools/call, resources/read, notifications/initialized, ...
      params 구조: { name, arguments }
      result 구조 (tools/list):  { tools: [{ name, description, inputSchema }] }
      result 구조 (tools/call):  { structuredContent, isError }
      inputSchema가 JSON Schema라는 점 (tools/list 응답에 직접 실림)
    
    MCP 서버 개발자가 정하는 것:
      도구별 페이로드 (예: { exit_code, output } / { job_id, status, ... } / { logs, next_offset, eof })
      structuredContent에 객체 그대로 실려 들어감

    코드 설명: inputSchema·structuredContent는 어디에 있는지 보자 — 둘 다 MCP가 정한 자리에 들어 있다. inputSchematools/listresult.tools[] 안, structuredContenttools/callresult 안. JSON-RPC가 정의한 건 그 바깥의 result라는 슬롯이 있다는 사실까지다 — 그 안에 무엇이 들어가야 하는지는 도메인 명세(MCP)가 메서드별로 별도로 못 박는다.

    왜 중요한가: JSON-RPC를 안다고 MCP를 다 아는 건 아니다. 반대도 마찬가지 — MCP만 외운 사람은 봉투 6단어가 어디서 왔는지 모른다. 둘은 따로 학습해야 하는 별개의 약속이고, 그게 분리되어 있다는 사실 자체가 두 표준이 각자 진화할 수 있는 이유다. MCP 명세가 다음 버전에서 structuredContent 형식을 더 다듬어도 JSON-RPC 봉투는 한 글자도 안 바뀐다.


    이 글은 생성형 AI의 도움을 받아 작성되었습니다. 원본 자료를 기반으로 AI가 초안을 생성하고, 작성자가 검토·편집하였습니다.

Designed by Tistory.