본문으로 건너뛰기

커넥터 — /v1/connectors

이 키에서 어떤 연결 서비스가 열렸는지 보고, 안 열린 서비스를 부탁하고, 부탁 결과를 봅니다.

AI 에게 넘기기ChatGPT 에서 열기 (새 창)Claude 에서 열기 (새 창)원문 열기

커넥터(연결 서비스)는 원포트 채팅 · 작업실이 Notion 같은 바깥 서비스의 글을 읽거나 쓰게 잇는 연결이에요. 이 장의 문 넷은 API 키로 커넥터를 보고, 안 열린 서비스를 부탁하고, 부탁 결과를 보는 문이에요. 모델을 부르는 키(sk-so-) 그대로 써요.

문 넷#

요청하는 일학생 키
GET /v1/connectors이 키에서 커넥터마다 state(지금 할 수 있는 일)와 reason(막힌 까닭)을 줘요.불러요 — blocked_by_admin · 저희가 부탁을 막은 not_verified 줄 말고는 모두 student_blocked
POST /v1/connectors/requests서비스 하나를 부탁해요. Idempotency-Key 가 꼭 있어야 해요.403 student_cannot_request
GET /v1/connectors/requests이 지갑의 부탁을 최근 것부터 50건까지 줘요.403 student_cannot_request
GET /v1/connectors/requests/{id}부탁 한 줄을 줘요.403 student_cannot_request

키는 머리글 Authorization: Bearer <키> 또는 x-api-key: <키> 로 실어요. 내 키가 학생 키인지는 GET /v1/key 의 audience 가 "student" 인지로 봐요(요금 없음) — "teacher" 나 null 이면 학생 키가 아니에요. 초·중·고 학교 지갑의 새 키는 「선생님」이 먼저 골라져 있어요 — 「학생」으로 만든 키로 선생님이 부탁하려면 그 키의 쓰임새를 「선생님」으로 바꿔요(바꾸는 곳은 아래 state 표 student_blocked 줄). 오류 몸통은 {"error": {"type", "code", "message", "reason", "connector_request_id", "request_id"}} 이고 type 과 code 는 같은 값이에요 — reason 은 403 connector_unavailable(커넥터 까닭)과 401 authentication_error(키 까닭 — 「인증」 장 「키가 안 열릴 때」 표)에 오고, 그 밖의 오류엔 칸이 없어요(null 로 오지 않아요). connector_request_id 는 그 403 의 reason 이 declined · not_verified 일 때만 와요 — 거절 · 막음이 적힌 그 서비스의 부탁 줄 id 라, GET /v1/connectors/requests/{id} 로 읽어 message(까닭)를 봐요. blocked_by_admin 엔 없어요. request_id 는 응답 머리글 request-id 와 같은 값이라 문의할 때 알려 주세요. message 는 사람에게 보일 문장이라 바뀔 수 있어요. 코드는 code 로 먼저 가르고, 그 안에서 reason 의 고정 이름을 보세요(아래 「오류」).

커넥터 목록 — GET /v1/connectors#

GET /v1/connectors — 응답 칸

파라미터받는 값저희가 하는 일
group{name, role} 또는 null스페이스 그룹 키(반 · 강좌 키)에만 와요. name 은 그룹 이름, role 은 그룹에 안 켜졌을 때(off_for_group) 켜 달라고 말할 쪽이에요 — group_admin(그 그룹 관리자) · wallet_admin(지갑 관리자). 그 밖의 키는 null 이고, 사람 이름은 오지 않아요.
connectors[].service서비스 id(소문자 · 숫자 · 하이픈)부탁 응답의 matched_service 와 같은 값이에요. 줄은 저희가 손님에게 연 서비스와 이 지갑에서 켜 둔 서비스만 와요 — 목록에 없는 서비스도 부탁은 할 수 있어요. 순서는 저희 목록 순서예요.
connectors[].name문자열화면에 보이는 서비스 이름이에요. 사람이 말한 이름과 견줄 땐 service · name 을 소문자로 바꾸고 공백 · 문장부호를 빼고 견줘요 — 한글로 적은 이름은 이 목록에서 안 맞을 수 있어요. 부탁 문은 자주 쓰는 한글 이름도 맞추지만, 이미 목록에 있는 서비스여도 새 부탁을 만들어요.
connectors[].statelinked · needs_link · requestable · pending · unavailable지금 이 키로 할 수 있는 일이에요(아래 표). 지갑에서 켰고 기한 · 그룹 · 저희 확인을 모두 통과한 줄은 state · reason 이 tools.read 쪽을 따라요(읽기 도구가 없는 서비스면 tools.write 쪽) — 그래서 쓰기만 막혀도 줄은 needs_link 로, 읽기만 막혀도 줄은 unavailable 로 보이니(그때도 tools.write 가 needs_link 면 link_url 이 와요), 도구마다 되는지는 tools 칸으로 가르세요. 그 밖의 줄은 줄 state 가 먼저 정해지고 두 칸이 그걸 따라요(requestable · pending 줄의 예외는 아래 tools 칸).
connectors[].reasonoff_for_group · student_blocked · student_write_blocked · blocked_by_admin · reapproval_needed · term_ended · not_verified · declinedstate 가 unavailable 일 때, 그리고 거절된 뒤 다시 부탁할 수 있는 requestable 일 때(declined)만 와요. 그 밖엔 칸이 없어요. 문장이 아니라 고정 이름이라 코드는 이 값으로 가르세요.
connectors[].tools.read · tools.write{state, reason} 또는 null읽기 도구 · 쓰기 도구를 따로 보여 줘요 — 「읽기는 되고 쓰기만 막힘」(reapproval_needed · not_verified)이 이렇게 보여요. 줄 state 가 requestable(다시 부탁할 수 있는 declined 포함) · pending 이어도, 그 종류에 쓸 수 있는 도구가 하나도 안 남으면(정의를 바꿨으면 reapproval_needed, 저희가 정의를 아직 못 봤으면 not_verified) 그 칸만 unavailable 이에요 — 줄 state 는 그대로라 다음 행동은 그 줄대로이고, 채팅 · 작업실에선 정의가 바뀌었거나 저희가 아직 못 본 그 도구만 숨고 같은 종류의 다른 도구는 그대로 써요. 지갑 관리자 · 저희가 막은 줄, 학생 키 줄, 다시 부탁할 수 없는 declined 줄, 기한이 지났거나 그룹에 안 켜진 줄은 두 칸이 줄의 state · reason 을 그대로 따라서, 그 종류 도구가 있는지(null 인지)만 알려 줘요. reason 은 그쪽 state 가 unavailable 일 때와, 거절 뒤 다시 부탁할 수 있는 requestable(declined)일 때만 와요. 그 종류 도구가 없는 서비스면 null 이에요(쓰기 도구가 없으면 write 가 null). 쓰기 도구는 채팅 · 작업실에서 실행 전에 사람 확인을 받아요.
connectors[].link_url주소 문자열tools.read · tools.write 가운데 하나라도 needs_link 면 와요 — 줄 state 와 상관없어요. 그래서 읽기 도구는 다시 확인 중(줄 state 가 unavailable)이고 tools.write 만 needs_link 인 줄에도 와요. 키를 산 곳(스쿨오더 · 오피스오더)의 그 서비스 연결 동의 화면 주소(…/ai/connect/<service>)예요 — 서비스마다 늘 같은 주소예요. 연결은 키 · 지갑이 아니라 링크를 열고 로그인한 사람에게 붙어요 — 사람마다 따로 잇고, 그 사람이 자기 채팅 · 작업실에서 써요. 이 지갑으로 쓰게 하려면 이 지갑에 초대된 분에게 건네요 — 초대되지 않은 분은 자기 개인 지갑으로 채팅 · 작업실을 써요. 채팅 첫 화면에서 용도를 고르는 지갑이면 「공부하는 데 써요」를 골랐거나 아직 안 고른 분이 열 때 연결이 막혀요 — 학교 지갑도 개인 지갑도 같아요(「수업과 학교 업무에 써요」 · 「일이나 개인 용도로 써요」를 고른 분은 이어요). 대학 지갑은 학생 자리도 이어요. 이건 링크를 연 사람의 자리 얘기라, 이 키가 학생 키(audience "student")인지와는 따로예요.
connectors[].valid_untilYYYY-MM-DD(한국 날짜)지갑에서 기한을 두고 켠 서비스에만 와요. 이 날이 지나면 이 문에서 unavailable · term_ended 예요.
connectors[].can_request_againtrue · falsereason 이 declined 인 줄엔 다시 부탁할 수 있는지가(지갑 관리자 거절이면 늘 true), 막힌 줄(blocked_by_admin · 부탁이 저희 확인에서 막힌 not_verified)엔 늘 false 가 와요. 그 밖엔 칸이 없어요.
connectors[].message문자열reason 이 declined 이고 지갑 관리자가 거절하며 한 줄을 남겼을 때만 와요. 사람에게 보여 줄 문장이라 코드는 reason 으로 가르세요. 저희가 거절한 까닭은 여기 오지 않고 부탁 줄의 message 에 와요.

503 api_error 는 저희가 상태를 잠깐 못 읽은 거예요 — 「막힌 것 없음」이나 「부탁 없음」으로 읽지 말고 잠시 뒤 다시 불러요. 학생 키로 불러도 200 이에요 — 지갑 관리자가 막은 줄(blocked_by_admin)과 저희가 부탁을 막은 줄(not_verified · can_request_again false) 말고는 모두 student_blocked 예요. 저희가 아직 안 연 서비스는 학생 키에도 줄이 없어요.

응답 예시 — 그룹 키 · 쓰기 도구만 다시 확인 중 · 그룹에 안 켜짐 · 지갑 관리자가 거절했지만 다시 부탁 가능
{
  "group": { "name": "과학 동아리", "role": "group_admin" },
  "connectors": [
    {
      "service": "notion",
      "name": "Notion",
      "state": "needs_link",
      "tools": {
        "read": { "state": "needs_link" },
        "write": { "state": "unavailable", "reason": "reapproval_needed" }
      },
      "link_url": "https://schoolorder.kr/ai/connect/notion",
      "valid_until": "2027-02-28"
    },
    {
      "service": "padlet",
      "name": "Padlet",
      "state": "unavailable",
      "reason": "off_for_group",
      "tools": {
        "read": { "state": "unavailable", "reason": "off_for_group" },
        "write": { "state": "unavailable", "reason": "off_for_group" }
      }
    },
    {
      "service": "kahoot",
      "name": "Kahoot!",
      "state": "requestable",
      "reason": "declined",
      "tools": {
        "read": { "state": "requestable", "reason": "declined" },
        "write": { "state": "requestable", "reason": "declined" }
      },
      "can_request_again": true,
      "message": "이번 학기엔 학교에서 쓰는 퀴즈 도구로 모아요."
    }
  ]
}

state · reason 으로 다음 행동 고르기#

까닭이 여럿이어도 reason 은 하나만 와요 — 지갑 관리자가 막은 것(blocked_by_admin)과 저희가 부탁을 막은 것(not_verified · can_request_again false)이 먼저고, 둘 다 해당하면 blocked_by_admin 이에요(부탁 문의 403 error.reason 도 같아요). 지갑에서 켠 서비스는 기한(term_ended) · 그룹(off_for_group)을 저희가 연 서비스인지(not_verified)보다 먼저 봐요 — 그래서 이 두 줄만으로는 저희가 연 서비스인지 알 수 없어요. 채팅 · 작업실에서 그 서비스가 안 보이면 원포트(admin@schoolorder.kr)에 물어보세요. tools.read · tools.write 의 state · reason 도 같은 이름이에요.

이 표는 이 키로 본 이 문의 상태예요. 원포트 채팅 · 작업실은 공개 서비스(Notion · Kahoot! · Padlet · Replit · Canva · Suno)면 지갑 결정 가운데 막기(blocked_by_admin)만 봐요 — 기한(term_ended) · 그룹(off_for_group) · 부탁 결과(pending · declined · 부탁이 막힌 not_verified) · 지갑 관리자 거절은 보지 않아서, 이런 줄이어도 채팅 · 작업실에선 그대로 써요. 그 밖의 서비스는 저희가 손님께 열었고 이 지갑에서 켜졌고(enabled) 기한 안이며 그 사람의 그룹에 닿을 때만 채팅 · 작업실에서 열려요. 어느 서비스든 쓰는 사람이 학생 자리면 닫히고, 저희가 도구를 다시 확인하는 중이면(tools 칸 reapproval_needed · not_verified) 그 종류 도구는 숨어요. 저희가 아직 손님께 열지 않은 서비스도 있어요(Zoom) — 채팅 · 작업실에 없고, 이 목록엔 지갑에서 켰을 때만 unavailable 줄로 보여요. 채팅은 닫힌 서비스를 입력창 [+] 목록에서 빼기만 하고 까닭은 적지 않아요. 작업실은 AI 가 지갑에서 닫힌 서비스를 부르면 막혔는지 · 아직 안 켜졌는지만 답에 적어요. 자세한 까닭은 이 문의 reason 으로 봐요.

statereason뜻다음 행동
linked—이 키에 서비스 계정까지 이어진 줄이에요(지금 API 키에선 오지 않아요 — 아래 needs_link).할 일 없어요.
needs_link—지갑에서 켰고 저희 확인도 끝났지만 이 키엔 서비스 계정이 이어지지 않았어요. API 키엔 계정을 잇는 자리가 없어서 linked 는 오지 않아요 — 쓸 수 있게 켜진 커넥터는 이 문에서 needs_link 로 보여요.link_url 을 사람에게 건네세요 — 원포트 채팅 · 작업실에서 그 서비스를 쓸 사람이에요. 주소엔 비밀값이 없어 메신저 · 메일로 보내도 되고, 기한도 없고 한 번만 쓰는 주소도 아니에요. 연 사람이 자기 계정으로 로그인해 저희 동의 화면에서 「<서비스 이름>에서 계속」(예: 「Notion에서 계속」)을 누르면 그 서비스의 로그인 · 허용 화면으로 넘어가고, 허용하면 내 계정 「연결한 서비스」로 돌아와요. 이 키로 잇는 API 문은 없고, 이어도 이 키의 줄은 needs_link 그대로예요.
requestable—저희가 연 서비스예요 — 이 지갑에서 따로 켜지 않았고, 결과를 기다리는 부탁도 없어요. 공개 서비스는 지갑에서 켜지 않아도 원포트 채팅 · 작업실에서 보여요(학생 자리는 빼고). 그 밖의 서비스는 지갑 관리자가 켜야 열려요.공개 서비스면 부탁할 필요 없어요 — 사람에게 원포트 채팅 · 작업실에서 쓰면 된다고 알려 주세요. 그 밖의 서비스면 지갑 관리자에게 물어보세요. 처음 쓸 땐 사람마다 그 서비스 계정을 이어요 — 이 줄엔 link_url 이 없지만 키를 산 곳의 같은 동의 화면에서도 이어요(스쿨오더 https://schoolorder.kr/ai/connect/<service> · 오피스오더 https://officeorder.co.kr/ai/connect/<service>) — 이어도 이 키의 줄은 requestable 그대로예요(API 키엔 연결이 안 보여요). 부탁(POST /v1/connectors/requests)은 이 목록에 없는 서비스에 써요.
requestabledeclined앞서 거절됐지만 다시 부탁할 수 있어요(can_request_again 이 true) — 부탁이 저희 확인에서 거절됐거나, 저희 확인이 끝난 뒤 지갑 관리자가 거절했어요. 지갑 관리자가 거절하며 한 줄을 남겼으면 이 줄의 message 로 와요. 어느 쪽 거절인지는 부탁 줄로 가려요 — 부탁 state 가 declined 면 저희, approved 이고 wallet_decision 이 declined 면 지갑 관리자예요. 공개 서비스면 채팅 · 작업실은 부탁 결과도 지갑 관리자 거절도 보지 않아요(그 밖의 서비스는 닫혀 있어요).저희 거절이면 부탁 줄의 message 를 보고 고쳐서 다시 부탁해요. 지갑 관리자 거절이면 이 줄의 message 를 사람에게 보여 주세요 — 다시 부탁하면 저희 확인 없이 approved 새 부탁 줄이 서고, 지갑 관리자가 다시 정할 때까지 이 줄은 pending 이에요. 그다음 지갑 관리자가 고를 수 있는 건 켜기 · 다시 거절 · 막기예요 — 모두 켜면 needs_link(몇몇 그룹에만 켜면 그 밖의 키는 off_for_group), 다시 거절하면 이 줄이 requestable · declined 로 돌아오고(그때 남긴 한 줄은 message 로), 막으면 blocked_by_admin 이 돼 채팅 · 작업실에서도 빠져요. 지갑 관리자에게 물어보세요.
pending—결과를 기다리는 부탁이 있어요 — 이 지갑의 다른 키가 낸 부탁도 여기에 들어요. 지갑 관리자가 거절한 뒤 다시 부탁했으면 지갑 관리자가 다시 정하길 기다리는 줄이에요(그 부탁의 state 는 approved). 공개 서비스면 기다리는 동안에도 채팅 · 작업실에선 써요(그 밖의 서비스는 지갑 관리자가 켜야 열려요).공개 서비스면 사람에게 원포트 채팅 · 작업실에서 쓰면 된다고 알려 주세요. 부탁 결과는 GET /v1/connectors/requests 로 그 부탁을 찾아 봐요. 같은 서비스를 또 부탁하면 그 줄로 합쳐져요(200 — 지갑 관리자를 기다리는 approved 줄은 같은 그룹의 키끼리 · 그룹 없는 키끼리만). 그 부탁이 approved 면 이 문의 줄은 지갑 관리자가 다시 정할 때까지 pending 이에요 — 바꾸려면 지갑 관리자에게 물어보세요.
unavailableblocked_by_admin이 지갑의 관리자가 이 서비스를 막았어요(can_request_again 은 false).지갑 관리자에게 물어보세요 — 지갑 관리자가 그 서비스를 다시 켜면 풀려요. 부탁해도 403 이고 API 로는 못 풀어요.
unavailablenot_verified저희(원포트)가 아직 열지 않았거나 막은 서비스예요. 부탁이 저희 확인에서 막혔으면 can_request_again 이 false 로 와요 — 이건 부탁 문의 막힘이고, 공개 서비스면 채팅 · 작업실은 부탁 줄을 보지 않아요. can_request_again 칸이 없으면 지갑에서 켰지만 저희가 그 서비스를 열지 않았거나(닫은 것 포함) 그 서비스의 도구를 아직 확인하지 못한 거예요 — 그 서비스(도구만이면 그 종류 도구)는 채팅 · 작업실에서도 안 보여요. 도구를 못 본 건 requestable · pending 줄이면 tools 칸에만 와요.원포트(admin@schoolorder.kr)에 물어보세요. 지갑 관리자가 풀 수 있는 게 아니에요.
unavailableoff_for_group지갑에서 몇몇 그룹에만 켰고 이 키의 그룹은 빠졌어요(그룹이 없는 키도 이래요). 공개 서비스면 채팅 · 작업실은 그룹 켜기를 보지 않아요 — 그 밖의 서비스는 채팅 · 작업실도 같은 까닭으로 닫혀요.group.role 쪽에 이 그룹에도 켜 달라고 해요 — group_admin 이면 그 그룹 관리자, wallet_admin 이거나 group 이 null 이면 지갑 관리자예요.
unavailablestudent_blocked이 키가 학생 키라 이 문에선 통째로 닫혔어요. 채팅 · 작업실은 키가 아니라 쓰는 사람의 자리로 열고 닫아서, 선생님 자리의 채팅은 이 줄과 상관없어요.선생님(키를 나눠 준 분)께 말해요. 학생 키는 부탁 · 부탁 목록 문도 403 student_cannot_request 예요. 키 쓰임새를 「선생님」으로 바꾸면 다음 요청부터 풀려요 — 학교 지갑 키는 지갑 주인이 대시보드 AI API 화면 키 목록의 쓰임새 칩에서(관리 키면 PATCH /v1/keys/{id} 에 "audience": "teacher"), 스페이스 키는 스페이스 주인 · 관리자가 그 키 줄의 칩에서 바꿔요. 바꿔도 API 키로 커넥터를 쓰진 못해요 — 열린 서비스는 원포트 채팅 · 작업실에서 써요.
unavailablestudent_write_blocked학생 키에서 쓰기 도구만 닫혔다는 이름이에요 — tools.write 에만 붙어요. 지금은 학생 키가 통째로 student_blocked 라 오지 않아요.쓰기 없이 읽기만 써요.
unavailableterm_ended지갑에서 기한을 두고 켠 서비스인데 그 날(valid_until)이 지났어요. 공개 서비스면 채팅 · 작업실은 이 기한을 보지 않아요 — 그 밖의 서비스는 채팅 · 작업실도 같은 까닭으로 닫혀요.기한을 늘리려면 지갑 관리자에게 물어보세요.
unavailablereapproval_needed그 서비스가 도구 정의를 바꿔서, 저희가 다시 확인할 때까지 그 도구를 숨겼어요. 쓰기 도구만 이럴 수도 있어요(tools.write). 줄 reason 으로는 지갑에서 켠 줄에만 와요 — requestable · pending 줄엔 tools 칸에만 와요.부탁할 필요 없어요. 나중에 다시 불러 보세요. 읽기 도구만 다시 확인 중이고 쓰기 도구는 이을 수 있으면 이 줄에도 link_url 이 와요 — 쓰기 도구를 쓸 사람에게 건네요.
unavailabledeclined부탁이 저희 확인에서 거절됐고 다시 부탁할 수 없어요(can_request_again 이 false). 공개 서비스면 채팅 · 작업실은 부탁 결과를 보지 않아요(그 밖의 서비스는 닫혀 있어요).부탁 줄의 message 를 보세요. 다시 부탁하면 403 connector_unavailable(reason declined)이에요. 다시 부탁할 수 없게 거절한 건 저희라 원포트(admin@schoolorder.kr)에 물어보세요 — 지갑 관리자 거절은 늘 다시 부탁할 수 있어서 이 줄이 되지 않아요.

부탁하기 — POST /v1/connectors/requests#

POST /v1/connectors/requests — JSON 본문과 요청 헤더

파라미터받는 값저희가 하는 일
Authorization 또는 x-api-key필수Bearer sk-so-… 또는 sk-so-…부탁은 그 키가 속한 지갑에 쌓여요. 학생 키는 403 student_cannot_request 예요.
Idempotency-Key필수공백 · 제어문자 없는 ASCII(영문 · 숫자 · 기호) 8~200자커넥터 부탁 문(POST /v1/connectors/requests)은 Idempotency-Key 가 꼭 있어야 해요(없거나 꼴이 틀리면 400) — 글 문의 409 request_already_exists 와 조회 주소(/v1/requests/<id>)는 이 문엔 없어요. 새 부탁이면 201, 이미 있던 줄이면 200 으로 부탁 한 줄이 오고, 결과는 그 줄의 state 칸(received · reviewing · approved · declined · blocked)으로 봐요. 같은 API 키로 같은 값 · 같은 본문을 다시 보내면 새 줄 없이 그 부탁을 200 으로 돌려줘요 — 그사이 결과가 나왔어도(approved · declined · blocked) 그 줄이 와요(Location 은 /v1/connectors/requests/{id}). 그 줄이 declined 이고 can_request_again 이 true 면 고쳐서 새 값으로 다시 부탁해요 — 같은 값이면 그 거절 줄이 또 와요. 받은 값은 기한 없이 기억해요 — 지난 부탁에 쓴 값을 새 부탁에 다시 쓰면 새 줄이 아니라 그 옛 줄(200)이나 409 가 와요. 같은 값으로 본문을 바꾸면 409 idempotency_conflict 예요 — 그 값으로 먼저 보낸 부탁은 이미 접수돼 있어요. x-should-retry: false 가 붙어서 SDK 가 스스로 다시 보내지 않아요(이 문엔 SDK 함수가 없어서, SDK 의 저수준 호출로 부를 때 얘기예요). 400 · 401 · 403 · 413 · 429 거절은 그 값에 남지 않아서 고친 요청에 같은 값을 써도 돼요. 503 api_error 를 받았거나 응답을 못 받았으면 같은 값 · 같은 본문으로 다시 보내요 — 저희가 이미 받은 부탁이면 새 줄 없이 그 줄이 200 으로 와요. 칸 순서 · 앞뒤 공백만 다른 건 같은 본문이에요. 다른 부탁엔 다른 값을 써요. 받은 값은 API 키마다 따로 기억해요 — 「새로 받기」로 바꾼 키로 같은 값을 보내면 새 부탁으로 읽고 아래 합치기 규칙대로 해요. 키를 바꿨으면 먼저 GET /v1/connectors/requests 로 앞 부탁을 찾아요.
service_name필수1~100자 문자열서비스 이름을 사람 말로 적어요. 대소문자 · 띄어쓰기 · 문장부호는 빼고 견주고, 덧말은 낱말 단위로 앱 · 어플 · app · 연동 · 연결 · 커넥터 · connector · mcp · 서비스 · service 만 빼요 — 「Padlet 게시판」처럼 다른 낱말이 붙으면 Padlet 과 다른 부탁으로 쌓여요. 자주 쓰는 한글 이름 몇몇(노션 · 카훗 · 패들렛 · 레플릿 · 캔바 · 구글 클래스룸 · 구글 드라이브 등)은 같은 서비스로 맞춰요 — 회사가 쓰는 이름(영어)으로 적으면 가장 잘 맞아요. 그 밖의 한글 이름(예: 「시소」)은 영어 이름과 다른 부탁으로 따로 쌓여요. 저희가 손님에게 연 서비스와 맞으면 응답 matched_service 에 그 id 가 와요.
reason500자까지 문자열 또는 null왜 필요한지 한두 문장이에요. 저희가 확인할 때 보고, 지갑 관리자가 정할 때도 부탁한 쪽 역할과 같이 보여요. 만든 응답과 한 줄 조회에만 실리고 목록엔 안 실려요. GET /v1/connectors 줄의 reason · 403 의 error.reason(막힌 까닭)과는 이름만 같은 다른 칸이에요.
그 밖의 칸받지 않아요모르는 칸이 있으면 400 이에요. JSON 본문 전체는 8,000바이트까지예요(넘으면 413 invalid_request_error).

이 지갑에서 같은 서비스(맞춘 서비스가 없으면 견준 이름)를 이미 기다리는 부탁이 있으면 새 줄 없이 그 줄을 200 으로 돌려줘요 — 다른 키가 낸 부탁이어도 합쳐져요. 합쳐지면 이번에 보낸 reason 은 남지 않고, 응답엔 처음 부탁의 reason 이 와요. 앞 부탁이 저희 확인이 끝난(approved) 줄이면 저희 확인을 다시 거치지 않아요 — 같은 자리(같은 그룹의 키끼리 · 그룹 없는 키끼리)에서 지갑 관리자가 마지막으로 정한 뒤(아직 안 정했으면 언제든) 낸 approved 부탁이 있으면 그 줄이 200 으로 오고, 없으면(그 자리의 첫 부탁 · 지갑 관리자가 정한 뒤 첫 다시 부탁) state 가 approved 인 새 줄(201)이 서서 지갑 관리자 차례로 가요. 앞 부탁이 declined 인데 다시 부탁할 수 있으면 received 새 줄(201)이에요. 부탁 문은 그 서비스가 GET /v1/connectors 목록에 있는지 보지 않아요 — 목록에 있는 서비스는 부탁하지 않아도 돼요. 낸 부탁을 고치거나 거두는 문은 없어요(PATCH · DELETE 는 405 method_not_allowed) — 고칠 게 있으면 부탁 id 를 적어 admin@schoolorder.kr 로 보내 주세요.

429 too_many_open 이면 앞 부탁의 결과가 나온 뒤 같은 값으로 다시 보내도 돼요. 오류마다 할 일은 아래 「오류」 표예요.

부탁하기 — 응답을 못 받았으면 같은 Idempotency-Key 로 다시
curl https://oneport.kr/v1/connectors/requests \
  -H "Authorization: Bearer $ONEPORT_API_KEY" \
  -H 'Content-Type: application/json' \
  -H 'Idempotency-Key: jotform-request-001' \
  -d '{"service_name":"Jotform","reason":"설문 응답을 모아 보려고요"}'
201 응답 예시(Location: /v1/connectors/requests/00000000-0000-4000-8000-000000000010)
{
  "id": "00000000-0000-4000-8000-000000000010",
  "object": "connector_request",
  "service_name": "Jotform",
  "matched_service": null,
  "state": "received",
  "wallet_decision": null,
  "requested_by": { "role": "wallet_key" },
  "reason": "설문 응답을 모아 보려고요",
  "created_at": "2026-10-30T01:00:00+00:00",
  "updated_at": "2026-10-30T01:00:00+00:00"
}

부탁 결과 보기 — GET /v1/connectors/requests · /{id}#

부탁 한 줄(object connector_request)의 칸

파라미터받는 값저희가 하는 일
idUUIDLocation 의 끝과 같은 값이에요. GET /v1/connectors/requests/{id} 로 그 줄을 다시 읽어요.
service_name문자열보낸 이름 그대로예요(앞뒤 공백만 빼요). 같은 줄에 합쳐졌으면 처음 부탁한 쪽이 적은 이름이에요.
matched_service서비스 id 또는 null저희가 손님에게 연 서비스와 맞을 때만 와요 — GET /v1/connectors 의 service 와 같은 값이에요. 저희가 확인하면서 맞춰 넣으면 나중에 채워질 수 있어요.
statereceived · reviewing · approved · declined · blocked부탁의 결과예요(아래 표). GET /v1/connectors 의 state 와 이름이 다른 별개의 값이에요.
message문자열state 가 declined 면 와요(저희는 거절할 때 까닭을 꼭 적어요). blocked 는 저희가 까닭을 적었을 때, approved 는 이 부탁 뒤에 지갑 관리자가 거절하며 한 줄을 남겼을 때만 와요. 사람에게 보여 줄 문장이에요.
can_request_againtrue · falsestate 가 declined · blocked 일 때만 와요. blocked 면 늘 false 예요.
wallet_decisionenabled · declined · blocked · null이 지갑의 관리자가 그 서비스를 어떻게 정했는지예요 — enabled(켬) · declined(거절) · blocked(막음) · null(아직 안 정했거나 맞춘 서비스가 없어요). 부탁 한 줄의 값이 아니라 그 서비스에 대한 이 지갑의 지금 결정이라, 같은 서비스의 옛 부탁 줄에도 같은 값이 와요. 저희 확인(state)과 따로 서요 — 저희가 확인하는 중이어도 지갑이 이미 정했으면 보여요. 목록 · 한 줄 조회 · 만든 응답(POST) 모두 실려요. 만든 응답에서만 칸이 빠질 수 있는데, 저희가 지갑 결정을 잠깐 못 읽은 거예요 — 칸이 없으면 「모름」, null 은 「아직 결정 없음」으로 읽으세요. 목록 · 한 줄 조회는 못 읽으면 503 이에요. 예외는 하나예요 — 지갑 관리자가 거절한 뒤 다시 부탁해 선 approved 새 줄엔 wallet_decision 이 null 이고 message 도 없어요. 옛 거절은 이 부탁의 답이 아니라서예요. 지갑 관리자가 다시 정하면 그 결정이 와요. 그 앞의 옛 줄엔 지금 결정이 와요(거절이면 지금 남긴 한 줄도) — 그래서 지갑 관리자가 다시 정하면 옛 줄 값도 같이 바뀌어요. 이번 부탁의 결과는 저장해 둔 이번 줄 id 로 읽어요 — null 이던 이번 줄에 값이 오면 지갑 관리자가 정한 거예요. 기다리는 중인지는 GET /v1/connectors 의 그 서비스 줄이 pending 인지로 봐요.
requested_by.roleowner · admin · group_admin · member · wallet_key · group_key부탁한 쪽의 역할만 와요(사람 이름 · 키 이름은 안 와요). API 키로 낸 부탁은 wallet_key(지갑 키) · group_key(스페이스 그룹 키)예요. 모르는 값은 건너뛰게 짜세요.
reason문자열 또는 null부탁한 까닭이에요. 만든 키의 응답에만 와요 — 목록 줄엔 이 칸만 없고, message · can_request_again · wallet_decision 은 목록에도 와요.
created_at · updated_atISO 8601 시각부탁을 처음 받은 때와 마지막으로 바뀐 때예요. 저희가 확인하며 그 줄을 바꾸면(state · matched_service) updated_at 이 바뀌어요 — 지갑 관리자 결정으론 안 바뀌니, 그건 wallet_decision 과 GET /v1/connectors 줄로 봐요.
부탁 state뜻다음 행동
received부탁을 받았어요.기다려요.
reviewing저희가 그 서비스의 공식 연결을 확인하고 있어요.기다려요.
approved저희 확인이 끝났어요. wallet_decision 이 null 일 때 GET /v1/connectors 의 그 줄은, 처음 확인이 끝난 부탁이면 requestable 이고 지갑 관리자 거절 뒤 다시 부탁한 줄이면 지갑 관리자가 다시 정할 때까지 pending 이에요. 공개 서비스면 어느 쪽이든 그 결정을 기다릴 일은 아니에요 — 채팅 · 작업실은 공개 서비스엔 지갑 결정 가운데 막기만 봐요.그 message 가 있으면 사람에게 보여 주세요. matched_service 가 오면 GET /v1/connectors 의 그 service 줄대로 해요(공개 서비스의 requestable 이면 원포트 채팅 · 작업실에서 써요). matched_service 가 null 이면 저희가 공개하기 전이에요 — 기다리거나 원포트(admin@schoolorder.kr)에 물어보세요. 다시 부탁해도 저희 확인을 다시 거치지 않고 지갑 관리자 차례로 가요(위 「부탁하기」).
declined거절됐어요. message 에 저희가 적은 까닭이 와요.can_request_again 이 true 면 고쳐서 다시 부탁할 수 있어요. false 면 다시 부탁해도 403 connector_unavailable(reason declined)이에요.
blocked저희(원포트)가 막았어요. can_request_again 은 늘 false 예요.그 서비스가 GET /v1/connectors 목록에 있으면 not_verified 로 보여요 — 저희가 아직 안 연 서비스면 줄이 없어요. 같은 서비스를 다시 부탁하면 403 connector_unavailable(reason not_verified)이에요. 원포트(admin@schoolorder.kr)에 물어보세요.

부탁 한 줄로 결과 읽기 — approved 줄은 wallet_decision 으로 사람에게 알릴 말이 갈려요(received · reviewing · declined · blocked 는 위 표 그대로예요).

부탁 statewallet_decision사람에게 알릴 것
approvednull지갑 관리자 결정 전이에요.
approvedenabled지갑에서 켰어요 — GET /v1/connectors 의 그 줄대로 해요(needs_link 면 link_url 을 건네요).
approveddeclined지갑 관리자가 거절했어요 — message 가 오면 그분이 남긴 한 줄이에요. 이 줄엔 can_request_again 이 없어요(다시 부탁돼요 — state 표 requestable · declined 줄).
approvedblocked지갑 관리자가 막았어요 — 다시 부탁하면 403 connector_unavailable(reason blocked_by_admin)이에요.

목록(GET /v1/connectors/requests)은 {"object": "list", "data": [ … ], "has_more": true · false} 이고, 이 지갑의 부탁을 최근 것부터 50건까지 줘요(다른 키가 낸 부탁도 들어요). has_more 가 true 면 더 오래된 부탁이 있다는 뜻이에요 — 다음 쪽을 넘기는 칸은 없으니 보관해 둔 id 로 한 줄씩 읽어요. 건수 칸은 없어요. 목록의 service_name 은 보낸 글자 그대로라, 이름으로 견주면 「구글클래스룸 앱」처럼 적힌 부탁을 놓칠 수 있어요 — 부탁하기 전에 목록을 이름으로 뒤질 필요는 없어요(결과를 기다리는 부탁 · 확인이 끝난 부탁엔 위 규칙대로 이어져요). 결과는 저희가 확인한 뒤 바뀌고, 바뀌어도 메일 · 웹훅으로 알려 드리지 않아요 — 짧은 간격으로 되풀이해 부르지 말고, 사람에게 부탁했다고 알린 뒤 나중에 다시 보세요. id 가 UUID 꼴이 아니거나 이 지갑의 부탁이 아니면 404 not_found_error 예요.

오류#

상태code언제할 일
401authentication_error키가 없거나 쓸 수 없는 키예요 — error.reason 이 까닭이에요(「인증」 장의 「키가 안 열릴 때」 표).키 · 머리글을 확인해요.
400invalid_request_errorIdempotency-Key 가 없거나 꼴이 틀렸어요 · JSON 이 아니에요 · service_name · reason 꼴이 틀렸거나 모르는 칸이 있어요.고쳐서 보내요.
400reason_required지갑 관리자가 사유 필수(부탁할 때 어디에 쓰는지 꼭 적게 하기)를 켠 지갑에서 POST 의 reason 을 비워 보냈어요.reason 에 어디에 쓰는지 적어 다시 보내요.
413invalid_request_error본문이 8,000바이트를 넘었어요.reason 을 줄여요.
403student_cannot_request학생 키로 부탁 · 부탁 목록 · 한 줄 조회를 불렀어요.선생님께 말해요.
403connector_unavailable부탁할 수 없는 서비스예요 — error.reason 이 blocked_by_admin · not_verified · declined 중 하나예요. declined · not_verified 는 이 지갑에서 앞서 낸 같은 서비스 부탁이 닫힌 거라 그 부탁 줄 id(error.connector_request_id)가 늘 오고, 이번 요청으로 새 줄은 안 생겨요. declined 는 늘 저희(원포트)가 다시 부탁할 수 없게(can_request_again false) 거절한 거예요 — 지갑 관리자 거절은 403 이 아니라 다시 부탁돼요. not_verified 는 그 부탁을 저희가 막았을 때(부탁 state blocked)만 와요 — 아직 안 연 서비스는 부탁하면 받아요(새 부탁이면 201 received). blocked_by_admin 엔 id 가 없고, 그 message 는 정해진 안내 문장이에요 — 지갑 관리자가 막은 까닭은 API(이 403 · GET /v1/connectors · 부탁 줄)로 오지 않아요.위 표의 그 reason 줄대로 해요. 다시 보내도 같아요. connector_request_id 가 오면 그 id 로 GET /v1/connectors/requests/{id} 를 읽어 message 를 봐요.
404not_found_error이 지갑의 부탁이 아닌 id 예요.받은 id · Location 을 확인해요.
409idempotency_conflict같은 Idempotency-Key 로 다른 본문을 보냈어요(x-should-retry: false — SDK 가 스스로 다시 보내지 않아요).처음 본문 그대로 보내거나, 다른 부탁이면 다른 값을 써요.
429too_many_open이 지갑에서 결과를 기다리는(received · reviewing) 부탁이 20건 찼어요(x-should-retry: false).앞 부탁의 결과가 나온 뒤 다시 보내요.
503api_error저희가 상태를 잠깐 못 읽었거나 부탁을 확인하지 못했어요(키를 잠깐 못 읽었을 때도 와요).GET 은 잠시 뒤 다시 불러요 · POST 는 같은 Idempotency-Key 로 다시 보내요. 이 장의 문엔 Retry-After 가 오지 않아요 — 몇 초 쉬고 다시 해요.
403 응답 예시 — 다시 부탁할 수 없는 거절(declined) · message 는 줄였어요
{
  "error": {
    "type": "connector_unavailable",
    "code": "connector_unavailable",
    "message": "…",
    "reason": "declined",
    "connector_request_id": "00000000-0000-4000-8000-000000000010",
    "request_id": "00000000-0000-4000-8000-0000000000a1"
  }
}

값#

이 장의 문 넷은 요금이 안 붙어요. 채팅 · 작업실에서 커넥터가 가져온 글은 모델 입력 토큰으로 세요 — 커넥터 자체 요금은 없어요. 채팅 한 질문에서 커넥터는 4번까지 읽고, 한 번 읽은 글은 8,000자까지 모델에 실어요 — 넘으면 뒤가 잘려요(Google Drive 의 긴 글 · 파일은 첨부로 실려요). 작업실은 한 번 읽은 글을 20,000자까지 실어요. 긴 페이지를 통째로 요약하려면 그 서비스의 API 로 글을 직접 읽어 글 문(/v1/chat/completions 등)에 나눠 보내요.

예제 — 목록을 보고, 없으면 부탁하고, reason 으로 가르기#

Python(requests)
import os
import re
import uuid

import requests

BASE = "https://oneport.kr"
HEADERS = {"Authorization": f"Bearer {os.environ['ONEPORT_API_KEY']}"}
WANT = "Jotform"  # 사람이 쓰고 싶다고 한 서비스 이름
ONEPORT = "원포트(admin@schoolorder.kr)에 물어보세요."
# 지갑 관리자 몫 — 지갑에서 켜고 · 거절하고 · 막는 건 지갑 관리자예요.
WALLET = "지갑 관리자에게 물어보세요."
# 지갑에서 켜지 않아도 원포트 채팅 · 작업실에 열리는 공개 서비스 — 그 밖의 서비스는 지갑에서 켜야 열려요.
PUBLIC = {"notion", "kahoot", "padlet", "replit", "canva", "suno"}

# 막힌 까닭(reason)마다 누구에게 물을지 — message 문장으로 가르지 말고 이 이름으로 가르세요.
ASK = {
    "blocked_by_admin": "지갑 관리자가 막았어요. " + WALLET,
    "not_verified": ONEPORT,
    "off_for_group": "이 그룹엔 안 켜졌어요. group.role 쪽(그룹 관리자 · 지갑 관리자)에게 이 그룹에도 켜 달라고 하세요.",
    "student_blocked": "학생 키예요. 선생님께 말해 주세요.",
    "term_ended": "켜 둔 기한이 지났어요. " + WALLET,
    "reapproval_needed": "저희가 다시 확인하는 중이에요. 나중에 다시 보세요.",
    "declined": "거절됐어요. 저희 거절이면 부탁 줄의 message 를 보세요. 지갑 관리자 거절이면 이 줄의 message 를 보세요 — " + WALLET,
}


def same(name):
    """목록 줄과 견주기용 이름 — 소문자로 바꾸고 공백 · 문장부호를 빼요. 한글로 적은 이름은 목록에서 안 맞을 수 있어요."""
    return re.sub(r"[\W_]+", "", name.lower())


listing = requests.get(f"{BASE}/v1/connectors", headers=HEADERS, timeout=30)
listing.raise_for_status()  # 503 이면 잠시 뒤 다시 — 빈 목록으로 읽지 마세요
row = next((c for c in listing.json()["connectors"] if same(WANT) in (same(c["service"]), same(c["name"]))), None)

if row is not None:
    if row.get("reason"):
        print(ASK.get(row["reason"], ONEPORT), row.get("message", ""))
    elif row["state"] == "requestable" and row["service"] in PUBLIC:
        print("원포트 채팅 · 작업실에서 쓸 수 있어요. 부탁할 필요 없어요.")
    elif row["state"] == "requestable":
        print("지갑에서 켜야 원포트 채팅 · 작업실에서 열려요. " + WALLET)
    elif row["state"] == "needs_link":
        print("이 키로는 못 써요. 사람에게 이 주소를 건네 원포트 채팅 · 작업실에서 이어 쓰게 해요:", row["link_url"])
    else:  # pending
        print("원포트 채팅 · 작업실에선 공개 서비스면 지금도 써요. 결과를 기다리는 부탁은 GET /v1/connectors/requests 로 봐요 — 그 부탁이 approved 면 이 줄은 지갑 관리자가 다시 정할 때까지 pending 이에요. 바꾸려면 " + WALLET)
    # 쓰기 도구가 닫힌 건 tools.write 로만 보여요 — needs_link 줄 state 는 읽기 쪽이고, requestable · pending 줄 state 는 도구 칸과 따로 서요.
    write = (row.get("tools") or {}).get("write")
    if row["state"] in ("needs_link", "requestable", "pending") and write and write["state"] == "unavailable":
        print("쓰기 도구는 지금 꺼져 있어요.", ASK.get(write.get("reason"), ONEPORT))
    # link_url 은 tools.read · tools.write 가운데 하나라도 needs_link 면 와요 — 읽기 도구를 다시 확인하는 동안(줄 unavailable)에도 와요.
    if row["state"] != "needs_link" and row.get("link_url"):
        print("쓰기 도구는 이어서 쓸 수 있어요. 사람에게 이 주소를 건네요:", row["link_url"])
else:
    # 목록에 없는 서비스만 부탁해요. 이름 견주기는 저희가 해요 — 결과를 기다리는 같은 서비스 부탁이 있으면 그 줄로 이어져요.
    # 저희 확인이 끝난(approved) 부탁이 있으면 저희 확인 없이 그 줄로 이어지거나(같은 그룹) approved 새 줄이 서요.
    idem = str(uuid.uuid4())  # 응답을 못 받았으면(503 · 연결 끊김) 이 값 그대로 다시 보내요 — 다른 부탁엔 새 값
    r = requests.post(
        f"{BASE}/v1/connectors/requests",
        headers={**HEADERS, "Idempotency-Key": idem},
        json={"service_name": WANT, "reason": "설문 응답을 모아 보려고요"},
        timeout=30,
    )
    if r.status_code in (200, 201):
        req = r.json()
        if req["state"] == "approved" and req.get("matched_service"):
            # 한글 이름 등으로 위 목록 견주기는 못 맞췄지만 저희가 연 서비스예요 — 목록의 그 service 줄대로 해요.
            print("열린 서비스예요 — GET /v1/connectors 의", req["matched_service"], "줄을 보세요.")
        elif req["state"] == "approved":
            print("저희 확인은 끝났고 공개 전이에요.", ONEPORT)
        else:  # 결과는 GET /v1/connectors/requests/{id}
            print("부탁했어요:" if r.status_code == 201 else "이미 있던 부탁이에요:", req["id"], req["state"])
    else:
        err = r.json()["error"]
        if err["code"] == "student_cannot_request":
            print(ASK["student_blocked"])
        else:
            # 403 의 declined 는 늘 저희 거절이에요 — 지갑 관리자 거절은 403 이 아니라 다시 부탁돼요.
            # blocked_by_admin 의 message 는 정해진 안내 문장이라 까닭으로 보이지 마세요(까닭은 API 로 오지 않아요).
            print(err["code"], ONEPORT if err.get("reason") == "declined" else ASK.get(err.get("reason"), err["message"]))
            # declined · not_verified 403 엔 그 부탁 줄 id 가 와요(blocked_by_admin 엔 없어요) — 까닭은 그 줄의 message 예요.
            if err.get("connector_request_id"):
                s = requests.get(f"{BASE}/v1/connectors/requests/{err['connector_request_id']}", headers=HEADERS, timeout=30)
                if s.status_code == 200:
                    print("까닭:", s.json().get("message") or "적힌 까닭이 없어요")
                elif s.status_code == 503:  # 저희가 잠깐 못 읽었어요 — Retry-After 가 없으니 몇 초 쉬고 다시 봐요
                    print("까닭을 지금 못 읽었어요 — 잠시 뒤 다시 보세요")
                else:
                    print("까닭을 못 읽었어요:", s.json()["error"]["code"])


def request_state(request_id):
    """부탁 한 줄의 결과 — (state, message, can_request_again, wallet_decision). wallet_decision 칸이 없으면 「모름」이에요."""
    s = requests.get(f"{BASE}/v1/connectors/requests/{request_id}", headers=HEADERS, timeout=30)
    s.raise_for_status()
    body = s.json()
    return body["state"], body.get("message"), body.get("can_request_again"), body.get("wallet_decision")
Node 18+(fetch)
import { randomUUID } from "node:crypto";

const BASE = "https://oneport.kr";
const headers = { Authorization: `Bearer ${process.env.ONEPORT_API_KEY}` };
const WANT = "Jotform"; // 사람이 쓰고 싶다고 한 서비스 이름
const ONEPORT = "원포트(admin@schoolorder.kr)에 물어보세요.";
// 지갑 관리자 몫 — 지갑에서 켜고 · 거절하고 · 막는 건 지갑 관리자예요.
const WALLET = "지갑 관리자에게 물어보세요.";
// 지갑에서 켜지 않아도 원포트 채팅 · 작업실에 열리는 공개 서비스 — 그 밖의 서비스는 지갑에서 켜야 열려요.
const PUBLIC = ["notion", "kahoot", "padlet", "replit", "canva", "suno"];

// 막힌 까닭(reason)마다 누구에게 물을지 — message 문장으로 가르지 말고 이 이름으로 가르세요.
const ASK: Record<string, string> = {
  blocked_by_admin: "지갑 관리자가 막았어요. " + WALLET,
  not_verified: ONEPORT,
  off_for_group: "이 그룹엔 안 켜졌어요. group.role 쪽(그룹 관리자 · 지갑 관리자)에게 이 그룹에도 켜 달라고 하세요.",
  student_blocked: "학생 키예요. 선생님께 말해 주세요.",
  term_ended: "켜 둔 기한이 지났어요. " + WALLET,
  reapproval_needed: "저희가 다시 확인하는 중이에요. 나중에 다시 보세요.",
  declined: "거절됐어요. 저희 거절이면 부탁 줄의 message 를 보세요. 지갑 관리자 거절이면 이 줄의 message 를 보세요 — " + WALLET,
};
// 목록 줄과 견주기용 이름 — 소문자로 바꾸고 공백 · 문장부호를 빼요. 한글로 적은 이름은 목록에서 안 맞을 수 있어요.
const same = (name: string) => name.toLowerCase().replace(/[^\p{L}\p{N}]+/gu, "");

const listing = await fetch(`${BASE}/v1/connectors`, { headers });
if (!listing.ok) throw new Error(`GET /v1/connectors ${listing.status} — 503 이면 잠시 뒤 다시`);
type Gate = { state: string; reason?: string };
type Row = { service: string; name: string; state: string; reason?: string; message?: string; link_url?: string; tools?: { read: Gate | null; write: Gate | null } };
const { connectors } = (await listing.json()) as { connectors: Row[] };
const row = connectors.find((c) => [same(c.service), same(c.name)].includes(same(WANT)));

if (row) {
  if (row.reason) console.log(ASK[row.reason] ?? ONEPORT, row.message ?? "");
  else if (row.state === "requestable" && PUBLIC.includes(row.service)) console.log("원포트 채팅 · 작업실에서 쓸 수 있어요. 부탁할 필요 없어요.");
  else if (row.state === "requestable") console.log("지갑에서 켜야 원포트 채팅 · 작업실에서 열려요. " + WALLET);
  else if (row.state === "needs_link") console.log("이 키로는 못 써요. 사람에게 이 주소를 건네 원포트 채팅 · 작업실에서 이어 쓰게 해요:", row.link_url);
  else console.log("원포트 채팅 · 작업실에선 공개 서비스면 지금도 써요. 결과를 기다리는 부탁은 GET /v1/connectors/requests 로 봐요 — 그 부탁이 approved 면 이 줄은 지갑 관리자가 다시 정할 때까지 pending 이에요. 바꾸려면 " + WALLET); // pending
  // 쓰기 도구가 닫힌 건 tools.write 로만 보여요 — needs_link 줄 state 는 읽기 쪽이고, requestable · pending 줄 state 는 도구 칸과 따로 서요.
  const write = row.tools?.write;
  if (["needs_link", "requestable", "pending"].includes(row.state) && write?.state === "unavailable") console.log("쓰기 도구는 지금 꺼져 있어요.", ASK[write.reason ?? ""] ?? ONEPORT);
  // link_url 은 tools.read · tools.write 가운데 하나라도 needs_link 면 와요 — 읽기 도구를 다시 확인하는 동안(줄 unavailable)에도 와요.
  if (row.state !== "needs_link" && row.link_url) console.log("쓰기 도구는 이어서 쓸 수 있어요. 사람에게 이 주소를 건네요:", row.link_url);
} else {
  // 목록에 없는 서비스만 부탁해요. 이름 견주기는 저희가 해요 — 결과를 기다리는 같은 서비스 부탁이 있으면 그 줄로 이어져요.
  // 저희 확인이 끝난(approved) 부탁이 있으면 저희 확인 없이 그 줄로 이어지거나(같은 그룹) approved 새 줄이 서요.
  const idem = randomUUID(); // 응답을 못 받았으면(503 · 연결 끊김) 이 값 그대로 다시 보내요 — 다른 부탁엔 새 값
  const res = await fetch(`${BASE}/v1/connectors/requests`, {
    method: "POST",
    headers: { ...headers, "Content-Type": "application/json", "Idempotency-Key": idem },
    body: JSON.stringify({ service_name: WANT, reason: "설문 응답을 모아 보려고요" }),
  });
  const body = await res.json();
  // approved 인데 matched_service 가 오면 한글 이름 등으로 위 목록 견주기는 못 맞췄지만 저희가 연 서비스예요 — 목록의 그 service 줄대로 해요.
  if (res.ok && body.state === "approved" && body.matched_service) console.log("열린 서비스예요 — GET /v1/connectors 의", body.matched_service, "줄을 보세요.");
  else if (res.ok && body.state === "approved") console.log("저희 확인은 끝났고 공개 전이에요.", ONEPORT);
  else if (res.ok) console.log(res.status === 201 ? "부탁했어요:" : "이미 있던 부탁이에요:", body.id, body.state); // 결과는 GET /v1/connectors/requests/{id}
  else if (body.error.code === "student_cannot_request") console.log(ASK.student_blocked);
  else {
    // 403 의 declined 는 늘 저희 거절이에요 — 지갑 관리자 거절은 403 이 아니라 다시 부탁돼요.
    // blocked_by_admin 의 message 는 정해진 안내 문장이라 까닭으로 보이지 마세요(까닭은 API 로 오지 않아요).
    console.log(body.error.code, body.error.reason === "declined" ? ONEPORT : ASK[body.error.reason] ?? body.error.message);
    // declined · not_verified 403 엔 그 부탁 줄 id 가 와요(blocked_by_admin 엔 없어요) — 까닭은 그 줄의 message 예요.
    if (body.error.connector_request_id) {
      const s = await fetch(`${BASE}/v1/connectors/requests/${body.error.connector_request_id}`, { headers });
      const line = await s.json();
      if (s.ok) console.log("까닭:", line.message ?? "적힌 까닭이 없어요");
      else if (s.status === 503) console.log("까닭을 지금 못 읽었어요 — 잠시 뒤 다시 보세요"); // 저희가 잠깐 못 읽었어요 — Retry-After 가 없으니 몇 초 쉬고 다시 봐요
      else console.log("까닭을 못 읽었어요:", line.error.code);
    }
  }
}

서비스를 만드는 회사라면#

자기 MCP 서버를 원포트 채팅 · 작업실에 올리는 API 문은 없어요. POST /v1/connectors/requests 는 손님 지갑이 「이 서비스를 열어 주세요」라고 말하는 문이라 service_name · reason 두 칸만 받아요(서버 주소 같은 칸을 더하면 400). 서비스를 만드는 회사라면 서버 주소와 쓰게 하고 싶은 도구를 admin@schoolorder.kr 로 알려 주세요. 손님의 부탁은 저희가 그 서비스(서버 주소 · 로그인 · 도구)를 저희 목록에 올린 뒤에야 approved 가 되고, 손님께 여는 건 그다음 저희가 따로 해요 — 열기 전엔 approved 여도 matched_service 가 null 이에요(위 「부탁 결과 보기」).

저희가 서버를 부르는 방식이에요. 주소는 https 만 받고 포트 번호 · IP 주소 · localhost 는 안 돼요. 리다이렉트(3xx)는 따라가지 않고 실패로 봐요. Streamable HTTP 의 POST(JSON-RPC 2.0)로 부르고, 답은 application/json · text/event-stream 둘 다 읽어요 — GET 으로 여는 옛 SSE 방식은 쓰지 않아요. initialize 는 2025-11-25 판으로 먼저 보내고, 서버가 그 메서드를 모른다고(-32601) 하면 2026-07-28 무상태판으로 initialize 없이 불러요. Mcp-Session-Id 를 주면 다음 요청에 실어요. 시간은 initialize · tools/list 15초, tools/call 30초까지이고, 답 크기는 tools/call 2MB, tools/list 한 쪽 1MB 까지예요(tools/list 는 3쪽까지 읽어요). 사람마다 받은 토큰을 Authorization: Bearer 로 실어요 — 연결을 열 때나 목록 · 실행에서 401 · 403 이면 토큰을 한 번 갱신해 다시 하고(목록 · 실행은 2026-11-01 부터), 그래도 같으면 그 사람에게 다시 연결해 달라고 해요. sampling · elicitation · roots 같은 서버발 요청은 받지 않아요. 결과는 content 의 text · resource · resource_link 를 글로 읽고, 그림 · 소리는 싣지 않아요(글이 하나도 없을 때만 structuredContent 를 글로 실어요). 실어 주는 글자 수는 위 「값」 문단이에요.

사람마다 계정을 잇는 로그인은 OAuth 인가 코드 + PKCE(S256)이고, 저희가 클라이언트예요. 돌아오는 주소(redirect_uri)는 https://oneport.kr/api/ai/connectors/callback 과 https://schoolorder.kr/api/ai/connectors/callback 둘이에요. 동적 등록(RFC 7591)이 있으면 저희가 「원포트」 이름으로 한 번 등록하고, 없으면 그 회사 개발자 화면에서 만든 저희 앱 자격을 써요. 보호 자원 메타데이터(RFC 9728)와 인가 서버 메타데이터(RFC 8414)는 저희가 적어 둔 주소와 같아야 하고, code_challenge_methods_supported 에 S256 이 있어야 해요. 토큰은 token_type bearer 로 주세요. 갱신 토큰을 주면 저희가 갱신하고, 사람이 연결을 끊거나 앱 계정을 지우면 철회 끝점(RFC 7009)을 불러요.

도구는 저희가 고른 것만 실어요. 이름과 읽기 · 쓰기를 저희가 정하고, 그 회사의 도구 설명과 readOnlyHint 는 쓰지 않아요 — 모델엔 저희가 쓴 한 줄이 가요. 저희 목록에 없는 새 도구는 실리지 않아요. 쓰기 도구는 한 질문에 하나만 돌고, 사람 확인은 15분 안에 받아요. 질문마다 대조하는 정의는 도구 설명과 입력 꼴(inputSchema)이에요 — 바뀐 도구가 숨는 규칙은 위 tools.read · tools.write 칸이에요. 하루 한 번 바뀐 정의를 검사해 위험한 새 칸 · 지시문이 없으면 다시 열어요. 입력 꼴은 24,000자까지예요(넘으면 그 도구가 숨어요).