Claude 커스텀 커넥터는 osFoundry의 OAuth를 어떻게 찾고, 왜 권한을 매번 확인할까
토큰 없이 osFoundry의 원격 MCP 서버에 tool을 호출하면 이런 응답이 온다. 2026-11-06에 도쿄에 있는 Mac에서 curl로 직접 찔러 본 결과다. $ curl -s -D - -X POST https://api.osfoundry.io/api/v1/external-mcp \ -H 'content-type: application/json' \ -H 'acc
토큰 없이 osFoundry의 원격 MCP 서버에 tool을 호출하면 이런 응답이 온다. 2026-11-06에 도쿄에 있는 Mac에서 curl로 직접 찔러 본 결과다.
$ curl -s -D - -X POST https://api.osfoundry.io/api/v1/external-mcp \
-H 'content-type: application/json' \
-H 'accept: application/json, text/event-stream' \
-d '{"jsonrpc":"2.0","id":3,"method":"tools/call",
"params":{"name":"list_project_resources","arguments":{}}}'
HTTP/2 401
www-authenticate: Bearer resource_metadata="https://api.osfoundry.io/.well-known/oauth-protected-resource/api/v1/external-mcp", error="invalid_token", error_description="Connect osFoundry to continue"
{"jsonrpc":"2.0","id":3,"result":{"isError":true,
"content":[{"type":"text","text":"Authentication required"}],
"_meta":{"mcp/www_authenticate":["Bearer resource_metadata=\"https://api.osfoundry.io/.well-known/oauth-protected-resource/api/v1/external-mcp\", ..."]}}}
Claude 커스텀 커넥터든 ChatGPT 개발자 모드의 MCP 연결이든, 로그인 화면까지 가는 단서는 이 헤더 한 줄이다. 사용자가 붙여 넣는 건 URL 하나뿐이다. 앞부분은 실서버에 인증 없이 보낸 요청만으로 정리했다. 뒷부분에서는 curl로는 안 보이는 부분, 즉 로그인한 뒤 권한이 어떻게 다뤄지는지를 Python으로 작게 다시 만들어서 측정했다.
토큰이 없을 때 클라이언트에 보이는 것
메서드마다 응답이 다르다.
| 요청 | 상태 | 내용 |
|---|---|---|
initialize |
200 | 프로토콜 2025-06-18, 서버 osfoundry 0.1.0, instructions |
tools/list |
200 | tool 59개, 45,368바이트 |
tools/call |
401 | 리소스 메타데이터를 가리키는 WWW-Authenticate
|
엔드포인트에 GET
|
405 | GET으로 여는 SSE 스트림은 없다 |
핸드셰이크와 tool 목록은 공개돼 있다. 토큰이 필요한 건 tool 실행뿐이다. 덕분에 호스트는 로그인 전에도 어떤 tool이 있는지 사용자에게 보여 줄 수 있다. 목록에 사용자 데이터는 없고, initialize의 instructions도 일반적인 안내문이다.
Use list_granted_resources first. Every Note edit must use the exact tab ID.
Local-only data is unavailable to cloud clients.
tools/list에 나오는 tool마다 필요한 OAuth scope와 읽기 전용 여부를 스스로 선언한다.
{"name": "edit_record_item",
"annotations": {"readOnlyHint": false, "destructiveHint": true, "openWorldHint": false},
"securitySchemes": [{"type": "oauth2", "scopes": ["osfoundry:write"]}]}
59개를 세 보면 osfoundry:read 30개(전부 readOnlyHint: true), osfoundry:write 26개, osfoundry:run 3개(request_session_run, run_graph_session, cancel_graph_run)다. 호스트는 readOnlyHint를 보고 어떤 호출에서 승인을 받을지 정할 수 있다.
401은 어디를 가리키나
resource_metadata는 RFC 9728(OAuth Protected Resource Metadata) 형식이다. well-known은 호스트 바로 아래에 두고, 그 뒤에 리소스 경로를 이어 붙인다. 리소스 URL 끝에 붙이는 게 아니다.
GET /.well-known/oauth-protected-resource/api/v1/external-mcp 200
GET /.well-known/oauth-protected-resource 200 (같은 문서)
GET /api/v1/external-mcp/.well-known/oauth-protected-resource 404
{"resource": "https://api.osfoundry.io/api/v1/external-mcp",
"authorization_servers": ["https://api.osfoundry.io"],
"scopes_supported": ["osfoundry:read", "osfoundry:write", "osfoundry:run", "osfoundry:models"]}
클라이언트를 직접 짜면 제일 먼저 이 404에서 삽질하게 된다. URL은 조립하지 말고 헤더에 적힌 걸 그대로 쓰면 된다.
인가 서버 메타데이터
/.well-known/oauth-authorization-server(RFC 8414)는 이렇다.
{"issuer": "https://api.osfoundry.io",
"authorization_endpoint": "https://api.osfoundry.io/api/v1/external-oauth/authorize",
"token_endpoint": "https://api.osfoundry.io/api/v1/external-oauth/token",
"registration_endpoint": "https://api.osfoundry.io/api/v1/external-oauth/register",
"grant_types_supported": ["authorization_code", "refresh_token"],
"token_endpoint_auth_methods_supported": ["none"],
"code_challenge_methods_supported": ["S256"],
"authorization_response_iss_parameter_supported": true}
필드 하나하나가 설계 결정이다.
-
registration_endpoint가 있다. 호스트가 동적 클라이언트 등록(DCR)으로 스스로 등록하니까 사용자가 client ID를 만질 일이 없다. - 토큰 엔드포인트 인증이
none이다. 호스트는 시크릿이 없는 public client고, 인가 코드를 요청한 쪽과 묶어 주는 건 PKCE(S256)다. -
iss파라미터를 지원한다. 인가 서버 여러 개와 붙는 호스트에서 서로 헷갈리지 않게 막아 준다.
authorize 단계에서 브라우저는 osFoundry 동의 화면 https://osfoundry.io/external-assistant-authorize로 넘어간다. 여기서 사용자는 워크스페이스를 딱 하나 고르고 "I approve connecting this assistant to …"에 체크한다. 이 승인으로 정해지는 건 워크스페이스뿐이다. 새 연결에는 프로젝트가 하나도 없다. 프로젝트는 나중에 앱(열쇠 아이콘 → Connected → Add to project)에서 소유자가 붙인다.
scope만으로는 부족한 이유
scope는 토큰을 발급할 때 정해진다. osfoundry:write는 쓰기 계열 동작을 해도 된다는 뜻일 뿐이다. 어느 노트인지는 모르고, 그 노트가 10분 전에 다른 프로젝트로 옮겨졌다는 것도 모른다. osFoundry 설계 문서에는 호출할 때마다 다시 읽는 항목이 정해져 있다. 연결 상태, 워크스페이스, 프로젝트 소속, 리소스가 지금 속한 프로젝트와 ACL, 채널 소속이나 세션 역할, tool, OAuth scope다. 연결을 승인한 사람이 지금도 그 항목의 편집 권한을 갖고 있어야 한다는 조건도 붙는다. 빼거나 옮긴 변경은 즉시 반영된다고 적혀 있다.
반대 방식도 있다. 프로젝트 목록을 토큰(또는 토큰을 키로 쓰는 캐시)에 넣어 두고 만료될 때까지 믿는 방식이다. 구현은 이쪽이 편하다. 동작이 얼마나 달라지는지 보려고 둘 다 만들어 봤다.
최소 구성으로 다시 만들기
Claude 바이너리는 쓰지 않았다. client.py가 호스트를, server.py가 MCP 서버를 흉내 낸다. 둘 다 표준 라이브러리만 썼고 합쳐서 약 280줄이다. Apple M1, Python 3.9.6에서 돌렸다. 서버가 구현한 건 다음과 같다.
- 일반 HTTP JSON-RPC 위의
initialize,tools/list,tools/call - well-known 메타데이터 두 개
- 동의 화면 대신 워크스페이스 하나로 자동 승인하는
/authorize - PKCE S256을 검증하는
/token
Connected 탭에서 소유자가 누르는 동작은 /admin/*로 대신했다.
tool 실행 전에 매번 거치는 체크는 이게 전부다.
def access(token_info, tool, note_id):
if TOOL_SCOPE[tool] not in token_info["scopes"]:
return "insufficient_scope"
if CHECK == "token_only": # design B: trust issue-time state
ok = note_id is None or NOTES[note_id]["project"] in token_info["projects_at_issue"]
return None if ok else "not_in_project"
conn = CONNECTIONS.get(token_info["connection"])
if not conn or conn["status"] != "active":
return "connection_revoked"
if note_id is None:
return None
note = NOTES.get(note_id)
if not note or note["workspace"] != conn["workspace"]:
return "not_found"
if (token_info["connection"], note["project"]) not in PROJECT_MEMBERS:
return "not_in_project"
return None
클라이언트는 토큰 없이 출발한다. 먼저 tool을 호출해 401을 받고, WWW-Authenticate에서 resource_metadata를 꺼낸다. 메타데이터 두 개를 가져온 뒤 새 PKCE verifier로 authorize와 token을 통과하고, 시나리오를 차례로 돌린다.
$ CHECK=per_call python3 server.py & python3 client.py 8767
discovery: first call -> 401, metadata /.well-known/oauth-protected-resource/mcp, 5 round trips, 11.3 ms
1 fresh connection, no project yet 200 OK []
2 after owner adds project A 200 OK ["note-a1"]
3 read note in A 200 OK "Q3 launch checklist"
4 read note in B (never added) 200 DENY not_in_project
5 write with a read-only token 403 DENY insufficient_scope
6 write with read+write token 200 OK "saved"
7 same read after note moved to B 200 DENY not_in_project
8 same read after owner removes project A 200 DENY not_in_project
9 same read after revoke 200 DENY connection_revoked
같은 클라이언트를 CHECK=token_only 서버에 붙이면 이렇게 나온다. 이쪽 토큰은 마지막으로 갱신할 때의 프로젝트 목록을 들고 있다.
7 same read after note moved to B 200 DENY not_in_project
8 same read after owner removes project A 200 OK "Q3 launch checklist v2"
9 same read after revoke 200 OK "Q3 launch checklist v2"
1~7은 똑같다. token_only에서도 7이 막히는 건 노트의 소속 프로젝트만은 그때그때 조회하기 때문이다. 낡는 건 토큰에 박아 둔 사실뿐이다. 차이는 8과 9에서 났고, 둘 다 소유자에게 중요한 동작이다. 프로젝트에서 빼고 연결까지 취소했는데 채팅은 계속 읽는다. 이 상태는 토큰이 만료될 때까지 이어지고, 이번 재구현에서는 expires_in: 3600이었다.
거부는 두 층으로 나눠서 돌려준다. scope가 모자라면 프로토콜 문제로 보고 403과 error="insufficient_scope"에 필요한 scope를 붙여 보낸다. 그러면 호스트가 권한을 더 요청할 수 있다. 어느 항목이냐에 걸린 거부는 isError: true가 붙은 평범한 tool 결과로 보낸다. 이건 재인가해도 해결되지 않으니 호스트에게 다시 해 보라고 할 이유가 없다.
매번 체크하는 비용
서버 안에서 access() 앞뒤를 time.perf_counter_ns()로 감싸 실행마다 1,000번(허용 500, 거부 500) 쟀다. 측정하는 동안 Mac에서는 다른 작업도 돌고 있었다.
| access() 중앙값 | access() p99 | 루프백 tool 호출 중앙값 | |
|---|---|---|---|
| 매번 체크 (6회 실행) | 1.3–1.6 µs | 6.1–7.0 µs | 0.43–0.78 ms |
| token_only (3회 실행) | 0.7–1.6 µs | 3.5–7.7 µs | 0.35–1.36 ms (2회분) |
메모리 안에서 끝나는 구성에서는 매번 하는 재확인이 루프백 HTTP의 편차에 묻혔다. 실제 서버는 집합 조회 대신 DB를 보니까 더 무겁다. 다만 그 수치는 이 재구현으로는 잴 수 없어서 적지 않는다. 비교용으로 실서버를 보면, 도쿄에서 왕복 한 번에 약 500~800ms가 걸렸다. urllib으로 매번 TLS를 새로 맺고 각 10회 잰 값이고, 중앙값은 initialize 790ms, tools/list 816ms, 401이 나는 tools/call 565ms, 메타데이터 각 520~530ms였다. 커넥터의 시간을 잡아먹는 건 권한 체크가 아니다.
디스커버리는 로컬에서 5번 왕복했다(401, 리소스 메타데이터, 인가 서버 메타데이터, authorize, token). 7번은 7.5~13.4ms 안에 끝났고, Mac이 바빴던 한 번만 34ms였다. 실서버에 인증 없이 보낼 수 있는 세 구간을 더하면 콜드 스타트에서 약 1.6초다. authorize는 어차피 사람의 클릭을 기다리니까 여기서는 문제가 안 된다.
Claude는 호출하는데 osFoundry는 채팅을 못 여는 이유
이 구성에서 호출하는 쪽은 호스트다. 연결을 열고, tool 목록을 받고, 언제 호출할지 정하는 건 호스트고, 서버는 답만 한다. 서버가 호스트의 대화를 붙잡을 방법은 없다. osFoundry 계획 문서의 기능 표에도 개인 ChatGPT/Claude 채팅을 열거나 목록으로 보는 칸은 "No"이고, 이유는 "No general host API is provided for this"라고 적혀 있다.
tool 설계도 여기에 맞춰져 있다. link_current_chat은 채팅 안에서 그 채팅이 스스로 링크를 만들게 하는 tool이다. 라벨은 자기 신고고, osFoundry는 이걸 신원 확인이 아니라 라우팅용 메모로 쓴다. 링크된 Claude 채팅에 배정한 작업은 사용자가 그 채팅을 열고 확인해 달라고 할 때까지 기다린다. 구독한 채팅에 전달되는 MCP Events는 ChatGPT 쪽 기능이고, 계획 문서에서도 호스트별 기능으로 다룬다.
데이터에 관한 판단은 전부 서버가 한다. 프로젝트의 지금 상태를 아는 쪽이 서버뿐이기 때문이다. 언제 움직이고 사용자에게 물어볼지는 호스트가 정한다. 두 쪽의 인식이 어긋나도 매번 체크하면 서버의 판단은 계속 맞는 상태로 남는다.
직접 붙인다면 정해 둘 것
- 401의
WWW-Authenticate에resource_metadata를 넣고, RFC 9728 메타데이터를/.well-known/oauth-protected-resource/<리소스 경로>에서 내준다. URL을 직접 조립하는 클라이언트는 틀리기 쉬운데, 헤더가 있으면 어떤 클라이언트도 헤매지 않는다. - 로그인 전에 뭘 공개할지 정한다. osFoundry는
initialize와tools/list를 열어 두고tools/call만 막았다. tool 설명에서 테넌트 정보가 새어 나간다면tools/list도 막는다. - tool마다 scope와
readOnlyHint를 선언한다. 호스트는 이걸 보고 승인 절차를 넣을 수 있고, 403insufficient_scope를 받으면 무엇을 더 요청해야 할지 안다. - 토큰에는 누구인지, 어느 연결인지, 어떤 scope인지만 넣는다. 워크스페이스, 프로젝트 소속, 리소스의 소속 프로젝트, 승인한 사람의 현재 권한은 매번 다시 읽는다. 그래야 취소도, 다른 프로젝트로 옮기기도 다음 호출부터 바로 반영된다.
- 리소스 단위 거부는 401/403이 아니라 tool 결과로 돌려준다. "이 노트는 프로젝트 B에 있다"는 토큰을 바꿔도 그대로다.
- 쓰기에는
expected_version과 멱등 키를 필수로 받는다. 호스트는 재시도하고, 모델은 같은 호출을 다시 보낸다.
스펙의 MCP 쪽 내용은 MCP 인가 문서에 있다. osFoundry가 정리한 짧은 정의는 용어집의 MCP 항목에 있다.
Originally published by Dev.to Security. Aggregated on AIWithGhost for educational purposes — full credit and traffic to the original publisher.