첫 번째 MCP 클라이언트 만들기

첫 번째 MCP 클라이언트 만들기 (Build your first client)

이번엔 서버를 띄우고, 그 도구 목록을 보여 주고, 도구를 호출하는 프로그램 — 즉 MCP 클라이언트 — 를 만들어 볼게요. 대상은 첫 번째 서버 만들기에서 만든 기상 서버예요.

서버에 연결하기

기상 프로젝트에 클라이언트 패키지를 추가합니다. 이 패키지는 @modelcontextprotocol/server 와 따로 배포돼요.

npm install @modelcontextprotocol/client

src/client.ts 를 만드세요. Client 하나에 전송 하나를 더하면 그게 완전한 MCP 클라이언트입니다.

import { Client } from '@modelcontextprotocol/client';
import { StdioClientTransport } from '@modelcontextprotocol/client/stdio';

const client = new Client({ name: 'my-first-client', version: '1.0.0' });

const transport = new StdioClientTransport({
    command: 'npx',
    args: ['tsx', 'src/index.ts']
});

await client.connect(transport);

connect()npx tsx src/index.ts 를 자식 프로세스로 띄우고, 그 stdin/stdout 위에서 JSON-RPC로 대화한 뒤 initialize 핸드셰이크를 마칩니다. 이 시점부터 클라이언트가 그 프로세스를 소유해요. 프로세스는 전송이 살아있는 동안 정확히 그만큼만 살아갑니다.

::: tip src/index.ts 를 직접 시작하면 안 돼요 — connect() 가 대신 합니다. 여기서 spawn npx ENOENT 오류가 난다면 command 가 여러분 PATH 에 있는 실행 파일이 아니라는 뜻이에요. :::

stdio는 로컬 호스트가 쓰는 전송이고, 원격 서버용 HTTP는 서버에 연결하기 에서 다룹니다.

서버의 도구 목록 보기

listTools 는 서버가 등록한 모든 도구를, 각 도구 인자의 JSON Schema와 함께 반환해요.

const { tools } = await client.listTools();
for (const tool of tools) {
    console.log(tool.name, '—', tool.description);
}

지금까지 만든 것을 실행해 보세요 — 프로젝트 루트에서 npx tsx src/client.ts 를 입력하면 됩니다. 첫 줄은 자식 프로세스의 stderr에서 전달된 서버 배너이고, 두 번째 줄이 여러분의 반복문 출력이에요.

weather MCP server running on stdio
get-alerts — Get the active weather alerts for a US state

이 스크립트는 스스로 끝나지 않아요. 클라이언트가 아직 살아 있는 서버 프로세스를 소유하고 있으니까요. 지금은 Ctrl+C 로 멈추고, 연결 닫기 에서 제대로 끝내는 법을 볼게요.

도구 호출하기

callTool 은 도구 이름과, 그 inputSchema 를 만족해야 하는 arguments 객체를 받습니다.

const result = await client.callTool({ name: 'get-alerts', arguments: { state: 'CA' } });

for (const block of result.content) {
    if (block.type === 'text') console.log(block.text);
}

도구 결과는 타입이 정해진 content 블록들의 목록이고, get-alertstext 블록 하나를 돌려줘요. 그 텍스트는 국립기상청(National Weather Service)이 돌려준 실제 응답입니다 — 캘리포니아의 활성 특보마다 헤드라인 하나씩, 또는 없을 땐 No active alerts for CA. 라는 문장이죠. 그래서 여러분의 출력은 다른 사람의 것과 다를 수밖에 없어요.

핸들러가 던지는 예외나 inputSchema 가 거부하는 인자도, isError: true 가 붙은 똑같은 모양으로 돌아옵니다. 반면 서버가 등록하지 않은 도구 이름은 프로토콜 수준의 실패라서 await callTool 밖으로 throw 돼요 — 경계를 긋는 기준은 오류 에 있어요.

::: tip 인자를 { state: 'California' } 로 바꾸면 SDK가 핸들러(그 안의 네트워크 요청)가 실행되기 전에 거부합니다.

Input validation error: Invalid arguments for tool get-alerts: state: Too big: expected string to have <=2 characters

이 거부는 평범한 isError: true 결과라, 모델은 메시지를 읽고 맞는 인자로 다시 시도해요. :::

리소스 추가하고 읽기

기상 서버는 아직 리소스를 등록하지 않았어요. 리소스는 클라이언트가 URI로 읽는 데이터이고, 도구는 클라이언트가 호출하는 액션이죠. src/index.ts 에서 return server 줄 위에 리소스 하나를 등록합니다.

server.registerResource('about', 'weather://about', { title: 'About this server', mimeType: 'text/plain' }, async uri => ({
    contents: [{ uri: uri.href, text: 'Alert data comes from the US National Weather Service.' }]
}));

읽기 핸들러는 contents 를 반환해요 — 목록인 이유는 한 번의 읽기가 텍스트·바이너리 여러 부분을 돌려줄 수 있기 때문이죠. 리소스 에서 템플릿, 바이너리 내용, 구독을 다룹니다.

src/client.ts 로 돌아와서, 리소스를 목록으로 확인하고 새 리소스를 uri 로 읽어 볼게요.

const { resources } = await client.listResources();
console.log(resources);

const { contents } = await client.readResource({ uri: 'weather://about' });
console.log(contents);

다시 실행해 보세요. 앞서 봤던 줄들 뒤에 새 로그 두 개가 이렇게 나옵니다.

[
  {
    name: 'about',
    title: 'About this server',
    uri: 'weather://about',
    mimeType: 'text/plain'
  }
]
[
  {
    uri: 'weather://about',
    text: 'Alert data comes from the US National Weather Service.'
  }
]

listResources 는 여러분이 등록한 메타데이터를 광고하고, readResource 는 핸들러의 contents 를 그대로 돌려줍니다.

연결 닫기

파일을 close 로 끝맺음합니다.

await client.close();

close() 는 띄웠던 서버의 stdin을 끝내고, 스스로 끝나지 않으면 프로세스를 종료합니다. 완성된 스크립트를 한 번 더 실행해 보세요 — 위 내용을 모두 출력하고, Ctrl+C 없이 종료될 거예요.

::: tip connectclose 사이에 예외가 던져질 수 있는 클라이언트라면 close()finally 블록에 넣으세요. 그렇지 않으면 크래시가 나도 서버 프로세스가 계속 살아남습니다. :::

도구 목록을 모델에 넘기기

이 페이지의 어디에도 모델을 호출하는 코드는 없어요. 넘겨주는 지점은 바로 listTools() 입니다. 각 항목의 name, description, inputSchema — 평범한 JSON Schema — 가 도구 호출( tool-calling ) LLM API가 받는 도구 정의에 일대일로 대응해요. 그 목록을 담아 대화를 보내고, 모델이 도구 호출을 돌려주면 그 namearguments 를 그대로 callTool 에 넘기고 result.content 를 도구 결과로 붙이면 됩니다.

호스트 — 모델이 들어 있는 애플리케이션 — 는 자기만의 클라이언트를 통해 그 루프를 대신 돌려줘요. 실제 호스트에 붙이기 는 클라이언트 코드 없이도 이 기상 서버를 VS Code, Claude Code, Cursor 에 등록해 주고, SDK 저장소의 examples/cli-client 는 이 페이지의 호출만으로 만든 완전하고 공급자 중립적인 호스트입니다.

요약

  • Client 하나에 전송 하나를 더하면 완전한 MCP 클라이언트가 되고, connect() 가 initialize 핸드셰이크를 실행해요.
  • StdioClientTransport 가 서버 프로세스를 띄우고 소유합니다 — 직접 시작하지 마세요.
  • listTools, callTool, listResources, readResource 가 클라이언트의 동작이고, 각각 타입이 정해진 결과를 반환해요.
  • 실패한 핸들러나 거부된 인자는 isError: true 가 붙은 평범한 결과로 돌아옵니다.
  • close() 가 전송과 띄웠던 프로세스를 정리해요.
  • 모델은 listTools() 출력 — name, description, inputSchema — 을 그대로 사용합니다.