내 에이전트는 드롭다운을 볼 수는 있었지만, 아무 것도 선택하지 못했다.
출처: Dev.to
에이전트에게 리스트가 있었습니다. 아이템을 하나 골라 달라고 요청했지만 거절했습니다.
요소를 찾을 수 없다고 나오고, 새로 고침해도 같은 결과였습니다.
그래서 DevTools를 열어 다음을 붙여넣었습니다.
document.querySelector('select[name="status"]')
// null
null. 명백히 드롭다운이 존재하는 페이지였는데도 말이죠. 저는 드롭다운을 눈으로 확인하고, 클릭도 할 수 있었고, 마우스로 펼칠 수도 있었습니다. 그런데 document.querySelector는 존재하지 않는다고 고집했습니다.
이 이야기는 서로 대화하지 못하는 세 겹의 DOM과 Safari MCP 2.11.3 버전에서 safari_select_option이 그 사이를 잇기 위해 배운 교훈에 관한 것입니다.
페이지는 고객 포털에 삽입된 Salesforce/Lightning 지원 양식이었습니다. 포털이 부모 문서이고, 양식은 다른 (하지만 동일 출처) 호스트에서 제공되는 <iframe> 안에 있었습니다. 그 <iframe> 안에서 Lightning은 각각 자체 섀도우 루트를 가진 커스텀 엘리먼트들의 그래프를 조합해 UI를 구성합니다.
따라서 개발자가 Lightning에서 “Status” 드롭다운을 작성하면 실제 <select> 엘리먼트는 다음과 같은 구조 안에 렌더링됩니다.
top document
└── <iframe>
└── <lightning-base-combobox>
└── #shadow-root
└── <div>
└── #shadow-root
└── <select> ← 에이전트가 필요로 했던 엘리먼트
document.querySelector('select[name="status"]')를 최상위 문서에서 호출하면 위 구조를 전혀 탐색하지 못합니다. <iframe>도, 섀도우 루트도 전혀 보지 못하니, 그 <select>는 존재하지 않는 셈이죠.
이 문제를 눈치채기 어렵게 만든 두 가지 요인은 다음과 같습니다.
- safari_snapshot이 이를 포착했습니다. 에이전트가 페이지의 접근성 스냅샷을 찍었을 때, 드롭다운은 레퍼런스, 라벨,
aria-expanded상태, 옵션 등 모든 정보를 가지고 있었습니다. 빠진 것이 없었습니다. - safari_click은 정상적으로 동작했습니다. 저는 몇 주째 깊은 DOM 요소들을 클릭해 왔고, 그때는 전혀 생각하지 않았습니다. 같은 양식을 여는 버튼 자체가 다른 섀도우 루트 안에 있었지만 클릭은 문제없이 해결했습니다.
그래서 에이전트는 스스로에게 계속 물었습니다. “방금 이 폼을 클릭했는데, 드롭다운이 눈앞에 있는데 왜 아무것도 선택하지 못하지?”
답은 부끄럽게도 click과 select_option이 서로 다른 찾기 방식을 사용하고 있었기 때문이었습니다.
Safari MCP는 페이지 안에서 두 가지 엘리먼트 탐색 경로를 제공합니다.
mcpFindRef(ref)—safari_snapshot에서 얻은 레퍼런스를 받아, 문서, 모든 동일 출처<iframe>, 그리고 접근 가능한 모든 섀도우 루트를 순회해 해당 레퍼런스가 가리키는 엘리먼트를 찾습니다.mcpQuerySelectorDeep(selector)— CSS 선택자를 받아 위와 같은 깊은 순회를 수행하지만, 레퍼런스 대신 선택자로 매칭합니다.
click은 오래전부터 이 두 함수를 모두 활용해 왔습니다. 그래서 Lightning 폼, React 컴포넌트 라이브러리, 포털에 렌더링되는 모달 대화상자에서도 “클릭만 하면” 동작했습니다.
반면 safari_select_option은 여전히 다음과 같이 단순히 한 줄만 실행하고 있었습니다.
var el = document.querySelector(sel);
최상위 프레임만 살피고, <iframe>도 섀도우 루트도 전혀 고려하지 않았습니다. 일반적인 페이지의 일반적인 <select>라면 이 한 줄이면 충분했으며, 도구가 처음 만들어졌을 때부터 그대로였기에 아무도 건드리지 않았던 것이죠.
하지만 어느 날 사용자가 실제 Salesforce 포털에 Safari MCP를 투입하면서, 이 한 줄이 모든 호출에서 틀렸다는 것이 드러났습니다.
v2.11.3 패치는 작지만 핵심을 바꿉니다. safari_select_option에게 safari_click이 이미 알고 있던 방식을 가르쳐 줍니다.
let finder;
if (ref) {
finder = `mcpFindRef('${ref}')`;
} else if (selector) {
finder = `(document.querySelector('${sel}')||mcpQuerySelectorDeep('${sel}'))`;
}
두 가지 경로:
- ref 경로 —
safari_snapshot을 통해 얻은 레퍼런스에 대해 사용합니다.click과 동일한 깊은 탐색기를 통해<iframe>, 섀도우 루트, 모든 중첩 컴포넌트를 해결합니다. - selector 경로 — 먼저 가벼운 최상위 프레임 쿼리를 시도하고(대부분 95% 경우에 정상),
null이 반환되면 깊은 탐색기로 넘어갑니다.
도구의 나머지 부분—React의 제어 입력 bookkeeping을 깨우는 _valueTracker 리셋, 값이 아닌 텍스트로 표시되는 <select>를 위한 value‑then‑text‑then‑substring 매칭, input/change/blur 이벤트 시퀀스—는 그대로 유지됩니다. 깨진 부분은 오직 엘리먼트 조회뿐이었습니다.
전체 v2.11.3 릴리스 노트는 여기에서 확인할 수 있습니다.
솔직히 말하면, 저는 두 개의 찾기 함수를 가지고 있었고, click에서는 하나를, select_option을 구현할 때는 그 존재 자체를 잊어버렸습니다. 수정하는 데 한 시간도 안 걸렸고, 버그는 이미 3개월째 숨어 있었습니다.
제가 이 도구를 만들면서 계속 되새기는 교훈은 DOM 도구는 “행복 경로”를 가져서는 안 된다는 것입니다. 엘리먼트를 찾는 모든 도구는 서로 동일한 방식으로 찾아야 합니다. 페이지는 어느 도구가 다음에 호출될지 알 수 없기 때문이죠. 섀도우 루트 안을 클릭한 뒤 필드를 채우려 하면, 필드 채우기 도구도 클릭이 찾은 바로 그 위치를 찾아야 합니다.
safari_fill, safari_get_element, safari_hover, safari_get_computed_style 등은 모두 v2.x 시리즈 초기에 같은 마이그레이션을 거쳤으며, 버그 리포트 하나씩 해결해 왔습니다. safari_select_option만 마지막으로 감사를 받지 못했는데, v2.11.3이 그 격차를 메웠습니다.
브라우저 자동화 도구—MCP든 아니든—를 만들고 있다면, 오늘 밤 코드베이스에 다음 질문을 던져 보세요: “내 모든 엘리먼트 탐색 경로가 ‘그 엘리먼트’가 무엇인지에 대해 일치하고 있는가?” 일치하지 않을 때, 에이전트가 바로 그 사실을 깨닫게 될 테니까요.
Safari MCP는 MIT 라이선스로 GitHub에서 오픈 소스입니다. macOS에서 AI 에이전트를 위한 네이티브 브라우저 자동화—실제 Safari, 실제 로그인, Chrome 없이.