NOX MCP 서버 개발기 — 읽기 전용으로 시작한 이유와, Claude Code가 먼저 찾아낸 버그Building the NOX MCP Server — Why We Started Read-Only, and the Bug Claude Code Found First

MCP(Model Context Protocol)는 AI 에이전트가 외부 시스템의 기능을 '도구(tool)'로 불러 쓸 수 있게 하는 공개 규약이다. Claude Code 같은 에이전트에 MCP 서버를 하나 등록하면, 에이전트는 그 서버가 내놓은 도구 목록을 읽고 질문에 맞는 도구를 골라 호출한다.

NOX에서는 장비 한 대가 그대로 MCP 서버가 된다. 관리자가 AI 에이전트 전용 키를 발급해 Claude Code나 Claude Desktop에 등록하면, "어젯밤 녹화가 비었던 카메라 있어?" 같은 질문에 에이전트가 카메라 목록을 확인하고, 녹화 구간을 조회하고, 그 시각의 재생 링크까지 만들어 답한다. 장비 상태를 바꾸는 도구는 하나도 없다.

이 글은 기능 소개가 아니라 개발 기록이다. 왜 지금 모양이 되었는지, 무엇을 일부러 하지 않기로 했는지, 그리고 리뷰와 실제 장비 시험에서 무엇이 깨졌는지를 적는다. 본문 내용은 저장소의 설계 문서와 리뷰·QA·시험 보고서, 커밋 기록에 근거한다.

Claude Desktop에서 MCP로 개발 장비의 NOX에 질문하는 모습(8배속). 카메라 상태, 녹화 문제, 한 카메라의 하루 녹화 누락 구간, 10분 전 재생 링크, 오늘의 사람·차량 객체 검색 순서로 물었다. 도구를 처음 부를 때마다 Claude Desktop이 사용 허락을 묻는다.

영상에서 눈여겨볼 장면이 두 개 있다. 녹화에 문제 있는 카메라가 있느냐는 질문에 Claude는 문제 표시가 붙은 카메라는 없다고 답하면서도, 녹화 중인 세 카메라의 기록이 모두 같은 시각에 시작된다는 점을 짚고 그 시각에 무슨 일이 있었는지 되물었다. 마지막 객체 검색은 결과가 0건이었는데, Claude는 0건이라고만 답하지 않고 같은 기간의 이벤트 통계를 함께 조회해 이벤트는 있는데 객체 정보가 들어오지 않는 쪽을 원인으로 짚었다. 아래에 적은 설계 원칙 대부분이 이런 답을 가능하게 하려고 정한 것이다.

출발은 두 번 멈췄다

처음 생각한 것은 MCP 서버가 아니었다. 2026년 4월에는 제품 화면 안에 대화창을 넣어, 장비 안에서 언어 모델과 이야기하는 기능을 설계했다. 문서는 리뷰와 보안 검토까지 갔지만 코드로 이어지지는 않았다. 돌아보면 우리가 만들려던 것은 대화창이 아니라 장비의 정보를 에이전트가 안전하게 읽어 가는 통로였다. 대화창은 사용자가 이미 쓰고 있는 Claude Code나 Cursor가 더 잘 만든다.

7월 말에 방향을 MCP 서버로 바꿔 다시 시작했다. 그리고 그 작업의 코드를 잃었다. 설계 문서를 두던 작업 폴더가 git 추적 대상에서 빠져 있었고, 코드는 커밋되기 전이었다. 남은 것은 빈 디렉터리 몇 개, 개발용 데이터베이스에 만들어진 테이블, 그리고 Go 모듈 캐시에 남은 SDK 버전과 내려받은 시각이었다. 그 흔적을 거꾸로 읽어 설계를 되살린 문서가 지금도 저장소에 있다. 첫 줄은 이렇게 시작한다. "MCP 관련 코드는 리포에 단 한 번도 커밋된 적이 없다."

그 일 뒤로 설계 문서도 전부 git으로 관리한다. 지금 이 글을 쓸 수 있는 것도 그 덕분이다.

세 번째 출발은 10월 초였다. 이번에는 시작부터 범위를 줄였다. 새 인증 체계를 만들지 않고, 이미 있는 외부 API 키와 권한 체계 위에 얇게 얹는다. 7월 설계에 있던 장비-에이전트 페어링, 별도의 에이전트 테이블, 에이전트 전용 접속 모드는 이어받지 않았다.

처음 정한 여섯 가지

1. 읽기 전용으로 시작한다

카메라를 돌리고, 녹화를 켜고 끄고, 설정을 바꾸는 도구는 넣지 않았다. 에이전트가 틀리게 읽으면 답이 틀릴 뿐이지만, 에이전트가 틀리게 쓰면 현장의 녹화가 멈춘다. 읽기만으로도 쓸모가 있는지 먼저 확인하고, 쓰기는 그다음 단계에서 따로 설계하기로 했다.

2. 새 키를 만들지 않고, 키에 '용도'를 하나 더한다

NOX에는 외부 시스템 연동용 API 키가 이미 있다. 여기에 AI 에이전트(MCP)라는 용도를 추가했다. 에이전트 키에 기존 읽기 역할 '대신' 새 역할을 주는 방법도 있었다. 그런데 그러면 키가 살아 있는지 확인하는 기존 검사 세 곳과 권한 정리 작업이 에이전트 키를 폐기된 키로 판단한다. 그래서 기존 읽기 역할은 그대로 두고 에이전트용 역할을 '더하는' 쪽을 택했다. 기존 코드는 손대지 않아도 됐다.

허용할 도구 목록은 키 안에 서명해 넣었다. 요청마다 데이터베이스를 조회하지 않고도 이 키가 무엇을 볼 수 있는지 알 수 있다. 대신 도구 범위를 바꾸려면 키를 다시 발급해야 한다. 급하게 범위를 줄여야 하면 폐기하고 새로 발급하면 즉시 반영된다.

3. MCP 서버는 자기 권한으로 조회하지 않는다

MCP 서버가 내부 서비스에 데이터를 물어볼 때, 자기 서비스 자격이 아니라 호출한 키의 신원을 그대로 실어 보낸다. 카메라 목록이든 이벤트든, 데이터를 가진 서비스가 원래 키와 실제 클라이언트 IP로 권한을 다시 판단한다. MCP 서버 쪽 검사는 빨리 거절하고 기록을 남기기 위한 것이고, 결국 데이터를 가진 쪽이 판단한다.

이 원칙 때문에 경로 하나를 바꿔야 했다. 내부 조회를 장비의 웹 서버를 한 번 더 거쳐 보내는 방법도 있었지만, 그 경로로 들어온 요청은 '장비 앞에 앉은 관리자의 로컬 접속'으로 취급되고 클라이언트 IP도 장비 자신의 주소로 덮인다. 허용 IP를 지정한 키가 무력해지는 구조였다. 그래서 내부 서비스로 직접 보내고, 위임 요청은 항상 원격 접속으로 표시한다. 내부 서비스끼리 쓰는 인증 키는 위임 요청에 절대 싣지 않는다. 그 키가 실리는 순간 호출자의 권한을 넘어서는 통로가 된다.

4. 세션을 두지 않는다

7월 설계는 세션을 유지하는 방식이었다. 10월에는 세션 없이 요청 한 건마다 인증과 권한 확인을 처음부터 다시 하는 방식으로 바꿨다. 앞선 요청이 통과했다는 사실이 다음 요청을 대신 보증하지 않는다. SDK는 당시 최신이던 공식 Go SDK 1.8을 골랐다. MCP 규약 자체도 세션 없는 방식 쪽으로 가고 있었다. 응답은 스트리밍 없이 JSON 한 덩어리로 돌려준다.

5. 도구는 14개에서 10개로 줄였다

7월 설계에는 도구가 14개 있었다. 대시보드, 디스크, 시스템 지표를 따로 보여 주던 도구는 '시스템 상태'와 '저장소 상태' 두 개로 합쳤고, 사람 검색은 다음 단계로 미뤘다. 대신 이미 색인된 녹화에서 자연어로 장면을 찾는 도구를 넣었다.

도구가 많을수록 에이전트가 고를 것도 많아지고, 도구 설명이 차지하는 대화 공간도 커진다. 남긴 10개는 카메라, 이벤트·객체, 녹화·재생, 시스템, AI 다섯 묶음이다. 각 도구 설명에는 앞 단계 도구를 적어 두었다. 예를 들어 카메라 목록 도구에는 "다른 도구에 넘길 camera_id의 출처"라고 적혀 있다. 이 한 줄이 에이전트가 도구를 이어 쓰는 순서를 스스로 찾게 한다.

6. 실패를 빈 결과로 위장하지 않는다

도구 실행 중 생긴 오류는 규약상의 오류가 아니라 도구 결과로 돌려준다. 결과에 오류 표시와 함께 RATE_LIMITED, PERMISSION_DENIED 같은 코드를 담아, 모델이 읽고 스스로 다음 행동을 고칠 수 있게 했다. 설계 문서에 적은 규칙은 짧다. "빈 결과 성공 금지." 권한을 확인할 수 없거나 내부 서비스가 응답하지 않으면 0건이 아니라 오류다. 사람은 0건을 의심하지만, 에이전트는 0건을 그대로 믿고 답을 만든다.

녹화와 라이브를 지키는 상한

에이전트는 사람보다 훨씬 빠르게, 훨씬 많이 묻는다. 그리고 NVR의 첫 번째 일은 녹화다. 에이전트의 질문 때문에 녹화나 라이브 영상이 밀리면 이 기능은 없는 편이 낫다.

범위기본 상한
키별 MCP 요청분당 300건
키별 도구 호출분당 60건, 순간 20건
키별 자연어 장면 검색분당 6건
키별 동시 실행4건
장비 전체 장면 검색 동시 실행2건
장비 전체 내부 조회 동시 실행8건

상한은 설정으로 낮추거나 높일 수 있지만 끌 수는 없다. 조회 기간도 도구마다 31일이나 7일로 제한하고, 한 번에 돌려주는 결과 크기에도 상한을 뒀다.

리뷰에서 걸린 것들

기능이 대략 동작하기 시작한 뒤 코드 리뷰, 인증 리뷰, 화면 리뷰를 따로 돌렸다. 도구 쪽 리뷰의 첫 판정은 '변경 요청'이었다.

테스트는 통과했는데 감사 로그에 질의 원문이 남았다

자연어 질의와 번호판 문자열은 감사 로그에 원문으로 남기지 않기로 했다. 질의에는 개인정보가 섞일 수 있다. MCP 서버가 남기는 감사 기록은 그대로 가렸고, 그걸 확인하는 테스트도 통과했다. 그런데 MCP 서버가 위임 요청을 보낸 검색 서비스가 자기 감사 로그에 원문을 그대로 남기고 있었다. 테스트는 MCP 서버가 남긴 행만 보고 있었기 때문에 통과했다. 지금은 요청 주체가 API 키이면 검색 서비스도 질의와 번호판을 가려서 기록한다.

무거운 도구 하나가 나머지를 굶겼다

자연어 장면 검색은 한 건에 최대 2분까지 걸린다. 이 검색이 장비 전체의 내부 조회 자리 8개를 함께 쓰고 있어서, 장면 검색 몇 건이 몰리면 가벼운 카메라 목록 조회까지 '일시적으로 사용할 수 없음'으로 거절됐다. 장면 검색은 전용 자리 2개를 따로 쓰게 했다.

권한 밖 카메라가 '0건'으로 읽혔다

이벤트 통계에서 키의 권한 밖에 있는 카메라는 결과에서 조용히 빠졌다. 사람이 보면 "이 카메라는 왜 없지?"라고 생각하겠지만, 에이전트는 "그 카메라는 이벤트가 0건"이라고 답한다. 지금은 빠진 카메라를 excluded_camera_ids로 따로 알려 준다.

모양은 맞는데 값이 달랐다

QA에서 나온 결함들은 공통점이 있었다. 응답 JSON의 모양은 맞는데, 그 값을 받는 쪽이 다르게 다루고 있었다. 테스트에 쓴 가짜 응답을 우리가 직접 손으로 만들었기 때문에, 테스트는 우리 생각과 우리 코드가 서로 맞는지만 확인하고 있었다.

  • 이벤트 검색에 '오래된 순' 옵션을 만들었는데, 이벤트 서비스는 항상 최신순으로만 정렬하고 있었다. 옵션은 받아들여지고 아무 효과가 없었다. 처음에는 옵션을 뺐다가, 다음 날 정렬을 끝까지 연결하고 되살렸다.
  • 녹화 구간 확인 도구가 첫 녹화 블록 앞과 마지막 블록 뒤의 공백을 세지 않았다. 세 시간 전에 녹화가 멈춘 카메라를 두고 "공백 없음"이라고 답하는 구조였다. 지금은 조회 범위 전체에서 녹화된 구간을 빼는 방식으로 공백을 계산한다.

실제 장비에 Claude Code를 붙인 날

마지막 시험은 개발 장비에 실제로 Claude Code를 연결해 질문하는 것이었다. 장비의 CA 인증서를 내려받아 지정하고, claude mcp add로 등록하고, claude mcp list에서 ✔ Connected를 확인했다.

첫 두 질문은 순조로웠다. 카메라 상태를 물으니 카메라 목록과 상태 점검 도구를 이어 불러 "9대 전부 온라인"이라고 답했고, 장비 대시보드와 같았다. 지난 24시간 이벤트 통계도 대시보드 숫자와 맞았다.

세 번째 질문은 녹화 누락이었다. 녹화 구간 도구는 지금 녹화 중인 카메라 전부가 24시간 내내 비어 있다고 했고, 재생 링크 도구는 그 시각에 영상이 없다고 했다. Claude는 이 결과를 그대로 옮겨 "녹화가 전부 비었습니다"라고 답하지 않았다. "정보가 서로 모순된다"고 답했다.

원인은 MCP가 아니라 그 아래에 있었다. 녹화 타임라인을 묶어 주는 쿼리가 시간대 변환을 두 번 하고 있어서, 서울 시간 기준으로 모든 녹화 블록이 18시간 뒤로 밀려 있었다. 출시된 기능 중에는 이 경로를 장비 시간대와 함께 부르는 곳이 없어서 그동안 드러나지 않던 결함이, 에이전트의 호출로 처음 드러났다. 자세한 내용은 아래에 따로 적는다.

고치고 나서 같은 질문을 다시 하자 Claude는 카메라 목록 → 녹화 구간 → 재생 링크 순으로 도구를 불러 "두 채널 모두 최근 약 25분만 녹화되어 있고, 커버리지 1.7%"라고 답했다. 개발 장비의 녹화 저장소가 8GB라 오래된 영상이 계속 지워지고 있었으니, 데이터베이스에 남은 실제 녹화 분포와 정확히 맞는 답이었다.

그다음에는 재생 링크가 영상이 있는 시각에도 "영상 없음"을 돌려줬다. 이번에도 Claude가 먼저 "커버리지와 링크 결과가 서로 맞지 않는다"고 짚었다. 이것도 같은 경로의 결함이었다. 고친 뒤에는 "그 시각에 영상이 있습니다"라는 답과 함께 정확한 재생 링크가 나왔다.

마지막으로 키를 폐기했다. 폐기 시각이 기록된 뒤 약 0.2초 만에 첫 거절이 나왔고, claude mcp list는 ✘ Failed to connect … HTTP 401 TOKEN_INVALIDATED를 보여 줬다.

이날 가장 오래 남은 것은 두 번의 "서로 맞지 않는다"였다. 도구 결과가 서로 어긋나면 에이전트는 그걸 알아챈다. 반대로 말하면, 도구가 빈 결과나 잘못된 값을 아무 표시 없이 돌려주면 에이전트는 그걸 그대로 믿는다. 앞에서 정한 "빈 결과로 위장하지 않는다"는 원칙이 왜 필요한지 이 시험이 보여 줬다.

출시 전 시험에서 드러난 시간대 결함

먼저 범위부터 적는다. 녹화된 영상과 녹화 기록에는 영향이 없었다. 틀린 것은 넓은 범위의 타임라인을 묶어서 보여 주는 계산 한 줄이었다. 그리고 이 계산을 장비 시간대와 함께 부르는 출시된 기능이 없었기 때문에, 고객 화면에 이 결함이 나타난 적은 없다. MCP 서버를 출시하기 전, 실제 장비 시험에서 찾아 고쳤다. 수정 커밋은 MCP 기능 커밋보다 먼저 들어갔다.

그런데도 자세히 적는 이유는, 이 결함이 무엇을 놓치게 하는지가 MCP 서버를 만들며 배운 것과 맞닿아 있기 때문이다.

무엇을 하는 쿼리였나

녹화 타임라인을 넓게 볼 때(하루, 일주일 단위) 장비는 녹화 조각을 하나하나 돌려주지 않고 일정 길이의 블록으로 묶어서 돌려준다. 이때 블록 경계는 장비 현지 시각의 정시와 자정에 맞아야 한다. 서울에 있는 장비라면 "오늘 0시부터 1시까지" 블록이 서울 시각 기준이어야 한다. 이 정렬을 데이터베이스(PostgreSQL) 쿼리 한 줄이 맡고 있었다.

to_timestamp(FLOOR(EXTRACT(EPOCH FROM seg.start_time AT TIME ZONE $4) / $5) * $5) AT TIME ZONE $4 AS block_start

$4는 장비 시간대(예: Asia/Seoul), $5는 블록 길이(초)다.

한 단계씩 따라가 보면

실제로 첫 녹화 조각이 시작된 시각이 UTC 09:38(서울 18:38)이었던 경우를 따라가 본다.

  1. seg.start_time AT TIME ZONE 'Asia/Seoul' — UTC 09:38을 서울 현지 시각으로 바꾼다. 결과는 시간대 정보가 빠진 18:38이다. 여기까지는 의도대로다.
  2. EXTRACT(EPOCH FROM …) — 1970년부터 흐른 초를 구한다. 그런데 시간대 정보가 빠진 값은 UTC로 간주된다. 서울 18:38이 UTC 18:38로 바뀐다. 여기서 9시간이 한 번 밀린다. 그래도 이 값을 블록 길이로 나누고 버리면 현지 정시에 맞춘 경계가 나오므로, 정렬 목적으로는 이 단계까지도 쓸 만하다.
  3. to_timestamp(…) — 정렬된 초 값을 다시 시각으로 되돌린다. 결과는 시간대가 붙은 UTC 18:38이다. 이미 9시간 밀린 값이 실제 시각처럼 굳어 버렸다.
  4. … AT TIME ZONE 'Asia/Seoul' — 그 UTC 18:38을 다시 서울 현지 시각으로 바꾼다. 다음 날 03:38, 다시 시간대 정보가 빠진 값이다. 9시간이 한 번 더 밀렸다.
  5. 마지막으로 Go 데이터베이스 드라이버는 시간대 정보가 빠진 값을 UTC로 읽는다. 결과는 다음 날 UTC 03:38, 서울 시각으로 낮 12:38이다.

실제로는 서울 18:38에 시작한 녹화가, 타임라인에서는 다음 날 낮 12:38에 시작한 것으로 나왔다. 서울(UTC+9)에서는 오프셋의 두 배인 18시간이 밀린다.

왜 시험에서 녹화율이 0%로 나왔나

블록이 모두 18시간 뒤, 즉 미래로 밀렸다. 녹화 구간 확인 도구는 "현재 시각 이후는 아직 오지 않은 시간"으로 보고 잘라 낸다. 그러면 지난 24시간 안에 남는 블록이 하나도 없다. 녹화가 멀쩡히 돌고 영상도 저장되고 있는 카메라가, 도구 결과에서는 "녹화율 0%, 24시간 전체 공백"으로 나온 이유다. 틀린 것은 계산 결과였지 녹화가 아니었다. 재생 링크 도구도 같은 타임라인을 보므로 "그 시각에 영상 없음"을 돌려줬다.

왜 그동안 드러나지 않았나

세 가지가 겹쳤다.

  • 시간대가 UTC인 장비에서는 결함이 사라진다. 두 번 밀려도 밀리는 양이 0이기 때문이다. 시간대를 지정하지 않고 부르는 경우도 마찬가지로 정상으로 나온다.
  • 데이터베이스 쿼리를 실제로 실행하는 테스트가 없었다. 이 경로의 테스트는 데이터베이스 대신 가짜 객체를 써서, 쿼리 문장 자체는 한 번도 실행되지 않았다.
  • 이 쿼리를 장비 시간대와 함께 부르는 출시된 기능이 없었다. 장비 웹 화면에는 이 집계 API를 시간대와 함께 부르는 함수가 정의돼 있었지만 실제 화면에서는 쓰이지 않았고, 외부 연동 API로 이 경로를 쓰는 연동처도 없었다. MCP 서버는 내부 조회마다 장비 시간대를 항상 실어 보낸다. 이 경로를 정직하게 끝까지 부른 첫 호출자가 에이전트였다.

어떻게 고쳤나

3단계와 4단계 사이에 AT TIME ZONE 'UTC'를 하나 끼워 넣었다.

(to_timestamp(FLOOR(EXTRACT(EPOCH FROM seg.start_time AT TIME ZONE $4) / $5) * $5) AT TIME ZONE 'UTC') AT TIME ZONE $4 AS block_start

정렬된 초 값을 시각으로 되돌린 뒤 곧바로 '시간대 정보가 빠진 현지 시각'으로 되돌려 놓고, 그 값을 장비 시간대의 시각으로 해석하게 했다. 이러면 오프셋은 한 번도 더 적용되지 않는다. 같은 커밋에서 실제 PostgreSQL에 붙어 서울 시간대로 블록 시각을 확인하는 회귀 테스트를 추가했다. 가짜 객체로는 이 쿼리를 실행하지 않으므로, 실제 데이터베이스에서 돌려 봐야만 잡히는 결함이었다.

같은 시기에 들어온 또 하나

재생 링크가 영상이 있는데도 "영상 없음"을 돌려준 결함도 같은 경로의 코드에서 나왔다. 이것 역시 출시된 화면에는 나타나지 않았다. 데이터베이스는 트랙 종류를 Video, Audio, Metadata로 저장하는데, 타임라인을 응답으로 바꾸는 코드는 소문자 video와 비교하고 있었다. 그래서 확대해서 볼 때의 타임라인에서 영상 여부가 항상 '없음'으로 나왔다. 비교를 대소문자를 구분하지 않게 바꾸고, 실제 데이터베이스 값 그대로를 넣은 테스트를 추가했다.

두 결함 모두 테스트가 우리가 손으로 만든 값만 보고 있었다는 점에서, 앞의 QA 결함과 뿌리가 같다.

격리망 NVR에 에이전트를 연결하는 구조

NVR은 보통 외부 인터넷과 분리된 현장망에 있다. 카메라 영상이 밖으로 나가지 않아야 하고, 외부에서 장비로 들어오는 길도 없어야 한다. 그렇다면 클라우드에서 돌아가는 Claude가 어떻게 이 장비에 질문을 던질 수 있을까.

답은 도구를 실제로 호출하는 주체가 Claude 모델이 아니라 사용자 PC라는 데 있다.

Claude 모델(클라우드)  ⇄  인터넷  ⇄  사용자 PC: Claude Desktop · Claude Code  ⇄  현장망  ⇄  NOX 장비 /agent/mcp
  1. 사용자가 PC의 Claude Desktop이나 Claude Code에 질문한다.
  2. 클라우드의 Claude 모델은 "카메라 목록 도구를 이 인자로 불러 달라"는 요청만 돌려준다. 모델이 장비에 직접 접속하지는 않는다.
  3. 그 요청을 받은 PC의 Claude Desktop(또는 Claude Code)이 같은 현장망에 있는 NOX 장비에 HTTPS로 접속해 도구를 호출한다.
  4. 장비의 결과를 PC가 받아 모델에게 다시 전달하고, 모델은 그 결과로 답을 만든다.

그래서 NVR을 인터넷에 열 필요가 없다. 포트 포워딩도, 외부 중계 서버도 필요 없다. 필요한 조건은 하나다. Claude Desktop이나 Claude Code를 실행하는 PC가 인터넷(Claude)과 현장망(NVR) 양쪽에 닿아야 한다. 관제실의 운영 PC가 대표적인 자리다.

대신 무엇이 밖으로 나가는지는 분명히 해 둔다. 도구 결과, 즉 카메라 이름, 상태, 이벤트 건수, 녹화 구간, 재생 링크 같은 텍스트는 대화 내용으로 클라우드의 모델에 전달된다. 영상, 썸네일, 카메라 접속 주소와 자격증명은 도구 결과에 처음부터 담지 않는다. 재생 링크는 장비 웹 화면을 여는 주소일 뿐이라, 현장망 밖에서는 열리지 않는다.

Claude 앱의 설정 화면에서 URL로 추가하는 '원격 커넥터'는 이 방식과 다르다. 원격 커넥터는 Anthropic 서버 쪽에서 MCP 서버에 접속하므로, 인터넷에서 닿지 않는 격리망의 장비에는 연결할 수 없다. 격리된 NVR에는 PC에서 직접 접속하는 Claude Desktop의 로컬 설정이나 Claude Code를 쓴다.

Claude Code·Claude Desktop에 연결하는 방법

장비 웹 화면의 설정 → API 키에서 용도를 AI 에이전트(MCP)로 골라 발급하면, 결과 창에 접속 주소와 등록 명령이 함께 나온다.

claude mcp add --transport http nox-<키 이름> https://<장비 주소>/agent/mcp \
  --header "Authorization: Bearer <AI 에이전트 키>"
  • 장비 인증서는 장비 내부 CA가 서명한다. 웹 화면에서 CA 인증서를 내려받아 NODE_EXTRA_CA_CERTS=<파일 경로>로 지정한 뒤 Claude Code를 실행한다.
  • 주소 끝에 슬래시를 붙이면 404가 난다. /agent/mcp/가 아니라 /agent/mcp다.
  • 등록한 뒤 claude mcp list로 연결 상태를 확인한다.

Claude Desktop은 로컬 설정 파일(claude_desktop_config.json)에 MCP 서버를 등록한다. 이 파일에 등록한 서버는 PC 안에서 실행되므로 현장망의 장비에 닿는다. 원격 HTTP 서버에 헤더를 붙여 접속할 때는 PC에서 돌아가는 중계 도구 mcp-remote를 거치게 한다(Node.js 필요).

{
  "mcpServers": {
    "nox": {
      "command": "npx",
      "args": ["-y", "mcp-remote", "https://<장비 주소>/agent/mcp",
               "--header", "Authorization:Bearer ${NOX_KEY}"],
      "env": {
        "NOX_KEY": "<AI 에이전트 키>",
        "NODE_EXTRA_CA_CERTS": "<CA 인증서 파일 경로>"
      }
    }
  }
}
  • 키는 env의 NOX_KEY에만 두고, args에서는 ${NOX_KEY}로 불러 쓴다.
  • 설정을 저장한 뒤 Claude Desktop을 다시 시작하면 도구 목록에 NOX 도구가 나타난다. Claude Desktop으로 연결해 쓰는 모습은 글 앞쪽 영상에 담았다.
  • Cursor 같은 다른 에이전트도 같은 주소와 헤더를 원격(HTTP) MCP 서버 설정에 넣으면 된다. 어느 경우든 그 프로그램이 돌아가는 PC가 장비에 닿아야 한다.
  • 발급 화면에서 키마다 허용할 도구를 고를 수 있다. 고르지 않은 도구는 그 키의 도구 목록에 아예 나타나지 않는다.
  • AI 에이전트 키는 이 장비의 모든 카메라를 조회할 수 있다. 발급 화면에도 이 안내가 항상 표시된다.

연결하면 이런 질문을 할 수 있다.

  • "지금 오프라인이거나 녹화가 멈춘 카메라 있어?"
  • "지난 일주일 동안 이벤트가 가장 많았던 카메라 다섯 대 알려 줘."
  • "정문 카메라 어제 녹화에 빈 구간이 있었는지 보고, 있으면 그 직전 재생 링크 줘."
  • "저장소 여유 공간이랑 디스크 상태 괜찮아?"
  • "어제 저녁 주차장에서 흰색 트럭이 지나간 장면 찾아 줘." (자연어 장면 검색 라이선스가 있는 장비)

숫자로 남긴 것

항목내용
도구조회 10종, 쓰기 0종
요청이 도구에 닿기 전 검사8단계
MCP 서버 테스트테스트 파일 25개, 테스트 함수 202개
관련 서비스 쪽 추가 테스트이름에 agent·mcp가 들어간 파일 13개, 테스트 함수 93개
감사 기록도구 호출 1건당 1행, 성공과 실패 모두

아직 하지 않은 것

  • 쓰기 도구. 카메라·녹화·설정을 바꾸는 도구는 없다.
  • 카메라 단위 키 범위. 특정 카메라만 보이도록 키를 좁히는 기능은 아직 없다.
  • 사람 계정 접속. 로그인 토큰으로는 접속할 수 없고 AI 에이전트 키만 받는다.
  • 세션과 스트리밍. 서버 알림과 도구 목록 변경 알림이 없다.
  • 원격 장비. 페더레이션으로 연결된 다른 장비의 카메라는 조회하지 않는다.
  • 국정원 보안 모드. 이 모드가 켜진 장비에는 MCP 접속 주소 자체가 없다.

결론

MCP 서버의 뼈대, 즉 규약을 받고 도구 목록을 내주고 내부 서비스에 물어 답하는 경로는 공식 SDK 덕분에 오래 걸리지 않았다. 시간을 쓴 곳은 에이전트가 장비 안에서 할 수 있는 일을 줄이는 것, 그리고 에이전트가 받는 답이 사실과 다르지 않게 하는 것이었다.

리뷰에서 걸린 결함도, 실제 장비에서 나온 결함도 대부분 같은 종류였다. 결과가 비어 있거나 값이 틀렸는데 아무 표시가 없는 경우다. 사람은 화면을 보며 이상하다고 느끼지만, 에이전트는 받은 값을 그대로 믿고 다음 도구를 부른다. 그래서 판단 기준을 하나로 정했다. MCP 서버가 잘 만들어졌는지는 도구가 몇 개인지가 아니라, 에이전트가 틀린 값을 사실처럼 받는 경우가 얼마나 없는지로 판단해야 한다.

그리고 그 기준을 처음으로 시험해 준 것은 우리 테스트가 아니라 Claude Code였다.


👉 NOX 제품 페이지에서 실제 운영 화면 확인 — 자연어 장면 검색과 시스템 대시보드

NOX 기반 AI 에이전트 연동·맞춤 개발 문의: yiyol.com/contact


관련 글

MCP (Model Context Protocol) is an open protocol that lets AI agents call the functions of external systems as "tools." Register an MCP server with an agent such as Claude Code, and the agent reads the list of tools that server exposes, picks the ones that fit the question, and calls them.

In NOX, each device is itself an MCP server. Once an administrator issues a dedicated AI agent key and registers it in Claude Code or Claude Desktop, a question like "Did any camera have a gap in recording last night?" leads the agent to check the camera list, look up the recorded ranges, and even generate a playback link for that moment in its answer. Not a single tool changes the state of the device.

This post is not a feature introduction but a development log. It records why the server ended up in its current shape, what we deliberately chose not to do, and what broke during reviews and testing on real devices. The content is based on the design documents, review, QA and test reports, and commit history in our repository.

Asking NOX on a development device questions from Claude Desktop over MCP (8x speed; the demo was run in Korean). The questions, in order: camera status, recording problems, missing recording ranges for one camera over a day, a playback link from 10 minutes ago, and today's person and vehicle object search. Each time a tool is called for the first time, Claude Desktop asks for permission to use it.

Two moments in the video are worth noting. Asked whether any camera had a recording problem, Claude answered that no camera was flagged with a problem, yet it pointed out that the recordings of all three recording cameras start at the same moment and asked what had happened at that time. The final object search returned zero results, but instead of just answering "zero," Claude also looked up event statistics for the same period and identified the likely cause: events exist, but object information is not coming in. Most of the design principles described below were chosen to make answers like these possible.

Two False Starts

What we first had in mind was not an MCP server. In April 2026 we designed a feature that put a chat window inside the product UI, so users could talk to a language model inside the device. The documents went as far as review and security assessment, but never turned into code. Looking back, what we wanted to build was not a chat window but a channel through which agents can safely read the device's information. Chat windows are better built by Claude Code or Cursor, which users already use.

At the end of July we restarted with the direction changed to an MCP server. And then we lost the code for that work. The working folder holding the design documents had been excluded from git tracking, and the code had not yet been committed. What remained were a few empty directories, tables created in the development database, and the SDK version and download timestamp left in the Go module cache. A document that reconstructs the design by reading those traces backward is still in the repository. Its first line reads: "No MCP-related code has ever been committed to the repo."

Since then, all design documents are managed in git as well. That is also why this post could be written.

The third start came in early October. This time we narrowed the scope from the outset: no new authentication scheme, just a thin layer on top of the existing external API keys and permission system. The device-agent pairing, separate agent table, and agent-only access mode from the July design were not carried over.

Six Decisions Made Up Front

1. Start read-only

We did not include tools that pan cameras, turn recording on and off, or change settings. If an agent reads something wrong, the answer is merely wrong; if an agent writes something wrong, recording on site stops. We decided to first confirm that reading alone is useful, and to design writing separately as a later step.

2. No new key — add one more "purpose" to keys

NOX already has API keys for integrating with external systems. We added a purpose called AI Agent (MCP). One option was to give agent keys a new role "instead of" the existing read role. But then three existing checks that verify a key is still valid, along with the permission cleanup job, would treat agent keys as revoked. So we chose to keep the existing read role and "add" an agent role on top. No existing code had to be touched.

The list of allowed tools is signed into the key itself. That way we know what a key can see without querying the database on every request. The trade-off is that changing a key's tool scope requires reissuing it. If the scope needs to be narrowed urgently, revoking the key and issuing a new one takes effect immediately.

3. The MCP server never queries with its own privileges

When the MCP server asks an internal service for data, it does not use its own service credentials; it forwards the identity of the calling key as is. Whether it is the camera list or events, the service that owns the data re-evaluates permissions using the original key and the real client IP. The checks on the MCP server side exist to reject early and leave a record; in the end, the side that owns the data makes the call.

This principle forced us to change one path. We could have routed internal queries through the device's web server one more time, but requests coming in that way are treated as "local access by an administrator sitting in front of the device," and the client IP is overwritten with the device's own address. That would have neutralized keys restricted to allowed IPs. So requests go directly to internal services, and delegated requests are always marked as remote access. The authentication key that internal services use among themselves is never attached to a delegated request. The moment that key is attached, it becomes a path that exceeds the caller's privileges.

4. No sessions

The July design maintained sessions. In October we switched to a sessionless approach that redoes authentication and permission checks from scratch for every single request. The fact that a previous request passed does not vouch for the next one. For the SDK we chose the official Go SDK 1.8, the latest at the time. The MCP protocol itself was also moving toward a sessionless model. Responses are returned as a single JSON body, without streaming.

5. Tools cut from 14 to 10

The July design had 14 tools. The tools that separately showed the dashboard, disks, and system metrics were merged into two, "system status" and "storage status," and person search was deferred to a later stage. In their place we added a tool that finds scenes in already-indexed recordings using natural language.

The more tools there are, the more an agent has to choose from, and the more conversation space the tool descriptions take up. The remaining 10 fall into five groups: cameras, events and objects, recording and playback, system, and AI. Each tool description names the tool that should come before it. For example, the camera list tool says it is "the source of the camera_id passed to other tools." That single line lets the agent work out on its own the order in which to chain tools.

6. Never disguise failure as an empty result

Errors during tool execution are returned not as protocol errors but as tool results. The result carries an error flag along with a code such as RATE_LIMITED or PERMISSION_DENIED, so the model can read it and adjust its next action on its own. The rule written in the design document is short: "No successful empty results." If permissions cannot be verified or an internal service does not respond, that is an error, not zero results. People are suspicious of zero results; agents take zero at face value and build an answer on it.

Limits That Protect Recording and Live View

Agents ask far faster and far more than people do. And an NVR's first job is recording. If an agent's questions cause recording or live video to fall behind, it would be better not to have this feature at all.

ScopeDefault limit
MCP requests per key300 per minute
Tool calls per key60 per minute, burst of 20
Natural-language scene searches per key6 per minute
Concurrent executions per key4
Concurrent scene searches, device-wide2
Concurrent internal queries, device-wide8

The limits can be lowered or raised in settings, but not turned off. The query period is also capped at 31 or 7 days depending on the tool, and the size of the results returned at once is capped as well.

What the Reviews Caught

Once the feature roughly worked, we ran separate code, authentication, and UI reviews. The first verdict of the tool-side review was "changes requested."

Tests passed, but raw queries were left in the audit log

We decided not to keep natural-language queries and license plate strings verbatim in audit logs, since queries can contain personal information. The audit records written by the MCP server were masked properly, and the test checking that passed. But the search service that the MCP server sent delegated requests to was writing the raw text into its own audit log. The test passed because it only looked at rows written by the MCP server. Now, when the requester is an API key, the search service also masks queries and plates in its records.

One heavy tool starved the rest

A natural-language scene search can take up to two minutes. Because these searches shared the device-wide pool of 8 internal query slots, a few concurrent scene searches caused even a lightweight camera list query to be rejected as "temporarily unavailable." Scene searches now use their own 2 dedicated slots.

Cameras outside the key's scope read as "zero"

In event statistics, cameras outside the key's permissions were silently dropped from the results. A person would wonder, "Why is this camera missing?" but an agent would answer, "That camera had zero events." Now the dropped cameras are reported separately in excluded_camera_ids.

Right shape, wrong values

The defects found in QA had something in common. The shape of the response JSON was correct, but the side receiving those values handled them differently. Because we had hand-crafted the fake responses used in tests, the tests were only checking that our assumptions and our code agreed with each other.

  • We built an "oldest first" option for event search, but the event service always sorted newest first. The option was accepted and had no effect. At first we removed the option; the next day we wired sorting all the way through and brought it back.
  • The recording range tool did not count the gap before the first recording block or after the last one. For a camera whose recording had stopped three hours earlier, it would answer "no gaps." Gaps are now calculated by subtracting the recorded ranges from the entire query range.

The Day We Connected Claude Code to a Real Device

The final test was to actually connect Claude Code to a development device and ask questions. We downloaded and specified the device's CA certificate, registered it with claude mcp add, and confirmed ✔ Connected in claude mcp list.

The first two questions went smoothly. Asked about camera status, it chained the camera list and health check tools and answered "all 9 online," matching the device dashboard. Event statistics for the past 24 hours also matched the dashboard numbers.

The third question was about missing recordings. The recording range tool said every currently recording camera had been empty for the full 24 hours, and the playback link tool said there was no video at that time. Claude did not simply relay these results as "All recordings are empty." It answered that "the information is contradictory."

The cause was not MCP but something underneath it. The query that groups the recording timeline was converting time zones twice, so in Seoul time every recording block was shifted 18 hours later. A defect that had stayed hidden because no shipped feature called this path together with the device time zone surfaced for the first time through the agent's calls. Details are written up separately below.

After the fix, asked the same question again, Claude called the tools in the order camera list → recording ranges → playback link and answered, "Both channels have only about the last 25 minutes recorded, coverage 1.7%." The development device had only 8GB of recording storage, so old footage was constantly being deleted; the answer matched exactly the actual recording distribution left in the database.

Next, the playback link returned "no video" even at times when video existed. Once again, Claude was the first to point out that "the coverage and the link result don't match." This too was a defect in the same path. After the fix, the answer "There is video at that time" came back with an accurate playback link.

Finally, we revoked the key. The first rejection came about 0.2 seconds after the revocation time was recorded, and claude mcp list showed ✘ Failed to connect … HTTP 401 TOKEN_INVALIDATED.

What stayed with us longest that day were the two "these don't match" moments. When tool results contradict each other, the agent notices. Put the other way, if a tool returns an empty result or a wrong value without any indication, the agent believes it as is. This test showed why the principle we set earlier, "never disguise failure as an empty result," is necessary.

The Time Zone Defect Found in Pre-Release Testing

First, the scope. Recorded video and recording records were not affected. What was wrong was a single line of calculation that groups a wide-range timeline for display. And because no shipped feature called this calculation with the device time zone, the defect never appeared on a customer's screen. We found and fixed it in real-device testing before releasing the MCP server. The fix commit landed before the MCP feature commit.

We still describe it in detail because what this defect causes you to miss ties directly to what we learned building the MCP server.

What the query did

When viewing the recording timeline over a wide range (a day or a week), the device does not return recording segments one by one; it groups them into blocks of a fixed length. The block boundaries must line up with the hour and midnight in the device's local time. For a device in Seoul, the "today 00:00 to 01:00" block must be based on Seoul time. This alignment was handled by a single line in a database (PostgreSQL) query.

to_timestamp(FLOOR(EXTRACT(EPOCH FROM seg.start_time AT TIME ZONE $4) / $5) * $5) AT TIME ZONE $4 AS block_start

$4 is the device time zone (e.g. Asia/Seoul) and $5 is the block length in seconds.

Following it step by step

Let's trace an actual case where the first recording segment started at 09:38 UTC (18:38 Seoul).

  1. seg.start_time AT TIME ZONE 'Asia/Seoul' — converts 09:38 UTC to Seoul local time. The result is 18:38 with the time zone information stripped. So far, this is as intended.
  2. EXTRACT(EPOCH FROM …) — computes seconds elapsed since 1970. But a value without time zone information is treated as UTC. Seoul 18:38 becomes 18:38 UTC. Here the time is shifted by 9 hours once. Even so, dividing this value by the block length and truncating gives boundaries aligned to the local hour, so for alignment purposes it is still usable up to this step.
  3. to_timestamp(…) — turns the aligned seconds back into a timestamp. The result is 18:38 UTC with a time zone attached. A value already shifted by 9 hours is now set in stone as if it were the real time.
  4. … AT TIME ZONE 'Asia/Seoul' — converts that 18:38 UTC to Seoul local time again: 03:38 the next day, once more without time zone information. The time has been shifted by 9 hours a second time.
  5. Finally, the Go database driver reads values without time zone information as UTC. The result is 03:38 UTC the next day, or 12:38 noon in Seoul.

A recording that actually started at 18:38 Seoul time appeared on the timeline as starting at 12:38 the next afternoon. In Seoul (UTC+9), it is shifted by twice the offset: 18 hours.

Why the test showed a 0% recording rate

Every block was shifted 18 hours later, that is, into the future. The recording range tool treats anything after the current time as "time that hasn't arrived yet" and cuts it off. That leaves no blocks within the past 24 hours. This is why cameras that were recording normally and storing video showed up in tool results as "0% recording rate, gap across the full 24 hours." What was wrong was the calculation, not the recording. The playback link tool looks at the same timeline, so it returned "no video at that time."

Why it had gone unnoticed

Three things overlapped.

  • On devices whose time zone is UTC, the defect disappears. Shifted twice, the amount of shift is still zero. Calls that do not specify a time zone likewise come out correct.
  • There was no test that actually executed the database query. Tests for this path used fake objects instead of a database, so the query statement itself had never run.
  • No shipped feature called this query with the device time zone. The device web UI defined a function that called this aggregation API with a time zone, but it was not used on any actual screen, and no integration partner used this path through the external API. The MCP server always sends the device time zone with every internal query. The first caller to honestly follow this path all the way through was the agent.

How we fixed it

We inserted one AT TIME ZONE 'UTC' between steps 3 and 4.

(to_timestamp(FLOOR(EXTRACT(EPOCH FROM seg.start_time AT TIME ZONE $4) / $5) * $5) AT TIME ZONE 'UTC') AT TIME ZONE $4 AS block_start

After turning the aligned seconds back into a timestamp, it is immediately converted back into a "local time without time zone information," and that value is then interpreted as a time in the device time zone. This way the offset is never applied an extra time. In the same commit we added a regression test that connects to a real PostgreSQL instance and checks block times in the Seoul time zone. Since fake objects never execute this query, it was a defect that could only be caught by running against a real database.

Another one from the same period

The defect where the playback link returned "no video" even though video existed also came from code in the same path. It, too, never appeared on a shipped screen. The database stores track types as Video, Audio, and Metadata, but the code that converts the timeline into a response was comparing against lowercase video. As a result, the zoomed-in timeline always showed video as "absent." We made the comparison case-insensitive and added a test using the actual database values as is.

Both defects share the same root as the earlier QA defects: the tests were only looking at values we had crafted by hand.

Connecting an Agent to an Air-Gapped NVR

NVRs usually sit on an on-site network isolated from the internet. Camera video must not leave, and there must be no path from outside into the device. So how can Claude, running in the cloud, ask this device questions?

The answer is that the one actually calling the tools is not the Claude model but the user's PC.

Claude model (cloud)  ⇄  Internet  ⇄  User PC: Claude Desktop · Claude Code  ⇄  On-site network  ⇄  NOX device /agent/mcp
  1. The user asks a question in Claude Desktop or Claude Code on their PC.
  2. The Claude model in the cloud only returns a request like "call the camera list tool with these arguments." The model never connects to the device directly.
  3. Claude Desktop (or Claude Code) on the PC receives that request and calls the tool over HTTPS on the NOX device in the same on-site network.
  4. The PC receives the device's result and passes it back to the model, which builds its answer from that result.

That is why there is no need to expose the NVR to the internet. No port forwarding, no external relay server. Only one condition is required: the PC running Claude Desktop or Claude Code must reach both the internet (Claude) and the on-site network (the NVR). An operator PC in the control room is the typical place.

We do, however, make clear what leaves the site. Tool results — text such as camera names, status, event counts, recorded ranges, and playback links — are sent to the cloud model as part of the conversation. Video, thumbnails, camera access addresses, and credentials are never included in tool results in the first place. A playback link is only an address that opens the device's web UI, so it will not open outside the on-site network.

"Remote connectors," added by URL in the Claude app's settings, work differently. A remote connector connects to the MCP server from Anthropic's servers, so it cannot reach a device on an air-gapped network that is not reachable from the internet. For an isolated NVR, use Claude Desktop's local configuration or Claude Code, which connect directly from the PC.

How to Connect Claude Code and Claude Desktop

In the device web UI, go to Settings → API Keys, choose the purpose AI Agent (MCP), and issue a key; the result dialog shows both the endpoint address and the registration command.

claude mcp add --transport http nox-<key name> https://<device address>/agent/mcp \
  --header "Authorization: Bearer <AI agent key>"
  • The device certificate is signed by the device's internal CA. Download the CA certificate from the web UI, set it with NODE_EXTRA_CA_CERTS=<file path>, and then launch Claude Code.
  • A trailing slash in the address results in a 404. It is /agent/mcp, not /agent/mcp/.
  • After registering, check the connection status with claude mcp list.

Claude Desktop registers MCP servers in a local configuration file (claude_desktop_config.json). Servers registered in this file run on the PC, so they can reach devices on the on-site network. To connect to a remote HTTP server with a header, route through mcp-remote, a relay tool that runs on the PC (Node.js required).

{
  "mcpServers": {
    "nox": {
      "command": "npx",
      "args": ["-y", "mcp-remote", "https://<device address>/agent/mcp",
               "--header", "Authorization:Bearer ${NOX_KEY}"],
      "env": {
        "NOX_KEY": "<AI agent key>",
        "NODE_EXTRA_CA_CERTS": "<CA certificate file path>"
      }
    }
  }
}
  • Keep the key only in NOX_KEY under env, and reference it in args as ${NOX_KEY}.
  • After saving the configuration, restart Claude Desktop and the NOX tools will appear in the tool list. The video at the top of this post shows it connected and in use with Claude Desktop.
  • Other agents such as Cursor work the same way: put the same address and header into their remote (HTTP) MCP server settings. In every case, the PC running the program must be able to reach the device.
  • On the issuance screen, you can choose which tools each key is allowed to use. Tools you do not select do not appear in that key's tool list at all.
  • An AI agent key can query every camera on the device. The issuance screen always displays this notice as well.

Once connected, you can ask questions like these.

  • "Are any cameras offline right now, or has any stopped recording?"
  • "Tell me the five cameras with the most events over the past week."
  • "Check whether the front gate camera had any gaps in yesterday's recording, and if so, give me a playback link from just before it."
  • "Is the free storage space and disk health okay?"
  • "Find the scene where a white truck passed through the parking lot yesterday evening." (devices with a natural-language scene search license)

By the Numbers

ItemDetails
Tools10 read, 0 write
Checks before a request reaches a tool8 stages
MCP server tests25 test files, 202 test functions
Additional tests in related services13 files with agent or mcp in the name, 93 test functions
Audit recordsOne row per tool call, for both successes and failures

Not Done Yet

  • Write tools. There are no tools that change cameras, recording, or settings.
  • Per-camera key scope. Narrowing a key so that only specific cameras are visible is not yet supported.
  • Access with user accounts. Login tokens cannot be used; only AI agent keys are accepted.
  • Sessions and streaming. There are no server notifications or tool list change notifications.
  • Remote devices. Cameras on other devices connected via federation are not queried.
  • NIS security mode. On devices with this mode enabled, the MCP endpoint does not exist at all.

Conclusion

The skeleton of the MCP server — the path that accepts the protocol, serves the tool list, and answers by asking internal services — did not take long thanks to the official SDK. Where we spent our time was in limiting what an agent can do inside the device, and in making sure the answers an agent receives do not differ from the facts.

The defects caught in review and those found on the real device were mostly of the same kind: a result that is empty or a value that is wrong, with no indication of it. A person looking at the screen senses something is off, but an agent trusts the value it received and calls the next tool. So we settled on one criterion: whether an MCP server is well built should be judged not by how many tools it has, but by how rarely an agent receives wrong values as fact.

And the first to put that criterion to the test was not our test suite, but Claude Code.


👉 See the real operating screens on the NOX product page — natural-language scene search and the system dashboard

For NOX-based AI agent integration and custom development inquiries: yiyol.com/contact


Related Posts