리소스

리소스 (Resources)

리소스(resource) 는 연결된 클라이언트가 목록으로 보고, 읽고, 모델의 컨텍스트로 붙이는 읽기 전용 데이터예요 — 파일, 데이터베이스 행, 렌더링된 리포트 같은 것들이죠. 무엇을 읽을지는 클라이언트가 정합니다. 리소스는 애플리케이션 통제이고, 도구 는 모델 통제예요.

정적 리소스 등록하기

registerResource 는 이름, 고정 URI, 메타데이터, 읽기 콜백을 받아요.

import { McpServer, ResourceTemplate } from '@modelcontextprotocol/server';

const server = new McpServer({ name: 'workspace', version: '1.0.0' });

server.registerResource(
    'config',
    'config://app',
    {
        title: 'Application Config',
        description: 'Application configuration data',
        mimeType: 'text/plain'
    },
    async uri => ({
        contents: [{ uri: uri.href, text: 'log_level=info\nregion=eu-west-1' }]
    })
);

resources/list 가 이제 그 메타데이터와 함께 config://app 을 광고하고, config://app 에 대한 resources/read 는 콜백을 실행해요.

v1에서 오셨나요?

registerResourceresource() 를 대체해요 — codemod 를 실행한 뒤 업그레이드 가이드를 보세요.

읽기 콜백에서 내용 반환하기

콜백은 { contents: [...] } 를 반환해요. 내용에 항목 두 개를 가진 리소스를 추가해 볼게요. 각 항목은 자신이 응답하는 uri 를 다시 말하고, text 또는 base64 blob 중 하나를 싣습니다.

// A 1x1 PNG; a production server reads these bytes from disk or object storage.
const chartPng = 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAAC0lEQVR4nGNgAAIAAAUAAXpeqz8AAAAASUVORK5CYII=';

server.registerResource(
    'report',
    'report://latest',
    {
        title: 'Latest usage report',
        description: 'Weekly usage summary with a rendered chart',
        mimeType: 'text/markdown'
    },
    async uri => ({
        contents: [
            { uri: uri.href, mimeType: 'text/markdown', text: 'Active installs grew 12% week over week.' },
            { uri: uri.href, mimeType: 'image/png', blob: chartPng }
        ]
    })
);

이 페이지의 모든 호출은 위 서버에 연결된 메모리 안의 Client 에서 와요. 서버 테스트하기 가 그 연결을 보여 주고, MCP 호스트도 stdio 또는 HTTP 위에서 똑같이 합니다. 리소스를 읽어 볼게요.

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

콜백의 배열이 항목 하나마다 항목 하나씩, 그대로 돌아옵니다.

[
  {
    uri: 'report://latest',
    mimeType: 'text/markdown',
    text: 'Active installs grew 12% week over week.'
  },
  {
    uri: 'report://latest',
    mimeType: 'image/png',
    blob: 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAAC0lEQVR4nGNgAAIAAAUAAXpeqz8AAAAASUVORK5CYII='
  }
]

각 항목의 mimeType 은 그 항목을 설명하고, 등록 설정의 mimeType 은 리소스 전체를 resources/list 에서 설명해요.

리소스 템플릿 추가하기

ResourceTemplate 은 URI 하나가 아니라 URI 패턴 전체를 등록합니다. list 는 필수 키라, 인스턴스가 무한할 때는 undefined 를 넘기면 돼요.

server.registerResource(
    'user-profile',
    new ResourceTemplate('users://{userId}/profile', { list: undefined }),
    {
        title: 'User Profile',
        description: 'Profile data for one user',
        mimeType: 'application/json'
    },
    async (uri, { userId }) => ({
        contents: [{ uri: uri.href, mimeType: 'application/json', text: JSON.stringify({ userId, plan: 'pro' }) }]
    })
);

매칭된 변수는 읽기 콜백의 두 번째 인자로 파싱되어 도착해요. 패턴이 매칭하는 어떤 URI든 읽을 수 있습니다.

const profile = await client.readResource({ uri: 'users://7/profile' });
console.log(profile.contents);

콜백은 userId'7' 로 묶인 채 실행됐어요.

[
  {
    uri: 'users://7/profile',
    mimeType: 'application/json',
    text: '{"userId":"7","plan":"pro"}'
  }
]

템플릿의 인스턴스 나열하기

users://{userId}/profile 은 읽을 수는 있지만 resources/list 에는 절대 나타나지 않아요. list: undefined 면 열거할 게 없으니까요. 열거 가능한 집합 위에 템플릿을 등록하고 list 콜백을 주면 됩니다.

server.registerResource(
    'team-roster',
    new ResourceTemplate('teams://{teamId}/roster', {
        list: async () => ({
            resources: [
                { uri: 'teams://core/roster', name: 'Core team roster' },
                { uri: 'teams://growth/roster', name: 'Growth team roster' }
            ]
        })
    }),
    {
        description: 'Members of one team',
        mimeType: 'text/plain'
    },
    async (uri, { teamId }) => ({
        contents: [{ uri: uri.href, text: `Members of team ${teamId}` }]
    })
);

resources/list 는 정적 리소스와 각 템플릿의 list 결과를 합칩니다.

const { resources } = await client.listResources();
console.log(resources.map(resource => resource.uri));

teams:// 명단 두 개는 찾을 수 있고, users:// 템플릿은 아무것도 기여하지 않아요.

[
  'config://app',
  'report://latest',
  'teams://core/roster',
  'teams://growth/roster'
]

resources/templates/list 는 여전히 두 URI 패턴을 광고하므로(client.listResourceTemplates()), 이미 userId 를 아는 클라이언트는 구체적인 URI를 스스로 만들어요.

파일 기반 경로 정리하기

파일시스템 경로가 되는 템플릿 변수는 클라이언트가 조종하는 입력이에요. 읽기 전에 실제 경로로 resolve 하고 루트 밖의 것을 거부해야 합니다.

import { readFile, realpath } from 'node:fs/promises';
import path from 'node:path';

const DOCS_ROOT = path.resolve('./docs');

server.registerResource(
    'doc',
    new ResourceTemplate('docs://{file}', { list: undefined }),
    {
        description: 'A markdown page from the docs directory',
        mimeType: 'text/markdown'
    },
    async (uri, { file }) => {
        const requested = await realpath(path.join(DOCS_ROOT, String(file)));
        if (!requested.startsWith(DOCS_ROOT + path.sep)) {
            throw new Error(`${uri.href} resolves outside the docs root`);
        }
        return { contents: [{ uri: uri.href, text: await readFile(requested, 'utf8') }] };
    }
);

realpath.. 세그먼트와 심볼릭 링크를 실제 디스크에 있는 경로로 접어 버리고, 그 뒤의 startsWith 검사가 DOCS_ROOT 를 벗어난 것을 거부합니다. 거부할 때는 예외를 던지고, 던져진 예외가 어떻게 클라이언트에 닿는지는 오류 에서 다룹니다.

::: warning 템플릿 변수나 클라이언트가 준 URI를 검사 없이 파일시스템 API에 넘기면 안 돼요. .. 은 날것과 퍼센트 인코딩된 형태로 오고, 루트 안의 심볼릭 링크는 루트 밖을 가리킬 수 있어요. 클라이언트가 보낸 문자열이 아니라 resolve 된 실제 경로를 비교하세요. :::

리소스가 바뀌었을 때 클라이언트에 알리기

리소스를 등록, 활성화, 비활성화, 제거하면 이미 notifications/resources/list_changed 를 보냅니다. SDK가 볼 수 없는 이유로 집합이 바뀌면 직접 보내면 돼요.

server.sendResourceListChanged();

이 알림은 연결된 클라이언트에게 resources/list 를 다시 부르라고 말합니다. 리소스 하나의 내용이 바뀌는 것은 다른 신호 notifications/resources/updated 에요 — 둘 다 알림 에서 다룹니다.

리소스별 구독 서빙하기

2025 시대 클라이언트는 resources/subscribe 로 URI 하나에 대해 notifications/resources/updated 를 받아옵니다. SDK는 동사를 라우팅하고, 북키핑은 여러분 몫이에요. 기능을 광고하고, 연결별로 URI를 추적하고, 구독자에게만 알림을 보내면 됩니다.

let deployStatus = 'idle';

const deploys = new McpServer({ name: 'deploys', version: '1.0.0' }, { capabilities: { resources: { subscribe: true } } });

deploys.registerResource(
    'deploy-status',
    'deploys://status',
    { description: 'The current deploy state', mimeType: 'text/plain' },
    async uri => ({ contents: [{ uri: uri.href, text: deployStatus }] })
);

// The SDK routes the two verbs; which URIs this connection watches is yours to track.
const subscribedUris = new Set<string>();
deploys.server.setRequestHandler('resources/subscribe', request => {
    subscribedUris.add(request.params.uri);
    return {};
});
deploys.server.setRequestHandler('resources/unsubscribe', request => {
    subscribedUris.delete(request.params.uri);
    return {};
});

async function setDeployStatus(status: string): Promise<void> {
    deployStatus = status;
    if (subscribedUris.has('deploys://status')) {
        await deploys.server.sendResourceUpdated({ uri: 'deploys://status' });
    }
}

Set 은 서버 인스턴스 하나에 속하고, 각 연결은 여러분의 팩토리에서 자기 인스턴스를 받아요 — 그래서 구독은 연결 사이로 새지 않습니다. resources/updated 는 구독한 연결에게만 보내야 하고, 요청하지 않은 리소스별 업데이트는 2025 시대 연결에서는 잘못된 행동이에요.

이 패턴에는 구독 호출보다 오래 사는 연결이 필요해요. stdio(그리고 세션이 있는 모든 배선)에서는 인스턴스와 그 Set 이 연결만큼 살아요. createMcpHandler 의 무상태 legacy 폴백 뒤에서는 각 POST가 새 인스턴스를 받으므로 resources/subscribe 는 성공하고 Set 은 그것과 함께 버려져, 그 자세에서는 어떤 업데이트도 전달될 수 없어요. legacy 클라이언트 지원하기 가 서빙 자세를 다룹니다.

::: info 2026-07-28 연결에서는 이 동사가 존재하지 않아요. 클라이언트가 subscriptions/listen 필터에 리소스 URI를 넣고, 그 항목이 전달을 스스로 걸러냅니다 — serveStdio 는 인스턴스 자신의 sendResourceUpdated 호출을 매칭되는 스트림으로 라우팅하고, createMcpHandler 는 여러분이 notifier 에 발행한 것을 전달해요(핸들러를 통한 리소스 업데이트 발행). :::

따라서 이중 시대(dual-era) 서버는 2026-07-28 연결에서도 여전히 sendResourceUpdated 를 부르는데, 거기서 구독 집합은 항상 비어 있어요 — 집합뿐 아니라 연결의 시대도 함께 검사해야 합니다. resources 예제 는 팩토리에서 reqCtx.era === 'modern' || subscribedUris.has(uri) 로 방어하고, 자기 검증( self-verifying ) 쌍으로 동작합니다. 전달은 두 시대 모두에서 stdio 위에서, 그리고 2026-07-28 listen 경로에서는 HTTP 위에서 단언되고, 무상태 legacy HTTP 구간은 구독 호출이 성공하는 것만 단언해요.

요약

  • registerResource(name, uri, config, readCallback) 는 고정 URI에 리소스를 등록합니다.
  • 읽기 콜백은 { contents: [...] } 를 돌려주고, 각 항목은 uri 를 다시 말하며 text 또는 base64 blob 을 싣어요.
  • ResourceTemplate 은 URI 패턴을 등록하고, 매칭된 변수는 읽기 콜백의 두 번째 인자로 파싱되어 옵니다.
  • 템플릿의 list 콜백이 그 인스턴스를 resources/list 에 나타나게 해요.
  • 파일 기반 경로는 실제 위치로 resolve 하고, 읽기 전에 루트 밖의 것을 거부해야 합니다.
  • 등록 변경은 notifications/resources/list_changed 를 자동으로 내보내요.
  • resources/subscribe 북키핑은 서버 몫입니다 — resources: { subscribe: true } 를 광고하고, 연결별로 URI를 추적하며, 구독자에게만 resources/updated 를 보내요.