커넥터 — /v1/connectors
이 키에서 어떤 연결 서비스가 열렸는지 보고, 안 열린 서비스를 부탁하고, 부탁 결과를 봅니다.
커넥터(연결 서비스)는 원포트 채팅 · 작업실이 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[].state | linked · 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[].reason | off_for_group · student_blocked · student_write_blocked · blocked_by_admin · reapproval_needed · term_ended · not_verified · declined | state 가 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_until | YYYY-MM-DD(한국 날짜) | 지갑에서 기한을 두고 켠 서비스에만 와요. 이 날이 지나면 이 문에서 unavailable · term_ended 예요. |
| connectors[].can_request_again | true · false | reason 이 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 으로 봐요.
| state | reason | 뜻 | 다음 행동 |
|---|---|---|---|
| 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)은 이 목록에 없는 서비스에 써요. |
| requestable | declined | 앞서 거절됐지만 다시 부탁할 수 있어요(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 이에요 — 바꾸려면 지갑 관리자에게 물어보세요. |
| unavailable | blocked_by_admin | 이 지갑의 관리자가 이 서비스를 막았어요(can_request_again 은 false). | 지갑 관리자에게 물어보세요 — 지갑 관리자가 그 서비스를 다시 켜면 풀려요. 부탁해도 403 이고 API 로는 못 풀어요. |
| unavailable | not_verified | 저희(원포트)가 아직 열지 않았거나 막은 서비스예요. 부탁이 저희 확인에서 막혔으면 can_request_again 이 false 로 와요 — 이건 부탁 문의 막힘이고, 공개 서비스면 채팅 · 작업실은 부탁 줄을 보지 않아요. can_request_again 칸이 없으면 지갑에서 켰지만 저희가 그 서비스를 열지 않았거나(닫은 것 포함) 그 서비스의 도구를 아직 확인하지 못한 거예요 — 그 서비스(도구만이면 그 종류 도구)는 채팅 · 작업실에서도 안 보여요. 도구를 못 본 건 requestable · pending 줄이면 tools 칸에만 와요. | 원포트(admin@schoolorder.kr)에 물어보세요. 지갑 관리자가 풀 수 있는 게 아니에요. |
| unavailable | off_for_group | 지갑에서 몇몇 그룹에만 켰고 이 키의 그룹은 빠졌어요(그룹이 없는 키도 이래요). 공개 서비스면 채팅 · 작업실은 그룹 켜기를 보지 않아요 — 그 밖의 서비스는 채팅 · 작업실도 같은 까닭으로 닫혀요. | group.role 쪽에 이 그룹에도 켜 달라고 해요 — group_admin 이면 그 그룹 관리자, wallet_admin 이거나 group 이 null 이면 지갑 관리자예요. |
| unavailable | student_blocked | 이 키가 학생 키라 이 문에선 통째로 닫혔어요. 채팅 · 작업실은 키가 아니라 쓰는 사람의 자리로 열고 닫아서, 선생님 자리의 채팅은 이 줄과 상관없어요. | 선생님(키를 나눠 준 분)께 말해요. 학생 키는 부탁 · 부탁 목록 문도 403 student_cannot_request 예요. 키 쓰임새를 「선생님」으로 바꾸면 다음 요청부터 풀려요 — 학교 지갑 키는 지갑 주인이 대시보드 AI API 화면 키 목록의 쓰임새 칩에서(관리 키면 PATCH /v1/keys/{id} 에 "audience": "teacher"), 스페이스 키는 스페이스 주인 · 관리자가 그 키 줄의 칩에서 바꿔요. 바꿔도 API 키로 커넥터를 쓰진 못해요 — 열린 서비스는 원포트 채팅 · 작업실에서 써요. |
| unavailable | student_write_blocked | 학생 키에서 쓰기 도구만 닫혔다는 이름이에요 — tools.write 에만 붙어요. 지금은 학생 키가 통째로 student_blocked 라 오지 않아요. | 쓰기 없이 읽기만 써요. |
| unavailable | term_ended | 지갑에서 기한을 두고 켠 서비스인데 그 날(valid_until)이 지났어요. 공개 서비스면 채팅 · 작업실은 이 기한을 보지 않아요 — 그 밖의 서비스는 채팅 · 작업실도 같은 까닭으로 닫혀요. | 기한을 늘리려면 지갑 관리자에게 물어보세요. |
| unavailable | reapproval_needed | 그 서비스가 도구 정의를 바꿔서, 저희가 다시 확인할 때까지 그 도구를 숨겼어요. 쓰기 도구만 이럴 수도 있어요(tools.write). 줄 reason 으로는 지갑에서 켠 줄에만 와요 — requestable · pending 줄엔 tools 칸에만 와요. | 부탁할 필요 없어요. 나중에 다시 불러 보세요. 읽기 도구만 다시 확인 중이고 쓰기 도구는 이을 수 있으면 이 줄에도 link_url 이 와요 — 쓰기 도구를 쓸 사람에게 건네요. |
| unavailable | declined | 부탁이 저희 확인에서 거절됐고 다시 부탁할 수 없어요(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 가 와요. |
| reason | 500자까지 문자열 또는 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 이면 앞 부탁의 결과가 나온 뒤 같은 값으로 다시 보내도 돼요. 오류마다 할 일은 아래 「오류」 표예요.
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":"설문 응답을 모아 보려고요"}'{
"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)의 칸
| 파라미터 | 받는 값 | 저희가 하는 일 |
|---|---|---|
| id | UUID | Location 의 끝과 같은 값이에요. GET /v1/connectors/requests/{id} 로 그 줄을 다시 읽어요. |
| service_name | 문자열 | 보낸 이름 그대로예요(앞뒤 공백만 빼요). 같은 줄에 합쳐졌으면 처음 부탁한 쪽이 적은 이름이에요. |
| matched_service | 서비스 id 또는 null | 저희가 손님에게 연 서비스와 맞을 때만 와요 — GET /v1/connectors 의 service 와 같은 값이에요. 저희가 확인하면서 맞춰 넣으면 나중에 채워질 수 있어요. |
| state | received · reviewing · approved · declined · blocked | 부탁의 결과예요(아래 표). GET /v1/connectors 의 state 와 이름이 다른 별개의 값이에요. |
| message | 문자열 | state 가 declined 면 와요(저희는 거절할 때 까닭을 꼭 적어요). blocked 는 저희가 까닭을 적었을 때, approved 는 이 부탁 뒤에 지갑 관리자가 거절하며 한 줄을 남겼을 때만 와요. 사람에게 보여 줄 문장이에요. |
| can_request_again | true · false | state 가 declined · blocked 일 때만 와요. blocked 면 늘 false 예요. |
| wallet_decision | enabled · declined · blocked · null | 이 지갑의 관리자가 그 서비스를 어떻게 정했는지예요 — enabled(켬) · declined(거절) · blocked(막음) · null(아직 안 정했거나 맞춘 서비스가 없어요). 부탁 한 줄의 값이 아니라 그 서비스에 대한 이 지갑의 지금 결정이라, 같은 서비스의 옛 부탁 줄에도 같은 값이 와요. 저희 확인(state)과 따로 서요 — 저희가 확인하는 중이어도 지갑이 이미 정했으면 보여요. 목록 · 한 줄 조회 · 만든 응답(POST) 모두 실려요. 만든 응답에서만 칸이 빠질 수 있는데, 저희가 지갑 결정을 잠깐 못 읽은 거예요 — 칸이 없으면 「모름」, null 은 「아직 결정 없음」으로 읽으세요. 목록 · 한 줄 조회는 못 읽으면 503 이에요. 예외는 하나예요 — 지갑 관리자가 거절한 뒤 다시 부탁해 선 approved 새 줄엔 wallet_decision 이 null 이고 message 도 없어요. 옛 거절은 이 부탁의 답이 아니라서예요. 지갑 관리자가 다시 정하면 그 결정이 와요. 그 앞의 옛 줄엔 지금 결정이 와요(거절이면 지금 남긴 한 줄도) — 그래서 지갑 관리자가 다시 정하면 옛 줄 값도 같이 바뀌어요. 이번 부탁의 결과는 저장해 둔 이번 줄 id 로 읽어요 — null 이던 이번 줄에 값이 오면 지갑 관리자가 정한 거예요. 기다리는 중인지는 GET /v1/connectors 의 그 서비스 줄이 pending 인지로 봐요. |
| requested_by.role | owner · admin · group_admin · member · wallet_key · group_key | 부탁한 쪽의 역할만 와요(사람 이름 · 키 이름은 안 와요). API 키로 낸 부탁은 wallet_key(지갑 키) · group_key(스페이스 그룹 키)예요. 모르는 값은 건너뛰게 짜세요. |
| reason | 문자열 또는 null | 부탁한 까닭이에요. 만든 키의 응답에만 와요 — 목록 줄엔 이 칸만 없고, message · can_request_again · wallet_decision 은 목록에도 와요. |
| created_at · updated_at | ISO 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 는 위 표 그대로예요).
| 부탁 state | wallet_decision | 사람에게 알릴 것 |
|---|---|---|
| approved | null | 지갑 관리자 결정 전이에요. |
| approved | enabled | 지갑에서 켰어요 — GET /v1/connectors 의 그 줄대로 해요(needs_link 면 link_url 을 건네요). |
| approved | declined | 지갑 관리자가 거절했어요 — message 가 오면 그분이 남긴 한 줄이에요. 이 줄엔 can_request_again 이 없어요(다시 부탁돼요 — state 표 requestable · declined 줄). |
| approved | blocked | 지갑 관리자가 막았어요 — 다시 부탁하면 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 | 언제 | 할 일 |
|---|---|---|---|
| 401 | authentication_error | 키가 없거나 쓸 수 없는 키예요 — error.reason 이 까닭이에요(「인증」 장의 「키가 안 열릴 때」 표). | 키 · 머리글을 확인해요. |
| 400 | invalid_request_error | Idempotency-Key 가 없거나 꼴이 틀렸어요 · JSON 이 아니에요 · service_name · reason 꼴이 틀렸거나 모르는 칸이 있어요. | 고쳐서 보내요. |
| 400 | reason_required | 지갑 관리자가 사유 필수(부탁할 때 어디에 쓰는지 꼭 적게 하기)를 켠 지갑에서 POST 의 reason 을 비워 보냈어요. | reason 에 어디에 쓰는지 적어 다시 보내요. |
| 413 | invalid_request_error | 본문이 8,000바이트를 넘었어요. | reason 을 줄여요. |
| 403 | student_cannot_request | 학생 키로 부탁 · 부탁 목록 · 한 줄 조회를 불렀어요. | 선생님께 말해요. |
| 403 | connector_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 를 봐요. |
| 404 | not_found_error | 이 지갑의 부탁이 아닌 id 예요. | 받은 id · Location 을 확인해요. |
| 409 | idempotency_conflict | 같은 Idempotency-Key 로 다른 본문을 보냈어요(x-should-retry: false — SDK 가 스스로 다시 보내지 않아요). | 처음 본문 그대로 보내거나, 다른 부탁이면 다른 값을 써요. |
| 429 | too_many_open | 이 지갑에서 결과를 기다리는(received · reviewing) 부탁이 20건 찼어요(x-should-retry: false). | 앞 부탁의 결과가 나온 뒤 다시 보내요. |
| 503 | api_error | 저희가 상태를 잠깐 못 읽었거나 부탁을 확인하지 못했어요(키를 잠깐 못 읽었을 때도 와요). | GET 은 잠시 뒤 다시 불러요 · POST 는 같은 Idempotency-Key 로 다시 보내요. 이 장의 문엔 Retry-After 가 오지 않아요 — 몇 초 쉬고 다시 해요. |
{
"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 으로 가르기#
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")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자까지예요(넘으면 그 도구가 숨어요).