MCP는 모델과 툴 간의 통신을 정의하는 경량 프로토콜이다. GPT나 Claude 같은 LLM이 외부 툴을 호출할 수 있게 해주는 명세이자 프로토콜이다.
JSON-RPC 스타일의 표준 스트리밍 프로토콜이며 이를 통해 LLM이 외부의 도구를 “프로그래밍 없이 조작할 수 있도록” 설계되었다.
지난 포스팅에서 MCP에 대해서 더 많은 소개를 해두었으니 MCP 소개 포스팅을 참고하는 것을 추천한다.
이번 포스트에서는 그럼 MCP는 어떻게 활용할 수 있을 지, 공식적으로 제공하는@modelcontextprotocol/sdk를 통해 typescript에서 mcp-graphql-tools server를 만들어본 후기를 공유하며 더 자세히 알아보도록 하겠다.
가장 핵심이 되는 객체로, MCP 프로토콜에 따라 클라이언트와의 초기화, 통신, 메시지 라우팅을 책임지는 본체다. 이 MCP 서버는 JSON-RPC 기반의 메커니즘을 따른다.
const server = new Server(
{
name: "graphql-mcp-server",
version: 0.0.1,
},
);
MCP는 JSON-RPC라는 통신 방식을 기반으로 설계되어 있다. JSON-RPC는 이름 그대로 JSON 포맷을 사용해 원격 프로시저 호출(Remote Procedure Call)을 수행할 수 있도록 도와주는 경량화된 메시지 프로토콜이다.
HTTP, WebSocket 등 다양한 transport 위에서 작동할 수 있으며, LLM이 외부 도구나 리소스에 함수 호출하듯 상호작용할 수 있게 해준다.
예를 들어 LLM이 tools/call이라는 메서드를 호출하면서 { name: "calculate-bmi", arguments: { weightKg: 60, heightM: 1.7 } } 형태의 JSON 데이터를 서버에 보내면, 서버는 해당 메서드를 처리하고 결과를 다시 JSON 형태로 응답해준다.
JSON-RPC의 특징은 다음과 같다.
요약하면, JSON-RPC는 LLM이 외부 시스템과 안정적으로 통신하고, 함수를 호출하듯 툴이나 리소스를 사용할 수 있게 해주는 기본 토대이다. 이를 통해 복잡한 HTTP 라우팅 없이도, MCP 서버가 LLM의 요청을 받아서 툴 실행 → 응답까지 이어지는 흐름을 단순화시켜준다.
Resources는 일종의 “LLM이 읽을 수 있는 데이터 뷰”다. REST API의 GET 엔드포인트와 비슷하게, 순수하게 데이터를 제공하기 위한 목적이다. 복잡한 계산이나 side-effect는 없어야 하며, 단순히 LLM이 읽을 수 있도록 구조화된 텍스트를 돌려준다.
// 정적 자원
server.resource(
"config",
"config://app",
async (uri) => ({
contents: [{
uri: uri.href,
text: "App configuration here"
}]
})
);
// 동적 자원
server.resource(
"user-profile",
new ResourceTemplate("users://{userId}/profile", { list: undefined }),
async (uri, { userId }) => ({
contents: [{
uri: uri.href,
text: `Profile data for user ${userId}`
}]
})
);
정적 리소스와 템플릿 기반 리소스 둘 다 등록할 수 있으며, URI 템플릿 패턴에 따라 다양한 변형 주소도 대응 가능하다.
Tools는 LLM이 호출할 수 있는 “Action”이다. 데이터를 반환하는 리소스와는 달리, 툴은 실제로 계산하거나 외부 시스템에 영향을 줄 수 있는 함수다. 툴은 보통 LLM의 결과에 따라 선택적으로 호출되며, 명령적 함수라고 보면 된다.
// 간단한 Tool
server.tool(
"calculate-bmi",
{
weightKg: z.number(),
heightM: z.number()
},
async ({ weightKg, heightM }) => ({
content: [{
type: "text",
text: String(weightKg / (heightM * heightM))
}]
})
);
// API Call을 통한 비동기 Tool
server.tool(
"fetch-weather",
{ city: z.string() },
async ({ city }) => {
const response = await fetch(`https://api.weather.com/${city}`);
const data = await response.text();
return {
content: [{ type: "text", text: data }]
};
}
);
Prompts는 LLM과 자연스럽게 소통하기 위한 대화의 템플릿이다. 정해진 인자 구조를 받아 대화 메시지를 구성하는 역할을 하며, 대개 프롬프트 기반의 요청을 LLM에게 보낼 때 사용된다.
server.prompt(
"review-code",
{ code: z.string() },
({ code }) => ({
messages: [{
role: "user",
content: {
type: "text",
text: `Please review this code:\n\n${code}`
}
}]
})
);
여기서 중요한 건, Prompt는 단순한 텍스트 문자열이 아니라, 역할과 형식을 가진 메시지 구조로 전달된다는 점이다.
이번 프로젝트에서는 기존 GraphQL API를 MCP Server로 감싸는 구조를 구현해보았다. GraphQL은 다양한 데이터를 쿼리 언어로 자유롭게 조회할 수 있어서 매우 유용한 도구지만, LLM이 직접 GraphQL을 이해하고 사용하기에는 진입장벽이 있다.
이 문제를 해결하기 위해, GraphQL API를 MCP의 tool로 추상화해, LLM이 자연스럽게 데이터를 조회하고 조작할 수 있게 했다.
graphql_query
graphql_introspect
Tool
을 MCP Server에 연관시켜주는 로직 흐름은 간단하게 아래와 같이 구성하였다.
const GRAPHQL_QUERY_TOOL: Tool = {
name: "graphql_query",
description:
"Execute GraphQL queries using either a specified endpoint or the default endpoint configured during installation",
inputSchema: {
type: "object",
properties: {
endpoint: {
type: "string",
description: "GraphQL endpoint URL (can be omitted to use default)",
},
query: {
type: "string",
description: "GraphQL query to execute",
},
variables: {
type: "object",
description: "Variables to use with the query (JSON object)",
},
headers: {
type: "object",
description:
"Additional headers to include in the request (will be merged with default headers)",
},
timeout: {
type: "number",
description: "Request timeout in milliseconds",
},
},
required: ["query"],
},
};
const GRAPHQL_INTROSPECT_TOOL: Tool = {
name: "graphql_introspect",
description:
"Introspect a GraphQL schema from an endpoint with configurable headers",
inputSchema: {
type: "object",
properties: {
endpoint: {
type: "string",
description: "GraphQL endpoint URL (can be omitted to use default)",
},
headers: {
type: "object",
description:
"Additional headers to include in the request (will be merged with default headers)",
},
includeDeprecated: {
type: "boolean",
description: "Whether to include deprecated fields",
},
},
},
};
const GRAPHQL_TOOLS = [GRAPHQL_QUERY_TOOL, GRAPHQL_INTROSPECT_TOOL] as const;
server.setRequestHandler(ListToolsRequestSchema, async () => ({
tools: GRAPHQL_TOOLS,
}));
MCP 클라이언트가 지원 툴 목록을 요청할 때, 두 개의 툴(graphql_query
, graphql_introspect
)을 응답한다.
server.setRequestHandler(CallToolRequestSchema, async (request) => {
// 요청받은 툴 이름에 따라 분기 처리
switch (request.params.name) {
case "graphql_query":
return await handleGraphQLQuery();
case "graphql_introspect":
return await handleGraphQLIntrospect();
}
});
툴 호출이 들어오면 이름에 따라 적절한 핸들러(handleGraphQLQuery
, handleGraphQLIntrospect
)를 실행한다.
어느정도 로직을 작성하고 MCP Server가 잘 동작하는지 테스트를 하고 싶었는데, 처음에는 어떻게 테스트를 해야할지 감이 안잡혔다.
이전 포스팅에서 소개한 것 처럼 calude_desktop_config.json
의 변경을 통해 직접 MCP Server를 실행 시킬 수 있다.
서버를 빌드 한 후 해당 경로의 index.js
를 실행시켜 주기만 하면 된다. 하지만, 평소 Chat GPT만 유료로 사용하고 Calude는 무료버전을 사용하고 있어서 테스트를 하기에는 채팅 수 제한이 매우 거슬렸다.
{
"mcpServers": {
"mcp-graphql": {
"command": "node",
"args": ["/Users/hanseung-u/Desktop/mcp-graphql/build/index.js"]
}
}
}
조금 더 찾아보니 공식적으로 @modelcontextprotocol/inspector를 제공하고 있어 이것을 사용하기로 했다.
공식 문서를 따라 inspector를 실행시키면 간단하게 MCP Server 연결 및 Tool을 테스트 할 수 있다.
directive @depthLimit(limit: Int!, message: String) on FIELD_DEFINITION
type Query {
userDetails(id: ID!): User
}
type Mutation {
createUser(name: String!): User
createPost(userId: ID!, title: String!): Post
addComment(postId: ID!, content: String!, author: String!): Comment
}
type User {
id: ID
name: String
posts: [Post]
}
type Post {
title: String
comments: [Comment]
}
type Comment {
id: ID
content: String
author: String
}
위와 같은 간단한 GraphQL Schema를 가지고 있는 서버를 하나 실행시켜 테스트를 진행해보았다.
현재 실행중인 서버에 대한 GraphQL Schema를 Claude에게 요청해 보았더니, graphql_introspect
Tool을 자동으로 실행시켜 알맞은 Schema를 반환해 주었다.
서버에서 ID가 1인 사용자의 정보를 요청하는 쿼리를 유도해보았다. graphql_query
Tool을 실행시켜야 한다는 것을 찰떡같이 알아듣고 실행시켜서 userDetails(id: ID!)
쿼리에 id 1번으로 실행을 시켜 정보를 반환해 주었다.
앞서 소개했듯이 graphql_query
Tool에는 Mutation을 허용할지 말지에 대한 옵션도 넣어 두었다. 이는 혹시 모를 잘못된 데이터를 생성할 경우와 사용자는 쿼리를 요청했지만 LLM이 실수로 Mutation을 수행 할 경우를 방지하기 위한 목적으로 넣어두었다.
그래서, 아래와 같이 createUser(name: String!): User
Mutation 요청을 하면, Mutation 요청에 대해서 허용되지 않았다는 오류와 해결책을 제시한다.
이후 Mutation 작업을 허용해준다는 프롬포트와 함께 새로운 사용자 생성과 해당 유저의 정보를 요청하면, createUser(name: String!): User
Mutation 쿼리를 수행한 뒤, userDetails(id: ID!): User
쿼리를 수행해서 새로 만든 사용자에 대한 정보를 가져와준다.
아래와 같이 다른 Mutation에 대해서도 잘 작동하는 것을 볼 수 있다.
앞선 GraphQL Schema를 확인해보면 Post
를 가져오는 Query는 따로 존재하지 않는다. 그렇기 때문에, Post에 Comment를 만드려고 해도 id를 직접적으로 알 수 없기 때문에 불가능하다. 이러한 경우 LLM이 어떻게 동작하는지 궁금해서 아래와 같은 케이스를 만들어 보았고 아래와 같이 실행시켜 보았다.
어떻게 이 addComment(postId: ID!, content: String!, author: String!): Comment
Mutation을 수행시킬 수 있을지 Cluade가 다양한 경로를 유추하는 모습을 볼 수 있다.
물론, 그 과정에서 다른 Post
데이터에 댓글을 달며 잘못된 데이터를 만들긴 했지만 결과적으로는 나의 요청을 성공적으로 해낸 것을 볼 수 있다. (잘못된 데이터를 만드는 것은 프롬포트를 통해 방지하는 것이 필수처럼 보인다.)
단순히 Schema에 존재하는 Mutation와 Query를 실행하는 것이 아니라 Schema의 맥락을 전체적으로 이해하고 다양한 방식을 시도하며 결과 값까지 직접 확인해서 성공 여부를 판단하는 이 일련의 과정이 정말 인상 깊었다.
공식적으로 제공하는 MCP sdk를 사용하여 간단하게 GraphQL MCP Server
를 만들어 보았다. 또한, 실제로 LLM에 적용해봄으로서 만들어진 Tool이 Claude에게 얼마나 강력한 무기로 작용하는 지 확인해보았다.
현재 MCP가 오픈소스로 공개된 후 많은 개발자들이 MCP Server를 만들고 있으며, GraphQL Tool 컨셉을 가진 MCP Server도 mcp-server
이름으로 실제 NPM에 배포하려고 보니 같은 이름으로 만들어진 mcp-server가 존재했다.
사실 공개된지 이제 4개월 밖에 안되었기에 어느 정도 MCP 생태계가 성장할 지는 잘 모르겠지만, 이미 많이 만들어져 있고 더 생길 MCP Server를 사용할 수 있는 점과 굳이 만들어진 MCP Server를 사용하지 않더라도 MCP Server의 개발이 어렵지 않아 개인의 요구사항에 맞는 LLM agent를 만들기 쉬워짐은 분명한 것 같다.