"에이전트에게 API 키를 넘기지 말고, 대신 '이 계정으로 이 동작을 실행해 줘'라고 부탁할 창구를 하나 세워라" — 그 창구를 1,000개 서비스 분량으로 미리 만들어 둔 오픈소스 런타임이다.
투숙객(=AI 에이전트)이 "203호 금고 좀 열어줘", "세탁 맡겨줘", "택시 불러줘"라고 요청한다. 컨시어지(=OpenConnector)는 마스터키를 자기 서랍에 두고 요청을 대신 처리한 뒤 결과만 알려준다.
투숙객은 마스터키를 만져본 적이 없다. 그래서 투숙객이 실수하거나 나쁜 마음을 먹어도 피해 범위가 "컨시어지가 허용한 업무 목록" 안으로 제한된다. 게다가 컨시어지는 모든 요청을 장부(run log)에 남기고, "이 투숙객은 금고는 못 열고 세탁만 가능" 같은 규칙(action policy)도 적용한다.
OpenConnector가 하는 일이 정확히 이것이다. 다른 점은 마스터키가 1,000개 서비스분이라는 것뿐.
구체적으로는 네 가지 입구를 제공한다. SDK(앱 코드에서 호출), oo CLI(로컬 에이전트 릴레이), MCP(Claude 같은 에이전트 호스트가 붙는 표준 프로토콜), HTTP/OpenAPI(그 외 모든 클라이언트). 그리고 브라우저에서 쓰는 Web Console이 관리·디버깅용으로 함께 들어 있다.
# 인증이 필요 없는 액션으로 런타임이 살아있는지 확인
curl -s -X POST http://localhost:3000/v1/actions/hackernews.get_top_stories \
-H 'content-type: application/json' \
-d '{"input":{}}'
# GitHub 개인 액세스 토큰을 게이트웨이에 "보관"시킨다 (에이전트에겐 안 준다)
curl -s -X PUT http://localhost:3000/api/connections/github \
-H 'content-type: application/json' \
-d '{"authType":"api_key","values":{"apiKey":"github_pat_..."}}'
# 이제 에이전트는 토큰 없이 액션 ID만으로 실행한다
curl -s -X POST http://localhost:3000/v1/actions/github.get_current_user \
-H 'content-type: application/json' \
-d '{"input":{}}'
LLM 자체는 이제 충분히 똑똑하다. 문제는 에이전트가 실제로 뭔가를 하려면 사용자의 Gmail·Notion·Slack에 접근해야 하는데, 그 인증을 붙이는 일이 지루하고 위험하다는 것이다. 서비스마다 OAuth 흐름이 다르고, 토큰 갱신 주기가 다르고, 스코프 이름이 다르다. 프로바이더 10개만 붙여도 인증 코드가 앱 코드를 잡아먹는다.
Composio 같은 상용 서비스가 이 문제를 대신 풀어주면서 빠르게 성장했다. 그런데 상용 SaaS에 사용자의 모든 앱 자격증명을 위탁하는 구조는 보안·컴플라이언스 관점에서 부담이 크다. "자체 호스팅 가능한 같은 것"에 대한 수요가 자연스럽게 생겼고, OpenConnector가 그 자리를 노린다.
docker compose up 한 줄이면 로컬에서 전부 돈다. 데이터는 SQLite 파일 하나(/app/data 볼륨)에 들어간다. 클라우드 계정도, 회원가입도 필요 없다.
같은 코드베이스가 Node 서버(SQLite 저장)와 Cloudflare Workers(D1 + R2 저장) 양쪽에서 돈다. src/server/storage/에 sqlite-runtime-store.ts와 d1-runtime-store.ts가 나란히 있고, 비밀 암호화도 Node용 secret-codec.ts(node:crypto)와 Worker용 worker-secret-codec.ts(WebCrypto)로 나뉜다. 서버리스 무료 티어로도 굴릴 수 있다는 뜻이다.
POST /mcp 하나만 에이전트 호스트에 등록하면 끝난다. 노출하는 MCP 툴은 딱 4개 — list_apps, search_actions, get_action_guide, execute_action. 액션 1만 개를 툴 1만 개로 쏟아붓지 않고 "검색해서 찾아 쓰는" 디스커버리 방식을 택한 게 핵심 설계 판단이다(→ 4장에서 자세히).
모든 액션이 입력 스키마 · 출력 스키마 · 필요 스코프 · 실행기 소스를 공개한다. 에이전트가 "이 액션을 부르면 정확히 뭐가 필요하고 뭐가 돌아오는지"를 실행 전에 알 수 있다. 블랙박스 툴 호출이 아니다.
src/providers/ 아래에 실제로 1,097개 디렉터리가 있다. GitHub·Gmail·Notion·BigQuery·Slack 같은 대형 서비스부터 geocodio, kraken_io, alpha_vantage, coinmarketcap, tencent_maps 같은 틈새 API까지. "쓸 만한 게 있나"를 걱정할 단계는 지났다.
"1,000+ 프로바이더"가 곧 "1,000개 전부 로컬 실행 가능"은 아니다. 액션 정의에는 execution.locallyExecutable 플래그가 있고, 이게 false면 로컬 실행기 없이 카탈로그에만 존재한다. 실제 커버리지는 프로바이더마다 편차가 크다.
또 OAuth를 쓰려면 각 서비스에 본인 명의 OAuth 앱을 직접 등록해야 한다. 이건 자체 호스팅의 숙명이다. README도 "OAuth 승인 대기 때문에 막힌 팀"에게는 호스팅 옵션(OOMOL)을 권하는데, 이 지점이 오픈소스 버전의 실질적 진입 장벽이다.
| 영역 | 선택 | 왜 이걸 골랐는지 |
|---|---|---|
| 런타임 | Node.js 22+ / Cloudflare Workers | Docker 이미지는 node:24-alpine. Workers도 타깃이라 Node 전용 API 사용이 코드 전반에서 격리돼 있다. |
| 언어 | TypeScript 7 | 빌드 산출물 없이 node src/server/index.ts로 .ts를 직접 실행한다(Node의 네이티브 TS 실행). 번들러 단계가 사라져서 배포가 단순해진다. |
| HTTP 프레임워크 | Hono 4 | Web 표준 Request/Response 기반이라 Node와 Workers에서 같은 라우터 코드가 돈다. 이중 런타임 지원의 핵심 전제. |
| 스키마·검증 | zod 4 + @cfworker/json-schema | zod는 내부 타입 검증, @cfworker/json-schema는 액션 입출력의 JSON Schema 검증. Workers에서도 도는 순수 JS 구현이 필요해서 후자를 별도로 쓴다. |
| 에이전트 프로토콜 | @modelcontextprotocol/sdk 1.29 | MCP 서버 구현. stateless POST JSON-RPC만 지원(SSE 스트림 유지 안 함) — 서버리스 친화적 선택. |
| 액션 검색 | minisearch 7 | 액션 1만 개 중에서 search_actions로 찾아내는 전문 검색. 외부 검색 엔진 없이 인메모리로 해결. |
| 인증·토큰 | jose 6 | JWT 액세스 토큰 검증(JWKS URI 기반). 런타임 토큰과 공존. |
| 로깅 | pino 10 | 구조화 JSON 로그. 실행 로그는 별도로 DB에 적재된다. |
| 저장소 | SQLite / Cloudflare D1 | 마이그레이션 SQL 6개(migrations/0001~0006)를 양쪽이 공유한다. |
| 파일 전송 | R2 / KV / 로컬 | 액션이 파일을 주고받을 때 쓰는 transit file 저장소. 3가지 백엔드 구현이 같은 인터페이스를 만족. |
| 메일 | imapflow · nodemailer · mailparser | Gmail 외 일반 IMAP/SMTP 계정도 프로바이더로 붙일 수 있게 하는 전용 모듈(src/mail/). |
| 린트·포맷 | oxlint 1.71 · oxfmt | Rust로 작성된 초고속 툴체인. 파일 4,000개짜리 저장소에서 ESLint는 현실적으로 느리다 — 규모가 도구 선택을 강제한 사례. |
| 테스트 | vitest 4 | *.test.ts를 소스 옆에 두는 co-location 방식. 58개. |
| 영역 | 선택 | 메모 |
|---|---|---|
| UI 라이브러리 | React 19 | 최신 메이저. 별도 상태 관리 라이브러리 없음. |
| 빌드 | Vite 8 | 개발 서버는 5173, API는 3000으로 프록시. |
| 스타일 | Tailwind CSS 4 (+ @tailwindcss/vite) | CSS-first 설정을 쓰는 v4 방식. |
| 컴포넌트 | Radix UI 11종 + CVA + tailwind-merge | 사실상 shadcn/ui 구성. web/src/components/ui/에 배치. |
| 라우팅 | react-router 7 | 콘솔이 SPA로 동작. |
| 차트 | recharts 3 | Overview 페이지의 호출 추이 그래프. |
| 상태·i18n | @embra/reactivity · @embra/i18n | OOMOL이 만든 자체 경량 라이브러리. web/src/locales/에 다국어 문자열. |
| 아이콘 | lucide-react + @iconify-json/logos | 후자는 1,000개 프로바이더의 브랜드 로고 표시용. |
| 경로 | 구성 | 특징 |
|---|---|---|
| Docker | 멀티스테이지 node:24-alpine | build 스테이지에서 generate:catalog + 웹 빌드 → 런타임 스테이지는 dev 의존성 제외. HEALTHCHECK로 30초마다 자체 점검. |
| Fly.io | fly.toml + 영속 볼륨 | Node Docker 런타임 + 볼륨 위 SQLite. TLS·헬스체크 포함. |
| Cloudflare | Workers + D1 + R2 + Static Assets | wrangler.example.jsonc를 복사해 로컬 설정 생성 → npm run deploy:cloudflare. 콘솔은 Static Assets로 서빙. |
| CI | GitHub Actions | .github/workflows/. GHCR로 ghcr.io/oomol-lab/open-connector:latest 배포. |
"Web 표준으로만 짜서 Node든 Workers든 어디든 던져 넣을 수 있게 한다"가 이 저장소의 스택 선택 전체를 관통하는 원칙이다. Hono를 고른 것도, JSON Schema 검증기를 순수 JS 구현으로 고른 것도, 암호화 모듈을 두 벌 만든 것도 전부 같은 이유에서 나온 결정이다.
그림에서 가장 중요한 선은 아래쪽 신뢰 경계다. 위쪽(에이전트·앱)은 자격증명을 한 번도 만지지 않는다. 아래쪽(외부 API)으로 나가는 순간에만 SecretCodec이 복호화한 토큰이 헤더에 실린다. 그리고 그 나가는 길목에 guardedFetch가 서 있다.
프로바이더 4,040개 TS 파일을 시작 시점에 전부 로드하면 Node에서도 느리고, Cloudflare Workers에서는 번들 크기 제한과 CPU 시간 제한에 그냥 걸려 죽는다. 그런데 카탈로그(=목록)는 시작 즉시 필요하다.
목록은 빌드 시점에 scripts/generate-catalog.ts가 정적 JSON(catalog/apps/*.json)으로 미리 뽑아 둔다. 런타임은 이 JSON만 읽는다 — TS 모듈을 import하지 않는다.
실행기는 ProviderLoader가 액션이 실제로 호출될 때 그 서비스 하나의 모듈만 동적 import한다.
// src/providers/provider-loader.ts (주석 발췌 — 의도가 명시돼 있다)
/**
* Loads provider executor modules only when an action is executed.
*
* Provider definitions are intentionally not exposed here. Runtime catalog
* reads should use generated `catalog/apps/*.json` instead of importing
* hundreds of provider definition modules at startup.
*/
export interface IProviderLoader {
loadActionExecutor(service, actionId, providerDisplayName?): Promise<ActionExecutor | undefined>;
loadProxyExecutor(service, providerDisplayName?): Promise<ProviderProxyExecutor | undefined>;
loadCredentialValidators(service): Promise<CredentialValidators | undefined>;
}
도서관 카드 목록함과 같다. 어떤 책이 있는지 알려면 서가 전체를 뒤질 필요 없이 색인 카드만 보면 된다(=생성된 catalog JSON). 실제로 읽을 책이 정해지면 그때 그 한 권만 서가에서 꺼내 온다(=동적 import).
도서관 전체를 매일 아침 책상 위에 올려두는 짓을 안 하는 것 — 이게 1,097개 프로바이더를 감당하는 유일한 방법이다.
guardedFetch이 저장소에서 가장 배울 게 많은 파일이다. 소스 14.5KB, 테스트 18KB — 테스트가 구현보다 크다는 것 자체가 이 코드의 위험도를 말해준다.
169.254.169.254)를 때리게 만들면 서버의 클라우드 자격증명이 통째로 유출된다.
왜 이 게이트웨이가 특히 위험한가? URL의 일부가 사용자 입력에서 온다. 프로바이더 중에는 API 베이스 URL을 사용자가 직접 설정하는 것들이 있고(예: clickhouse, dataforseo), 에이전트가 만든 파라미터가 경로에 들어간다. 즉 "LLM이 지어낸 문자열이 서버의 아웃바운드 요청 주소가 될 수 있는" 구조다.
| 계층 | 무엇을 하는가 | 왜 이것만으론 부족한가 |
|---|---|---|
| 1. URL 검사 | assertPublicHttpUrl — 스킴 확인, 사설/예약/루프백/링크로컬 IP 리터럴 차단 | 호스트명이 evil.com인데 DNS가 127.0.0.1을 반환하면 통과해 버린다(DNS rebinding). |
| 2. DNS 재검증 | 요청 직전 실제 해석된 주소를 isBlockedIpAddress로 다시 검사 | 비용이 든다. 그래서 호스트가 코드에 고정된 경우 skipDnsValidation으로 끌 수 있게 했다. |
| 3. 리다이렉트 추적 | 최대 20홉까지 Location 헤더를 직접 검사하며 따라간다 | 플랫폼 기본 redirect:"follow"에 맡기면 리다이렉트 목적지를 검사할 기회가 없다. |
세 번째 계층에서 나오는 디테일이 특히 좋다. 교차 출처(cross-origin) 리다이렉트가 발생하면 인증 헤더를 전부 떼어낸다.
// src/core/guarded-fetch.ts — 명시적 allowlist로 관리한다
const crossOriginCredentialHeaders = new Set([
"authorization", "proxy-authorization", "cookie",
"api-key", "apikey", "x-api-key", "x-apikey",
"api-token", "x-api-token",
"auth-token", "x-auth-token", "x-auth-key",
"access-token", "x-access-token", "app-key",
// ...
]);
주석에 이유가 적혀 있다 — x-*token* 같은 정규식으로 뭉뚱그리면 idempotency-key나 x-correlation-id처럼 인증과 무관한데 이름만 비슷한 헤더까지 잘려나간다. 그래서 "새 프로바이더가 새로운 인증 헤더를 쓰면 여기 추가하라"는 유지보수 부담을 감수하고 allowlist를 골랐다.
보안 코드에서 "편한 정규식" 대신 "귀찮은 명시 목록"을 고르는 판단은 그대로 배워둘 만하다. 오탐(false positive)이 조용한 버그를 만드는 영역에서는 명시성이 항상 이긴다.
프로바이더 1,097개를 일관된 모양으로 유지하려면 보일러플레이트를 줄여야 한다. defineProviderAction이 그 역할을 한다.
// src/core/provider-definition.ts
export function defineProviderAction<TName extends string>(
service: string,
input: DefineProviderActionInput<TName>,
): ProviderActionDefinition<TName> {
return {
id: `${service}.${input.name}`, // github + get_issue → "github.get_issue"
service,
name: input.name,
description: input.description,
requiredScopes: input.requiredScopes ?? [],
providerPermissions: input.providerPermissions ?? [],
inputSchema: input.inputSchema,
outputSchema: input.outputSchema,
followUpActions: input.followUpActions,
asyncLifecycle: input.asyncLifecycle,
};
}
주목할 필드가 둘 있다. followUpActions는 "이 액션 다음에 보통 뭘 하게 되는지"를 에이전트에게 힌트로 준다 — 예를 들어 이슈를 만들면 다음은 라벨 붙이기. asyncLifecycle은 오래 걸리는 작업(영상 인코딩 등)의 폴링 계약을 표현한다. 둘 다 "에이전트가 스스로 다음 수를 두게" 돕는 메타데이터다.
프로바이더 정의 자체는 놀랍도록 짧다. GitHub 전체가 이 정도다:
// src/providers/github/definition.ts (전문에 가깝게)
export const provider: ProviderDefinition = {
service: "github",
displayName: "GitHub",
categories: ["Developer Tools"],
authTypes: ["oauth2", "api_key"],
auth: [
{ type: "oauth2",
authorizationUrl: "https://github.com/login/oauth/authorize",
tokenUrl: "https://github.com/login/oauth/access_token",
scopes: githubOAuthScopes,
tokenEndpointAuthMethod: "client_secret_post" },
{ type: "api_key",
label: "Personal access token",
placeholder: "github_pat_...",
description: "...Fine-grained tokens are recommended." },
],
homepageUrl: "https://github.com",
actions: githubActions,
};
파일 분할 규칙도 일관적이다 — definition.ts(메타), actions.ts(스키마), executors.ts(자격증명 배선), runtime-*.ts(도메인별 실제 호출), scopes.ts(스코프 상수). GitHub은 runtime-issue.ts, runtime-pull-request.ts, runtime-release.ts, runtime-search.ts, runtime-activity.ts, runtime-repository.ts로 도메인을 쪼갰다.
// src/core/action-policy.ts
evaluate(action: ActionDefinition): ActionPolicyDecision {
// 1) 차단 목록이 먼저 — 항상 이긴다
if (this.blocked.some((m) => m(action.id)))
return { allowed: false, code: "action_blocked", ... };
// 2) 허용 목록이 비어있지 않으면, 거기 없는 건 전부 거부
if (this.allowed.length > 0 && !this.allowed.some((m) => m(action.id)))
return { allowed: false, code: "action_not_allowed", ... };
return { allowed: true };
}
규칙 세 가지가 명확하다. ①차단이 허용을 이긴다. ②허용 목록이 비어 있으면 "제한 없음"이다(빈 목록 = 전부 거부가 아니다). ③액션 정책과 프록시 정책은 완전히 독립적이다 — 소스 주석이 "Neither pair reads the other"라고 못박아 뒀다.
패턴 매칭은 딱 세 가지만 지원한다 — *(전부), github.*(서비스 단위), 정확히 일치. 정규식을 허용하지 않은 것도 판단이다. 보안 정책 문법이 표현력을 얻으면 그만큼 "의도와 다르게 매칭되는" 사고가 늘어난다.
# docker-compose.yml 환경변수로 바로 설정된다
OOMOL_CONNECT_ALLOWED_ACTIONS=github.*,gmail.get_message
OOMOL_CONNECT_BLOCKED_ACTIONS=github.delete_repository
읽기 액션만 allowlist에 넣고 시작하라. github.delete_repository 같은 파괴적 액션은 allowlist에서 빠뜨리는 것에 의존하지 말고 blocklist에 명시하는 게 낫다. 나중에 allowlist를 github.*로 넓혀도 차단이 유지되기 때문이다.
에이전트는 타임아웃이 나면 그냥 다시 시도한다. 그런데 "이슈 생성"이나 "메일 발송"에서 재시도는 중복을 만든다. OpenConnector는 HTTP 표준 방식으로 해결한다:
IDEMPOTENCY_KEY=$(openssl rand -hex 16)
curl -s -X POST http://localhost:3000/v1/actions/github.create_issue \
-H "Idempotency-Key: $IDEMPOTENCY_KEY" \
-H 'content-type: application/json' \
-d '{"input":{"owner":"me","repo":"x","title":"버그"}}'
키가 없으면 매번 정상 실행된다(기본값이 안전한 쪽이 아니라 예측 가능한 쪽이다). 키는 공백 제거 후 비어있지 않아야 하고 255 UTF-8 바이트 이하여야 하며, 위반 시 400 invalid_input. 저장은 migrations/0003_action_idempotency.sql의 전용 테이블에서 담당한다.
에이전트의 컨텍스트 윈도우가 툴 정의만으로 터진다. 툴이 많아질수록 모델이 잘못된 툴을 고를 확률도 올라간다. 이건 MCP를 쓰는 모든 대규모 통합이 부딪히는 벽이다.
노출하는 툴은 넷뿐이다 — list_apps(앱 목록), search_actions(액션 검색), get_action_guide(한 액션의 사용법 문서), execute_action(실행).
에이전트의 작업 흐름은 이렇게 바뀐다: "깃허브 이슈 만들고 싶다" → search_actions("create issue") → get_action_guide("github.create_issue")로 스키마 확인 → execute_action. 툴 정의 대신 검색 능력을 준 것이다.
GET /api/actions/:actionId/agent.md가 액션별 마크다운 가이드를 뽑아주는 것도 같은 철학 — 에이전트가 읽을 문서를 런타임이 생성한다.
1일차: src/core/types.ts → 이 프로젝트의 모든 개념(액션·프로바이더·자격증명·실행결과)이 타입으로 정의돼 있다. 460줄짜리 계약서를 먼저 읽는 셈.
2일차: src/providers/hackernews/(인증 없음, 가장 작음) → src/providers/github/(OAuth+API키 둘 다) 순서로. 프로바이더 하나의 전체 모양이 손에 잡힌다.
3일차: src/core/guarded-fetch.ts와 그 테스트. 여기가 이 저장소에서 가장 밀도 높은 코드다.
AI 에이전트가 이 저장소에 프로바이더를 추가할 때 따라야 할 절차서다. 사람용 CONTRIBUTING.md와 별개로 "에이전트용 기여 가이드"를 두는 것이 2026년의 새 관행이 되고 있다.
내용이 흥미롭다. "Pattern Picker" 섹션에서 "이 상황이면 이 프로바이더를 참고하라"를 케이스별로 지정한다 — 무인증이면 hackernews/nasa, API키면 avoma/attention, OAuth+API키 둘 다면 github, 커스텀 자격증명이면 clickhouse. 그러면서 "기계적으로 복사하지 말고 현재 헬퍼 API를 발견하는 용도로 쓰라"고 못박는다.
"Public Boundary" 섹션은 더 실용적이다 — 서드파티 로고·API 스펙·문서 발췌를 배포 권한 없이 복사하지 말 것. 1,000개 브랜드를 다루는 프로젝트의 법무 리스크를 문서로 방어하고 있다.
저장소 루트에 있는 에이전트용 전역 규칙. 스타일과 검증 절차를 담는다. npm run fix-check(린트 수정 + 타입체크)가 사실상 기여의 관문이다.
대부분의 개발자는 들어오는 요청만 검증한다. 이 저장소는 나가는 요청을 검증하는 법을 통째로 보여준다. 배울 항목:
evil.com이 302로 리다이렉트해서 당신 서버가 자기한테 API 키를 보내게 만들 수 있다.skipDnsValidation은 "호스트가 코드에 고정된 경우에만 안전하다"는 조건을 주석에 명시한 채 기본 활성화돼 있다. 보안과 성능의 트레이드오프를 숨기지 않고 문서화했다.실습 아이디어: 당신의 프로젝트에서 사용자 입력이 URL의 일부가 되는 지점을 전부 찾아보라. 웹훅 등록, 이미지 URL 프록시, OAuth 리디렉트 URI — 대부분의 앱에 최소 하나는 있다.
1,097개 프로바이더가 강제한 설계지만, 원리는 훨씬 작은 규모에도 적용된다.
| 단계 | 산출물 | 비용을 언제 치르는가 |
|---|---|---|
| 빌드 타임 | catalog/apps/*.json | CI에서 한 번. 런타임은 비용 0. |
| 부팅 | 카탈로그 JSON 로드 + minisearch 인덱스 | 메모리만. 코드 import 없음. |
| 액션 실행 시점 | 해당 프로바이더 모듈 1개만 동적 import | 첫 호출에만. 이후 캐시. |
실습 아이디어: 앱 부팅 시간이 느리다면 "부팅에 필요한 것"과 "쓸 때 필요한 것"을 나눠보라. 라우트 핸들러, 관리자 페이지, 리포트 생성기 같은 것들은 대개 후자다.
같은 코드가 Node와 Workers에서 도는 건 마법이 아니라 인터페이스를 좁게 뽑은 결과다.
// src/server/secrets/secret-codec-core.ts — 인터페이스는 딱 3개 멤버
export interface ISecretCodec {
readonly encrypted: boolean;
encode(plaintext: string): Promise<string>;
decode(stored: string): Promise<string>;
}
// 구현체 3개가 이걸 만족한다:
// PlainTextSecretCodec (암호화 키 미설정 시 — 아무것도 안 함)
// AesGcmSecretCodec (Node: node:crypto의 scryptSync + aes-256-gcm)
// WorkerSecretCodec (Workers: WebCrypto)
같은 패턴이 저장소(runtime-store.ts ← SQLite/D1), 파일 전송(transit-file-store.ts ← R2/KV/로컬)에도 반복된다. "인터페이스는 3~5개 메서드를 넘기지 않는다"는 규율이 이식성을 만든다.
scryptSync(passphrase, salt, 32)로 사용자 패스프레이즈를 32바이트 키로 늘리고(키 파생), aes-256-gcm으로 암호화한다. GCM은 암호화와 무결성 검증을 동시에 해주는 모드 — 저장된 값이 변조되면 복호화가 실패한다. 저장 포맷은 enc:v1:<iv>.<tag>.<ciphertext>로, 버전 접두사를 넣어 나중에 알고리즘을 바꿀 여지를 남겼다.MCP나 함수 호출로 도구를 노출하는 모든 개발자에게 해당한다. 정리하면:
agent.md 패턴).followUpActions처럼 "다음에 뭘 할 수 있는지"를 응답에 실어주면 에이전트가 스스로 계획을 세운다.{success, message, data, meta})에 담으면 에이전트의 파싱 실패가 줄어든다.마이그레이션 파일 이름만 봐도 이 프로젝트가 무엇을 중요하게 여겼는지 보인다:
| 마이그레이션 | 추가한 것 | 읽히는 의도 |
|---|---|---|
| 0001_runtime | connections, oauth_*, runtime_tokens, runs | 기본 골격 |
| 0002_run_service | 실행 서비스 분리 | 실행 기록을 1급 개념으로 |
| 0003_action_idempotency | 멱등성 키 저장 | 재시도 안전성 |
| 0004_action_run_audit | 감사 로그 | "누가 언제 뭘 실행했나" |
| 0005_run_retention | 로그 보존 기간 | 무한 증식 방지 + 개인정보 관리 |
| 0006_connection_identity | 연결 신원 | 다중 사용자·다중 계정 지원 |
그리고 run-log-summary.ts의 summarizeForRunLog / safeRunLogError — 로그에 남기기 전에 민감값을 마스킹하고 크기를 줄이는 전용 모듈이다. 로깅을 나중에 붙이면 절대 나오지 않는 구조다.
migrations/0001의 runtime_tokens 테이블을 보면 컬럼이 token_hash text not null unique다. 원본 토큰은 어디에도 없다. revoked_at과 last_used_at을 함께 두어 폐기와 사용 추적까지 스키마 레벨에서 지원한다. 비밀번호와 똑같이 다루는 것 — 당연해 보이지만 실제로 빼먹는 프로젝트가 아주 많다.
| 경로 | 요구사항 | 비고 |
|---|---|---|
| Docker (권장) | Docker + Compose, 포트 3000, 디스크 여유 1GB 내외 | docker compose up. GHCR 이미지를 받아온다. GPU 불필요. |
| Node 로컬 개발 | Node.js 22+ (이미지는 24), npm | npm i → npm run dev. 콘솔은 5173, API는 3000. |
| Cloudflare | Workers + D1 + R2 + Static Assets, wrangler | 무료 티어로도 가능. wrangler.example.jsonc 복사 후 D1 마이그레이션 적용. |
| Fly.io | Fly 계정 + 영속 볼륨 | SQLite를 볼륨에 두는 구성. fly.toml 포함. |
| 메모리 | 수백 MB 수준 | LLM을 돌리는 게 아니라 HTTP 게이트웨이다. 무거운 건 카탈로그 인덱스 정도. |
# 저장 위치 (Docker는 /app/data 볼륨)
OOMOL_CONNECT_DATA_DIR=/app/data
# ★ 자격증명 암호화 키 — 설정 안 하면 평문 저장된다(PlainTextSecretCodec)
OOMOL_CONNECT_ENCRYPTION_KEY=<충분히 긴 랜덤 문자열>
# 관리 API·콘솔 보호
OOMOL_CONNECT_ADMIN_TOKEN=...
# /v1/*, /mcp 호출자 인증
OOMOL_CONNECT_RUNTIME_TOKEN=...
# OAuth 콜백 주소 계산에 쓰임
OOMOL_CONNECT_ORIGIN=http://localhost:3000
# JWT 검증 (셋을 함께 설정할 때만 활성 · Node 전용)
OOMOL_CONNECT_JWKS_URI=...
OOMOL_CONNECT_JWT_ISSUER=...
OOMOL_CONNECT_JWT_AUDIENCE=...
# 정책
OOMOL_CONNECT_ALLOWED_ACTIONS=...
OOMOL_CONNECT_BLOCKED_ACTIONS=...
OOMOL_CONNECT_ALLOWED_PROXIES=...
OOMOL_CONNECT_BLOCKED_PROXIES=...
createSecretCodec(encryptionKey)는 키가 undefined면 에러를 던지지 않고 PlainTextSecretCodec을 돌려준다. 로컬 실험에는 편하지만, 이 상태로 서버에 올리면 DB 파일 하나가 유출되는 순간 연결된 모든 SaaS 토큰이 함께 나간다.
또 하나 — 관리 엔드포인트(/api/*)와 런타임 엔드포인트(/v1/*, /mcp)는 인증 토큰이 다르다. 둘 다 설정하지 않으면 열린 상태로 뜬다. 외부에 노출할 거라면 배포 전에 반드시 확인하라.
docker compose up으로 띄우고 hackernews.get_top_stories를 호출한다. 그 다음 GET /v1/actions로 전체 목록을, GET /openapi.json으로 생성된 스펙을, GET /api/actions/hackernews.get_top_stories/agent.md로 에이전트용 가이드를 각각 열어본다.
확인할 것: 같은 액션 하나가 세 가지 형식(런타임 JSON · OpenAPI · 마크다운)으로 어떻게 다르게 표현되는가. 왜 셋 다 필요한지 생각해보라.
fine-grained PAT를 발급해 연결하고, github.get_current_user를 실행한다. 그 다음 환경변수로 정책을 걸어본다:
OOMOL_CONNECT_ALLOWED_ACTIONS=github.get_*,github.list_*
OOMOL_CONNECT_BLOCKED_ACTIONS=github.delete_repository
확인할 것: github.create_issue가 action_not_allowed로 거부되는가? 그 다음 allowlist를 github.*로 넓혔을 때 delete_repository는 여전히 막히는가(=blocklist 우선 규칙 검증)? github.get_*는 매칭될까? — 힌트: createMatcher는 .*로 끝나는 패턴만 접두사 매칭한다. 이 함정을 직접 확인하는 게 이 과제의 핵심이다.
http://localhost:3000/mcp를 MCP 지원 에이전트 호스트에 등록하고, "내 GitHub에서 최근 이슈 3개 요약해줘" 같은 자연어 요청을 던진다.
관찰할 것: 에이전트가 search_actions → get_action_guide → execute_action 순서를 스스로 밟는가? 어느 단계에서 헤매는가? Web Console의 최근 실행 목록으로 실제 호출 흐름을 역추적해보라. "툴 4개 + 검색"이라는 설계가 실제로 작동하는지를 눈으로 확인하는 과제다.
npm run generate:provider로 스캐폴딩을 만들고, 좋아하는 공개 API(예: 공공데이터포털, OpenWeather)를 프로바이더로 붙인다. .codex/skills/add-provider/SKILL.md의 Pattern Picker를 따라 참고 대상을 고를 것 — 무인증이면 hackernews, API 키면 avoma.
순서: ① definition.ts에 인증 방식 선언 → ② actions.ts에 s.* 빌더로 입출력 스키마 작성 → ③ executors.ts+runtime.ts에 실제 호출(반드시 guardedFetch 경유) → ④ npm run generate:catalog → ⑤ npm run fix-check로 검증.
배우는 것: 남의 API를 스키마로 번역하는 훈련. 이건 MCP 서버를 직접 만들 때 그대로 쓰이는 근육이다.
src/core/guarded-fetch.test.ts(18KB)를 정독한 뒤, 테스트가 아직 커버하지 않은 우회 시나리오를 찾아 실패하는 테스트를 먼저 작성해본다.
탐색 방향 예시: IPv6 매핑 주소(::ffff:127.0.0.1), 8진수·16진수 IP 표기(0177.0.0.1), DNS TTL을 이용한 rebinding 타이밍, 리다이렉트 21홉째, 유니코드 도메인(punycode) 혼동.
주의: 이건 자기 로컬 환경에서 자기 코드를 대상으로 하는 연습이다. 실제 서비스 대상 테스트는 하지 말 것. 결과가 진짜 취약점이면 SECURITY.md의 절차대로 비공개 제보하는 게 올바른 순서다.
| 주차 | 주제 | 구체적 활동 |
|---|---|---|
| 1주차 | 타입으로 도메인 읽기 | src/core/types.ts 정독. ActionDefinition, ProviderDefinition, ResolvedCredential, ExecutionResult의 관계를 직접 다이어그램으로 그려본다. TypeScript의 판별 유니온(discriminated union)이 AuthType별 분기에서 어떻게 쓰이는지 관찰. |
| 2주차 | Hono와 Web 표준 서버 | Hono 공식 문서 + connect-app.ts/runtime-api.ts. "Node 전용 API를 안 쓰면 어디서든 돈다"를 직접 검증: 작은 Hono 앱을 만들어 Node와 wrangler dev 양쪽에서 띄워본다. |
| 3주차 | SSRF와 아웃바운드 보안 | OWASP SSRF 치트시트 → guarded-fetch.ts/request.ts 정독 → 과제 5. IP 주소 표기법(사설 대역, 링크로컬 169.254.x, 클라우드 메타데이터)을 정리해둘 것. |
| 4주차 | OAuth 2.0 실전 | docs/credentials.md + oauth-completion-page.ts. 직접 GitHub OAuth 앱을 만들어 authorization code 흐름을 끝까지 태워보고, 토큰 갱신·스코프 부족 에러를 일부러 재현해본다. |
| 5주차 | MCP와 에이전트 도구 설계 | MCP 명세 + @modelcontextprotocol/sdk. 이 저장소의 "툴 4개" 전략과, 툴을 전부 나열하는 일반적 MCP 서버를 둘 다 만들어서 비교. 어느 쪽이 에이전트 성공률이 높은지 직접 측정. |
| 6주차 | 엣지 배포와 저장소 추상화 | Cloudflare Workers + D1 + R2 튜토리얼 → storage/와 files/의 인터페이스 대칭 구조 분석 → 이 저장소를 실제로 Workers에 배포. 서버리스에서 무엇이 안 되는지(SSE 스트림, Node crypto, 긴 실행) 몸으로 익히기. |
OpenConnector를 쓰는 법을 배우는 게 아니다. "신뢰할 수 없는 호출자와 신뢰할 수 없는 목적지 사이에 안전한 중개자를 세우는 법"을 배우는 것이다. 이 문제는 API 게이트웨이, 웹훅 릴레이, 결제 프록시, 사내 통합 서버 — 에이전트와 아무 상관 없는 곳에서 계속 다시 나타난다.
| 용어 | 뜻 |
|---|---|
| Provider | 연결 대상 외부 서비스 하나(github, gmail, notion…). service 문자열이 식별자. |
| Action | 프로바이더가 제공하는 개별 동작. ID는 {service}.{name} 형식(github.create_issue). |
| Catalog | 프로바이더·액션의 전체 목록. 빌드 시점에 정적 JSON으로 생성된다. |
| Connection | 사용자의 특정 계정 자격증명 한 벌. (service, connection_name)이 기본키 — 같은 서비스의 여러 계정을 이름으로 구분한다. |
| Alias | 어떤 연결을 쓸지 지정하는 이름. 헤더 x-oo-connector-alias: work 또는 쿼리 ?alias=work. |
| Executor | 액션을 실제로 수행하는 함수. 지연 로딩되며 ActionExecutor 타입. |
| Runtime token | /v1/*·/mcp 호출자 인증용 토큰. DB에는 해시만 저장된다. |
| Admin token | /api/*·콘솔·/docs용 관리자 인증. 런타임 토큰과 별개. |
| Action policy | 액션 실행 허용/차단 규칙. *, service.*, 정확일치 세 가지 패턴. |
| Proxy | 미리 정의된 액션이 아니라 프로바이더 API를 원시 형태로 중계하는 경로. 별도 정책(ALLOWED_PROXIES)으로 관리. |
| Transit file | 액션이 주고받는 임시 파일. R2·KV·로컬 중 하나에 저장되고 수명이 짧다. |
| Run log | 액션 실행 기록. 민감값은 run-log-summary가 마스킹한 뒤 적재. |
| Idempotency key | 재시도가 중복 실행이 되지 않게 하는 요청 식별자. 255바이트 이하. |
| guardedFetch | SSRF 방어가 내장된 fetch 래퍼. 모든 외부 호출이 여기를 지난다. |
| SSRF | 서버를 속여 내부 주소로 요청을 보내게 하는 공격. 클라우드 메타데이터 탈취가 대표 시나리오. |
| MCP | 에이전트가 외부 도구에 접근하는 표준 프로토콜. 여기서는 POST /mcp 하나로 노출. |
| locallyExecutable | 액션이 로컬 실행기를 가지는지 나타내는 플래그. false면 카탈로그에만 존재. |
| followUpActions | "이 다음에 보통 하는 액션" 힌트. 에이전트의 다단계 계획을 돕는다. |
| asyncLifecycle | 오래 걸리는 액션의 진행 확인·폴링 계약. |
| D1 / R2 | Cloudflare의 SQLite 호환 DB / 오브젝트 스토리지. Workers 배포 시 각각 SQLite·로컬 파일을 대체. |