fast-mcp이 Smithery에 거절당했다. Claude가 MCP 서버 전체를 30분 만에 공식 SDK로 옮겼다 — 재작성이 싸게 먹힌 건 처음에 얇게 썼기 때문.
smarts 프로젝트는 6일 전에 막 MCP를 붙였다. Claude Code한테 시켜서 fast-mcp 이라는 gem으로 tool 3개를 띄웠다:
get_contract_info(chain, address) — 컨트랙트 이름, 프로토콜 어댑터 태그, function / event 카운트read_contract_state(chain, address, function_name, args?) — 단일 view / pure 함수 호출, 60초 캐시get_uniswap_v3_pool(chain, address) — V3 풀 전체 패널: 양방향 가격, liquidity, tick, USD TVL그 commit이 27ca82e feat(mcp): serve live contract data over MCP via fast-mcp, 2026-04-21 새벽 1:37. 30분 정도. 매끄럽게 끝났다.
6일이 지난 오늘 저녁 2026-04-27 20:24, 또 다른 commit을 push했다: 4df08fa feat(mcp): switch to official mcp gem with Streamable HTTP transport.
이 두 commit 사이에 무슨 일이 있었나.
smarts를 Smithery (MCP 서버 디렉터리)에 등록하려고 submit했다. 스캐너가 첫판부터 405를 던졌다:
POST /mcp/sse → 405 Method Not Allowed
좀 시간을 들여서 알아냈다: fast-mcp 1.6.0 (latest release)이 구현한 건 여전히 MCP spec 2024-11-05의 HTTP+SSE transport다. 그런데 현대 MCP 클라이언트는 이미 spec 2025-03-26의 Streamable HTTP로 옮겨갔다 — 단일 endpoint, 같은 URL에 POST + DELETE, /sse라는 별도 경로 없음.
즉 spec이 한발 나갔고, 커뮤니티 주류 gem이 아직 못 따라잡았다.
fast-mcp가 따라올 때까지 기다릴 생각은 없었다. MCP 서버 층은 클라이언트가 호출하라고 있는 것이다 — spec 정합성이 어떤 gem이 손에 잘 맞느냐보다 훨씬 더 중요하다.
fast-mcp를 Anthropic 공식 mcp gem 0.14.0으로 교체했다. 이번 라운드는 새 thread를 Amp에서 열어서 진행했다 (밑에서 도는 건 여전히 Claude Opus, Claude Code와 같은 모델).
마이그레이션에서 처리한 것들:
Transport 갈아끼우기
# 옛날: fast-mcp가 /mcp에 자동 mount, /sse 포함
# 새것: 수동 mount, 단일 endpoint
mount StreamableHTTPTransport.new(server, stateless: true), at: "/mcp"
stateless: true가 핵심. 서버가 메모리에 per-session 상태를 안 들고 있어서, Puma를 workers > 0로 돌려도 sticky session 없이 수평 확장 가능하다.
Tool API 재구성
옛 fast-mcp 스타일:
class GetContractInfoTool < ApplicationTool
arguments do
required(:chain).filled(:string)
required(:address).filled(:string)
end
def call(chain:, address:)
# 비즈니스 로직
end
end
공식 gem은 SDK 배관을 베이스 클래스로 모은다:
class ApplicationTool < MCP::Tool
def self.call(**args, server_context: nil)
hash = payload(**args)
MCP::Tool::Response.new([{ type: "text", text: hash.to_json }])
end
end
class GetContractInfoTool < ApplicationTool
input_schema(
properties: {
chain: { type: "string" },
address: { type: "string" }
},
required: %w[chain address]
)
def self.payload(chain:, address:)
# 비즈니스 로직, Hash 리턴
end
end
서브클래스는 payload(**args)가 어떤 Hash를 리턴하느냐만 신경 쓴다. 베이스 클래스가 그걸 MCP::Tool::Response 안의 JSON-encoded text block으로 감싼다. 두 가지 이득:
Tool.payload(chain:, address:) Hash에 직접 단언 가능, protocol envelope 까지 않아도 됨dry-schema DSL도 raw JSON Schema 리터럴로 바꿨다. 겉으론 "퇴보" 같지만, input_schema는 MCP spec에서 클라이언트가 직접 소비하는 필드라, spec 원형에 가깝게 쓰는 게 더 정확하다.
테스트 이사
433개 테스트를 Tool.new.call(...)에서 Tool.payload(...)로 바꿨다. 전부 초록.
discovery / docs 동기화
.well-known/mcp.json의 transport: "streamable-http", protocol_version: "2025-03-26"https://smarts.md/mcp로 갱신claude mcp add의 flag를 --transport sse에서 --transport http로mcp를 primary, fast-mcp를 legacy/deprecated로 표시처음 MCP 붙이는 데 30분. 두 번째 다시 쓰는 데도 비슷. 사이는 6일.
깨달은 게 있다: MCP 같은 신생 생태에서는 gem이 spec을 못 따라가는 게 디폴트다. fast-mcp가 나쁘지 않다. 메인테이너도 부지런하다. 문제는 protocol 자체의 반복 속도가 서드파티 구현보다 빠르다는 점. 올해 안정적으로 보이는 gem이, 반년 뒤에는 "옛 spec짜리 그거"가 되어 있을 수 있다.
만약 6개월 전에 fast-mcp 위에 비즈니스 코드를 1만 줄 썼다면, 오늘 옛 spec에 락-인됐을 거다 — 다시 쓰는 비용이 손을 들기 싫을 만큼 커지니까.
근데 실제 상황은: fast-mcp 층은 처음부터 얇은 wrapper에 불과했다. 비즈니스 로직은 다 ApplicationTool 서브클래스 안에 있었다. 그래서 다시 쓰는 진짜 비용은:
진짜 비싼 건 비즈니스 로직. protocol wrapper는 싸야 하고, 다시 쓸 수 있어야 한다. 6일 전 Claude Code가 내게 준 가장 큰 도움이 이거다 — 비즈니스 로직을 fast-mcp 콜백에 쑤셔 넣지 않고, SDK를 흡수하는 깔끔한 ApplicationTool 추상을 만들어 줬다. 그래서 6일 뒤 SDK를 갈아끼울 때 베이스 클래스 + schema 표기만 바꾸면 됐다.
내가 6일 안에 "통합 → 출시 → spec 업그레이드 → 다시 쓰기"를 다 돌 수 있었던 건, 밑에 있는 Claude 모델의 안정성 덕분이다. 어떤 agent 셸을 썼느냐는 별로 상관없다.