Merge pull request #77 from NomaDamas/feature/#76

Feature/#76
This commit is contained in:
Jeffrey (Dongkyu) Kim 2026-04-06 21:09:02 +09:00 committed by GitHub
commit 8169d1d443
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
16 changed files with 1619 additions and 7 deletions

View file

@ -26,6 +26,7 @@ Claude Code, Codex, OpenCode, OpenClaw/ClawHub 등 각종 코딩 에이전트
| 한강 수위 정보 조회 | self-host 또는 배포 확인이 끝난 `k-skill-proxy` 경유로 관측소 기준 현재 수위·유량·기준수위 확인 | 프록시 URL 필요 | [한강 수위 정보 가이드](docs/features/han-river-water-level.md) |
| 한국 법령 검색 | `korean-law-mcp` 우선 + 장애 시 `법망` fallback으로 법령/조문/판례/유권해석 조회 | 로컬 CLI/MCP면 `LAW_OC` 필요, remote endpoint/법망 fallback은 불필요 | [한국 법령 검색 가이드](docs/features/korean-law-search.md) |
| 한국 부동산 실거래가 조회 | upstream `real-estate-mcp`로 아파트/오피스텔/빌라/단독주택 실거래가·전월세·지역코드 조회, hosted endpoint가 없으면 self-host + Cloudflare Tunnel + launchd 운영 | 로컬/stdio/self-host면 `DATA_GO_KR_API_KEY` 필요 | [한국 부동산 실거래가 조회 가이드](docs/features/real-estate-search.md) |
| 한국 주식 정보 조회 | `k-skill-proxy` 경유로 KRX 상장 종목 검색, 기본정보, 일별 시세 조회 | 불필요 | [한국 주식 정보 조회 가이드](docs/features/korean-stock-search.md) |
| 조선왕조실록 검색 | 공식 조선왕조실록 사이트에서 키워드 검색 후 왕별/연도별 필터와 기사 excerpt 조회 | 불필요 | [조선왕조실록 검색 가이드](docs/features/joseon-sillok-search.md) |
| 근처 가장 싼 주유소 찾기 | 현재 위치를 먼저 확인한 뒤 Kakao Map anchor + Opinet 공식 API로 근처 최저가 주유소 조회 | `OPINET_API_KEY` 필요 | [근처 가장 싼 주유소 찾기 가이드](docs/features/cheap-gas-nearby.md) |
| KBO 경기 결과 조회 | 날짜별 경기 일정, 결과, 팀별 필터링 | 불필요 | [KBO 결과 가이드](docs/features/kbo-results.md) |
@ -76,6 +77,7 @@ Claude Code, Codex, OpenCode, OpenClaw/ClawHub 등 각종 코딩 에이전트
- [한강 수위 정보 가이드](docs/features/han-river-water-level.md)
- [한국 법령 검색 가이드](docs/features/korean-law-search.md)
- [한국 부동산 실거래가 조회 가이드](docs/features/real-estate-search.md)
- [한국 주식 정보 조회 가이드](docs/features/korean-stock-search.md)
- [조선왕조실록 검색 가이드](docs/features/joseon-sillok-search.md)
- [근처 가장 싼 주유소 찾기 가이드](docs/features/cheap-gas-nearby.md)
- [KBO 경기 결과 조회](docs/features/kbo-results.md)

View file

@ -18,6 +18,9 @@ client/skill -> k-skill-proxy -> upstream public API
- `GET /v1/fine-dust/report`
- `GET /v1/seoul-subway/arrival`
- `GET /v1/han-river/water-level`
- `GET /v1/korean-stock/search`
- `GET /v1/korean-stock/base-info`
- `GET /v1/korean-stock/trade-info`
- `GET /v1/opinet/around`
- `GET /v1/opinet/detail`
- `GET /B552584/:service/:operation` (허용된 AirKorea route passthrough)
@ -34,6 +37,7 @@ client/skill -> k-skill-proxy -> upstream public API
- `SEOUL_OPEN_API_KEY=...`
- `HRFCO_OPEN_API_KEY=...`
- `OPINET_API_KEY=...`
- `KRX_API_KEY=...`
- `KSKILL_PROXY_PORT=4020`
## 프로덕션 배포 구조
@ -122,6 +126,23 @@ curl -fsS --get 'https://k-skill-proxy.nomadamas.org/v1/opinet/detail' \
--data-urlencode 'id=A0009905'
```
한국 주식 검색 endpoint:
```bash
curl -fsS --get 'https://k-skill-proxy.nomadamas.org/v1/korean-stock/search' \
--data-urlencode 'q=삼성전자' \
--data-urlencode 'bas_dd=20260404'
```
한국 주식 기본정보 endpoint:
```bash
curl -fsS --get 'https://k-skill-proxy.nomadamas.org/v1/korean-stock/base-info' \
--data-urlencode 'market=KOSPI' \
--data-urlencode 'code=005930' \
--data-urlencode 'bas_dd=20260404'
```
AirKorea passthrough endpoint:
```bash
@ -137,5 +158,6 @@ curl -fsS --get 'https://k-skill-proxy.nomadamas.org/B552584/ArpltnInforInqireSv
## 주의할 점
- upstream key는 프록시 서버에서만 관리합니다.
- 한국 주식 route도 사용자에게 `KRX_API_KEY` 를 배포하지 않습니다.
- client 쪽에는 upstream API key를 배포하지 않습니다.
- public hosted route rollout 이 끝나기 전에는 서울 지하철 예시를 local/self-host URL 로 검증합니다.

View file

@ -0,0 +1,81 @@
# 한국 주식 정보 조회 가이드
## 이 기능으로 할 수 있는 일
- KRX 상장 종목 검색 (`/v1/korean-stock/search`)
- 종목 기본정보 조회 (`/v1/korean-stock/base-info`)
- 종목 일별 시세 조회 (`/v1/korean-stock/trade-info`)
- 종목명이 모호할 때 시장/종목코드 후보를 먼저 좁히기
## 가장 중요한 규칙
기본 경로는 `https://k-skill-proxy.nomadamas.org/v1/korean-stock/...` 이다.
사용자는 `KRX_API_KEY` 를 준비할 필요가 없다. `KRX_API_KEY` 는 proxy 서버에서만 관리한다.
upstream 참고 구현은 [`jjlabsio/korea-stock-mcp`](https://github.com/jjlabsio/korea-stock-mcp) 이지만, 이 레포의 기본 사용법은 로컬 MCP 설치가 아니라 **proxy first** 다.
## 먼저 필요한 것
없음. 인터넷 연결만 있으면 된다.
## 추천 조회 순서
1. 종목명이 애매하면 `/v1/korean-stock/search?q=...` 로 후보를 먼저 찾는다.
2. 후보에서 `market`, `code` 를 확인한다.
3. 기본 정보가 필요하면 `/v1/korean-stock/base-info` 를 호출한다.
4. 가격/거래량이 필요하면 `/v1/korean-stock/trade-info` 를 호출한다.
5. 휴장일이면 `bas_dd` 를 최근 영업일로 다시 지정한다.
## 검색 예시
```bash
curl -fsS --get 'https://k-skill-proxy.nomadamas.org/v1/korean-stock/search' \
--data-urlencode 'q=삼성전자' \
--data-urlencode 'bas_dd=20260404'
```
## 기본정보 예시
```bash
curl -fsS --get 'https://k-skill-proxy.nomadamas.org/v1/korean-stock/base-info' \
--data-urlencode 'market=KOSPI' \
--data-urlencode 'code=005930' \
--data-urlencode 'bas_dd=20260404'
```
## 일별 시세 예시
```bash
curl -fsS --get 'https://k-skill-proxy.nomadamas.org/v1/korean-stock/trade-info' \
--data-urlencode 'market=KOSPI' \
--data-urlencode 'code=005930' \
--data-urlencode 'bas_dd=20260404'
```
## 응답 해석 팁
- `code` 는 보통 6자리 단축코드다.
- `standard_code` 는 KRX 표준코드다.
- `close_price`, `trading_volume`, `market_cap` 은 숫자로 정규화돼 온다.
- `base_date`/`bas_dd` 는 일별 snapshot 날짜다.
- 휴장일/장마감 전에는 빈 결과나 `not_found` 가 나올 수 있다.
## 답변 템플릿 권장
- 종목명 / 시장 / 종목코드
- 기준일
- 종가 / 등락률 / 거래량 / 시가총액
- 필요하면 상장일 / 액면가 / 상장주식수
- 마지막 한 줄: `KRX 공식 데이터 기준이며 투자 조언은 아닙니다.`
## 에러/제약
- 잘못된 `market`, `code`, `bas_dd` 형식은 400
- proxy 서버에 `KRX_API_KEY` 가 없으면 503
- upstream KRX 오류는 502
- 기준일에 종목을 찾지 못하면 404 `not_found`
## 참고 링크
- 원본 MCP 서버(참고용): `https://github.com/jjlabsio/korea-stock-mcp`
- 공식 KRX Open API: `https://openapi.krx.co.kr/contents/OPP/MAIN/main/index.cmd`

View file

@ -53,6 +53,7 @@ npx --yes skills add <owner/repo> \
--skill kakaotalk-mac \
--skill korean-law-search \
--skill real-estate-search \
--skill korean-stock-search \
--skill joseon-sillok-search \
--skill cheap-gas-nearby \
--skill fine-dust-location \
@ -100,6 +101,27 @@ korean-law list
`real-estate-search` 는 별도 설치 없이 기본 hosted proxy(`k-skill-proxy.nomadamas.org`)를 통해 바로 사용할 수 있다. 사용자 쪽 `DATA_GO_KR_API_KEY` 가 불필요하다. 원본 참고: `https://github.com/tae0y/real-estate-mcp/tree/main`. 자세한 사용법은 [한국 부동산 실거래가 조회 가이드](features/real-estate-search.md)를 본다.
`korean-stock-search` 는 별도 설치 없이 기본 hosted proxy(`k-skill-proxy.nomadamas.org`)를 통해 바로 사용할 수 있다. 사용자 쪽 `KRX_API_KEY` 가 불필요하다. 원본 참고: `https://github.com/jjlabsio/korea-stock-mcp`. 자세한 사용법은 [한국 주식 정보 조회 가이드](features/korean-stock-search.md)를 본다.
### `korean-stock-search` proxy quickstart
`korean-stock-search` 는 로컬 MCP 설치 대신 **proxy first** 로 사용한다.
- 가장 빠른 smoke test 는 `curl -fsS --get 'https://k-skill-proxy.nomadamas.org/v1/korean-stock/search' --data-urlencode 'q=삼성전자' --data-urlencode 'bas_dd=20260404'`
- 검색 결과에서 `market`, `code` 를 확인한 뒤 `base-info` 또는 `trade-info` 로 이어간다.
- 사용자 쪽 `KRX_API_KEY` 는 필요 없다. self-host proxy 운영자만 서버 환경변수 `KRX_API_KEY` 를 설정한다.
```bash
curl -fsS --get 'https://k-skill-proxy.nomadamas.org/v1/korean-stock/search' \
--data-urlencode 'q=삼성전자' \
--data-urlencode 'bas_dd=20260404'
curl -fsS --get 'https://k-skill-proxy.nomadamas.org/v1/korean-stock/base-info' \
--data-urlencode 'market=KOSPI' \
--data-urlencode 'code=005930' \
--data-urlencode 'bas_dd=20260404'
```
### `olive-young-search` upstream CLI quickstart
`olive-young-search` 는 upstream 원본 [`hmmhmmhm/daiso-mcp`](https://github.com/hmmhmmhm/daiso-mcp) / npm package [`daiso`](https://www.npmjs.com/package/daiso) 를 그대로 사용한다.
@ -250,6 +272,7 @@ python3 scripts/korean_spell_check.py --text "아버지가방에들어가신다.
- `fine-dust-location`
- `korean-law-search`
- `real-estate-search`
- `korean-stock-search`
- `cheap-gas-nearby`
관련 문서:

View file

@ -16,6 +16,7 @@
- 한강 수위 정보 조회 스킬 출시
- 한국 법령 검색 스킬 출시
- 한국 부동산 실거래가 조회 스킬 출시
- 한국 주식 정보 조회 스킬 출시
- 조선왕조실록 검색 스킬 출시
- 근처 가장 싼 주유소 찾기 스킬 출시
- 우편번호 검색

View file

@ -29,7 +29,7 @@ AIR_KOREA_OPEN_API_KEY=replace-me
KSKILL_PROXY_BASE_URL=https://your-proxy.example.com
```
서울 지하철 도착정보는 hosted public route rollout 이 끝나기 전까지 self-host 또는 배포 확인이 끝난 proxy URL 만 넣는다. 미세먼지, 한강 수위, 주유소 가격 기본 hosted proxy(`k-skill-proxy.nomadamas.org`)를 쓰므로 사용자 쪽 키가 불필요하다.
서울 지하철 도착정보는 hosted public route rollout 이 끝나기 전까지 self-host 또는 배포 확인이 끝난 proxy URL 만 넣는다. 미세먼지, 한강 수위, 주유소 가격, 한국 주식 정보 조회는 기본 hosted proxy(`k-skill-proxy.nomadamas.org`)를 쓰므로 사용자 쪽 키가 불필요하다.
## Missing secret handling policy
@ -62,8 +62,9 @@ KSKILL_PROXY_BASE_URL=https://your-proxy.example.com
- `KSKILL_KTX_PASSWORD`
- `LAW_OC`
- `AIR_KOREA_OPEN_API_KEY`
- `KRX_API_KEY`
- `KSKILL_PROXY_BASE_URL`
`LAW_OC``korean-law-mcp` 가 법제처 Open API 를 호출할 때 쓰는 표준 변수명이다. 이 값은 로컬 CLI/로컬 MCP server 경로에서만 사용자 쪽에 필요하고, upstream remote MCP endpoint 예시는 사용자 `LAW_OC` 없이 `url`만 등록한다. `DATA_GO_KR_API_KEY` 는 프록시 운영자 문맥에서만 서버에 넣는다. 부동산 실거래가 조회는 기본 hosted proxy(`k-skill-proxy.nomadamas.org`)를 경유하므로 사용자 쪽 키가 불필요하다. 근처 가장 싼 주유소 찾기는 기본 hosted proxy를 경유하므로 사용자 쪽 `OPINET_API_KEY` 가 불필요하다. `OPINET_API_KEY` 는 프록시 운영자 문맥에서만 서버에 넣는다. public 공유용 Cloudflare Tunnel/Auth0/operator secret은 사용자 기본 secrets 파일에 넣지 않는다. 프록시 운영자 문맥에서는 upstream 환경변수 `SEOUL_OPEN_API_KEY`, `AIR_KOREA_OPEN_API_KEY`, `HRFCO_OPEN_API_KEY`, `OPINET_API_KEY`, `DATA_GO_KR_API_KEY` 를 사용할 수 있다. 다만 일반 사용자/client 쪽 기본 secrets 파일에는 넣지 않는다. `KSKILL_PROXY_BASE_URL` 은 서울 지하철 route가 실제 배포된 proxy URL 로만 넣는다. 미세먼지, 한강 수위, 주유소 가격 이 값이 없으면 기본 hosted path(`k-skill-proxy.nomadamas.org`)를 사용한다.
`LAW_OC``korean-law-mcp` 가 법제처 Open API 를 호출할 때 쓰는 표준 변수명이다. 이 값은 로컬 CLI/로컬 MCP server 경로에서만 사용자 쪽에 필요하고, upstream remote MCP endpoint 예시는 사용자 `LAW_OC` 없이 `url`만 등록한다. `DATA_GO_KR_API_KEY` 는 프록시 운영자 문맥에서만 서버에 넣는다. 부동산 실거래가 조회는 기본 hosted proxy(`k-skill-proxy.nomadamas.org`)를 경유하므로 사용자 쪽 키가 불필요하다. 한국 주식 정보 조회도 기본 hosted proxy를 경유하므로 사용자 쪽 `KRX_API_KEY` 가 불필요하다. `KRX_API_KEY` 는 self-host proxy 운영자 문맥에서만 서버에 넣는다. 근처 가장 싼 주유소 찾기는 기본 hosted proxy를 경유하므로 사용자 쪽 `OPINET_API_KEY` 가 불필요하다. `OPINET_API_KEY` 는 프록시 운영자 문맥에서만 서버에 넣는다. public 공유용 Cloudflare Tunnel/Auth0/operator secret은 사용자 기본 secrets 파일에 넣지 않는다. 프록시 운영자 문맥에서는 upstream 환경변수 `SEOUL_OPEN_API_KEY`, `AIR_KOREA_OPEN_API_KEY`, `HRFCO_OPEN_API_KEY`, `OPINET_API_KEY`, `DATA_GO_KR_API_KEY`, `KRX_API_KEY` 를 사용할 수 있다. 다만 일반 사용자/client 쪽 기본 secrets 파일에는 넣지 않는다. `KSKILL_PROXY_BASE_URL` 은 서울 지하철 route가 실제 배포된 proxy URL 로만 넣는다. 미세먼지, 한강 수위, 주유소 가격, 한국 주식 정보 조회는 이 값이 없으면 기본 hosted path(`k-skill-proxy.nomadamas.org`)를 사용한다.
이 레포의 credential-bearing skill은 전부 이 정책을 전제로 작성한다. 자세한 공통 설치 절차는 [공통 설정 가이드](setup.md)를 본다.

View file

@ -1,6 +1,6 @@
# 공통 설정 가이드
`k-skill` 전체 스킬을 설치한 뒤, 인증 정보가 필요한 기능(SRT 예매, KTX 예매, 한국 법령 검색의 로컬 CLI/MCP 경로용 `LAW_OC`, self-host 프록시 운영용 서울 지하철 upstream key, 또는 배포 확인이 끝난 proxy URL 공유)이 있으면 이 절차를 진행하면 된다. 미세먼지, 한강 수위, 주유소 가격, 부동산 실거래가는 기본 hosted proxy를 쓰므로 사용자 쪽 키가 불필요하다.
`k-skill` 전체 스킬을 설치한 뒤, 인증 정보가 필요한 기능(SRT 예매, KTX 예매, 한국 법령 검색의 로컬 CLI/MCP 경로용 `LAW_OC`, self-host 프록시 운영용 서울 지하철 upstream key, 또는 배포 확인이 끝난 proxy URL 공유)이 있으면 이 절차를 진행하면 된다. 미세먼지, 한강 수위, 주유소 가격, 부동산 실거래가, 한국 주식 정보 조회는 기본 hosted proxy를 쓰므로 사용자 쪽 키가 불필요하다.
## Credential resolution order
@ -41,6 +41,8 @@ remote MCP endpoint는 사용자 `LAW_OC` 없이 `url`만으로 연결한다.
한국 부동산 실거래가 조회는 기본 hosted proxy(`k-skill-proxy.nomadamas.org`)를 경유하므로 사용자 쪽 `DATA_GO_KR_API_KEY` 가 불필요하다.
한국 주식 정보 조회는 기본 hosted proxy(`k-skill-proxy.nomadamas.org`)를 경유하므로 사용자 쪽 `KRX_API_KEY` 가 불필요하다. self-host proxy 운영자만 서버 환경변수 `KRX_API_KEY` 를 사용한다.
근처 가장 싼 주유소 찾기는 기본 hosted proxy(`k-skill-proxy.nomadamas.org`)를 경유하므로 사용자 쪽 `OPINET_API_KEY` 가 불필요하다.
## 확인
@ -65,6 +67,7 @@ bash scripts/check-setup.sh
| 한국 법령 검색 (로컬 CLI/MCP) | `LAW_OC` |
| 한국 법령 검색 (remote MCP endpoint) | 사용자 시크릿 불필요 (`url`만 등록, 장애 시 `법망` fallback 가능) |
| 한국 부동산 실거래가 조회 | 사용자 시크릿 불필요 (기본 hosted proxy 사용) |
| 한국 주식 정보 조회 | 사용자 시크릿 불필요 (기본 hosted proxy 사용, 운영자만 `KRX_API_KEY`) |
| 근처 가장 싼 주유소 찾기 | 사용자 시크릿 불필요 (기본 hosted proxy 사용) |
| 서울 지하철 도착정보 조회 | self-host 또는 배포 확인이 끝난 `KSKILL_PROXY_BASE_URL` |
| 사용자 위치 미세먼지 조회 | `KSKILL_PROXY_BASE_URL` 또는 `AIR_KOREA_OPEN_API_KEY` |
@ -79,6 +82,7 @@ bash scripts/check-setup.sh
- [한강 수위 정보 가이드](features/han-river-water-level.md)
- [한국 법령 검색 가이드](features/korean-law-search.md)
- [한국 부동산 실거래가 조회 가이드](features/real-estate-search.md)
- [한국 주식 정보 조회 가이드](features/korean-stock-search.md)
- [근처 가장 싼 주유소 찾기 가이드](features/cheap-gas-nearby.md)
- [보안/시크릿 정책](security-and-secrets.md)

View file

@ -22,6 +22,10 @@
- `hwp-mcp`: https://github.com/jkf87/hwp-mcp
- korean-law-mcp: https://github.com/chrisryugj/korean-law-mcp
- real-estate-mcp: https://github.com/tae0y/real-estate-mcp/tree/main
- korea-stock-mcp: https://github.com/jjlabsio/korea-stock-mcp
- KRX OPEN API 메인: https://openapi.krx.co.kr/contents/OPP/MAIN/main/index.cmd
- KRX 종목 기본정보 API (KOSPI): http://data-dbg.krx.co.kr/svc/apis/sto/stk_isu_base_info
- KRX 일별 매매정보 API (KOSPI): http://data-dbg.krx.co.kr/svc/apis/sto/stk_bydd_trd
- MOLIT 아파트 매매 실거래가 API: https://apis.data.go.kr/1613000/RTMSDataSvcAptTrade/getRTMSDataSvcAptTrade
- MOLIT 아파트 전월세 API: https://apis.data.go.kr/1613000/RTMSDataSvcAptRent/getRTMSDataSvcAptRent
- MOLIT 오피스텔 매매 API: https://apis.data.go.kr/1613000/RTMSDataSvcOffiTrade/getRTMSDataSvcOffiTrade

View file

@ -82,6 +82,8 @@ chmod 0600 ~/.config/k-skill/secrets.env
한국 부동산 실거래가 조회는 기본 hosted proxy(`k-skill-proxy.nomadamas.org`)를 경유하므로 사용자 쪽 `DATA_GO_KR_API_KEY` 가 불필요하다.
한국 주식 정보 조회는 기본 hosted proxy(`k-skill-proxy.nomadamas.org`)를 경유하므로 사용자 쪽 `KRX_API_KEY` 가 불필요하다. self-host proxy 운영자만 서버 환경변수 `KRX_API_KEY` 를 사용한다.
근처 가장 싼 주유소 찾기는 기본 hosted proxy를 경유하므로 사용자 쪽 `OPINET_API_KEY` 가 불필요하다.
@ -96,6 +98,7 @@ chmod 0600 ~/.config/k-skill/secrets.env
- 로컬 한국 법령 검색: `LAW_OC` + `korean-law-mcp`
- 한국 법령 검색 remote endpoint: 사용자 `LAW_OC` 없이 `url`만 등록, 장애 시 `법망` fallback
- 한국 부동산 실거래가 조회: 사용자 시크릿 불필요 (기본 hosted proxy 사용)
- 한국 주식 정보 조회: 사용자 시크릿 불필요 (기본 hosted proxy 사용, 운영자만 `KRX_API_KEY`)
- 근처 가장 싼 주유소 찾기: 사용자 시크릿 불필요 (기본 hosted proxy 사용)
- 서울 지하철: self-host 또는 배포 확인이 끝난 `KSKILL_PROXY_BASE_URL`
- 사용자 위치 미세먼지 조회: `KSKILL_PROXY_BASE_URL` 또는 `AIR_KOREA_OPEN_API_KEY`

View file

@ -0,0 +1,202 @@
---
name: korean-stock-search
description: Use k-skill-proxy to search Korean listed stocks, inspect KRX base information, and fetch daily trade snapshots without asking the user to issue a KRX API key.
license: MIT
metadata:
category: finance
locale: ko-KR
phase: v1
---
# Korean Stock Search
## What this skill does
기본적으로 `https://k-skill-proxy.nomadamas.org/v1/korean-stock/...` 로 요청해서 KRX 상장 종목 검색, 종목 기본정보, 일별 시세를 조회한다.
upstream 설계 참고는 [`jjlabsio/korea-stock-mcp`](https://github.com/jjlabsio/korea-stock-mcp) 이지만, 사용자는 `KRX_API_KEY` 를 발급받거나 로컬 MCP 서버를 설치할 필요가 없다. `KRX_API_KEY` 는 proxy 서버에서만 관리한다.
## When to use
- "삼성전자 종목코드랑 시장구분 찾아줘"
- "005930 기본정보 보여줘"
- "SK하이닉스 20260404 종가/거래량 알려줘"
- "KOSDAQ 에서 알테오젠 시세 확인해줘"
## When not to use
- 미국/일본/가상자산 같은 비한국 주식 조회
- 실시간 체결/호가/분봉 조회
- 재무제표/공시 원문 분석 (이 스킬 범위 밖)
- 투자 자문/매수 추천
## Inputs
- `q`: 종목명 또는 종목코드 검색어 (`search` endpoint)
- `market`: `KOSPI` | `KOSDAQ` | `KONEX`
- `code`: 종목코드 (보통 6자리 단축코드, 예: `005930`)
- `bas_dd`: 기준일 `YYYYMMDD` (없으면 KST 오늘 날짜 기본값, 휴장일이면 최근 영업일로 다시 시도)
- `limit`: 검색 결과 수 (기본 10, 최대 20)
## Prerequisites
없음. 사용자는 `KRX_API_KEY` 를 준비할 필요가 없다. upstream key는 proxy 서버에서만 주입한다.
## Default path
추가 client API 레이어는 불필요하다. 그냥 프록시 서버에 HTTP 요청만 넣으면 된다.
`KSKILL_PROXY_BASE_URL` 환경변수가 있으면 그 값을 사용하고, 없으면 기본 경로 `https://k-skill-proxy.nomadamas.org` 를 사용한다.
## Supported endpoints
### 종목 검색
```http
GET /v1/korean-stock/search?q={검색어}&bas_dd={YYYYMMDD}
```
### 종목 기본정보
```http
GET /v1/korean-stock/base-info?market={KOSPI|KOSDAQ|KONEX}&code={종목코드}&bas_dd={YYYYMMDD}
```
### 종목 일별 시세
```http
GET /v1/korean-stock/trade-info?market={KOSPI|KOSDAQ|KONEX}&code={종목코드}&bas_dd={YYYYMMDD}
```
## Example requests
종목 검색:
```bash
curl -fsS --get 'https://k-skill-proxy.nomadamas.org/v1/korean-stock/search' \
--data-urlencode 'q=삼성전자' \
--data-urlencode 'bas_dd=20260404'
```
종목 기본정보:
```bash
curl -fsS --get 'https://k-skill-proxy.nomadamas.org/v1/korean-stock/base-info' \
--data-urlencode 'market=KOSPI' \
--data-urlencode 'code=005930' \
--data-urlencode 'bas_dd=20260404'
```
종목 일별 시세:
```bash
curl -fsS --get 'https://k-skill-proxy.nomadamas.org/v1/korean-stock/trade-info' \
--data-urlencode 'market=KOSPI' \
--data-urlencode 'code=005930' \
--data-urlencode 'bas_dd=20260404'
```
## Response shape
### 검색 응답
```json
{
"items": [
{
"market": "KOSPI",
"code": "005930",
"standard_code": "KR7005930003",
"name": "삼성전자",
"short_name": "삼성전자",
"english_name": "Samsung Electronics",
"listed_at": "1975-06-11"
}
],
"query": { "q": "삼성전자", "bas_dd": "20260404", "limit": 10 },
"proxy": { "name": "k-skill-proxy", "cache": { "hit": false, "ttl_ms": 300000 } }
}
```
### 기본정보 응답
```json
{
"item": {
"market": "KOSPI",
"code": "005930",
"standard_code": "KR7005930003",
"name": "삼성전자",
"short_name": "삼성전자",
"english_name": "Samsung Electronics",
"security_group": "주권",
"section_type": "대형주",
"stock_certificate_type": "보통주",
"par_value": 100,
"listed_shares": 5969782550
},
"query": { "market": "KOSPI", "code": "005930", "bas_dd": "20260404" },
"proxy": { "name": "k-skill-proxy", "cache": { "hit": false, "ttl_ms": 300000 } }
}
```
### 일별 시세 응답
```json
{
"item": {
"market": "KOSPI",
"code": "005930",
"standard_code": "KR7005930003",
"base_date": "20260404",
"name": "삼성전자",
"close_price": 84000,
"change_price": 1000,
"fluctuation_rate": 1.2,
"open_price": 83000,
"high_price": 84500,
"low_price": 82800,
"trading_volume": 12345678,
"trading_value": 1030000000000,
"market_cap": 500000000000000
},
"query": { "market": "KOSPI", "code": "005930", "bas_dd": "20260404" },
"proxy": { "name": "k-skill-proxy", "cache": { "hit": false, "ttl_ms": 300000 } }
}
```
## Response policy
- 종목명이 모호하면 먼저 `search` 로 시장/종목코드를 좁힌 뒤 `base-info` 또는 `trade-info` 로 들어간다.
- `trade-info` 결과는 일별 snapshot 이다. 실시간 호가/체결처럼 말하지 않는다.
- 휴장일/장마감 이전이면 해당 `bas_dd` 에 데이터가 없을 수 있으니 최근 영업일로 재시도한다.
- 숫자는 사람이 읽기 쉬운 단위(원, 주, 억/조)로 짧게 풀어주되 원본 숫자도 유지한다.
- 답변 말미에 "KRX 공식 데이터 기준 / 투자 조언 아님" 을 짧게 남긴다.
## Keep the answer compact
- 종목명 / 시장 / 종목코드
- 기준일
- 종가 / 등락률 / 거래량 / 시가총액
- 필요할 때만 상장일 / 상장주식수 / 액면가
- 여러 후보가 나오면 상위 3~5개만 보여주고 사용자가 고르게 한다
## Failure modes
- `q`, `market`, `code`, `bas_dd` 형식이 잘못되면 400 응답
- 프록시 서버에 `KRX_API_KEY` 가 없으면 503 응답
- upstream KRX 응답 오류면 502 응답
- 해당 기준일/시장에 종목이 없으면 404 `not_found`
## Done when
- 검색어가 모호하면 `search` 로 후보를 먼저 좁혔다.
- 필요한 경우 `base-info``trade-info` 를 호출해 핵심 수치를 정리했다.
- 사용자가 `KRX_API_KEY` 없이도 조회 가능하다는 점을 유지했다.
- KRX 공식 데이터 기준임을 짧게 남겼다.
## Notes
- 원본 참고: `https://github.com/jjlabsio/korea-stock-mcp`
- 공식 데이터 출처: KRX Open API (`https://openapi.krx.co.kr/contents/OPP/MAIN/main/index.cmd`)
- 이 스킬은 read-only 조회 전용이다.

View file

@ -8,12 +8,16 @@
- `GET /v1/fine-dust/report`
- `GET /v1/seoul-subway/arrival`
- `GET /v1/han-river/water-level`
- `GET /v1/korean-stock/search`
- `GET /v1/korean-stock/base-info`
- `GET /v1/korean-stock/trade-info`
## 환경변수
- `AIR_KOREA_OPEN_API_KEY` — 프록시 서버 쪽 AirKorea upstream key
- `SEOUL_OPEN_API_KEY` — 프록시 서버 쪽 서울 열린데이터 광장 upstream key
- `HRFCO_OPEN_API_KEY` — 프록시 서버 쪽 한강홍수통제소 upstream key
- `KRX_API_KEY` — 프록시 서버 쪽 KRX Open API upstream key
- `KSKILL_PROXY_HOST` — 기본 `127.0.0.1`
- `KSKILL_PROXY_PORT` — 기본 `4020`
- `KSKILL_PROXY_CACHE_TTL_MS` — 기본 `300000`
@ -44,7 +48,15 @@ curl -fsS --get 'http://127.0.0.1:4020/v1/han-river/water-level' \
--data-urlencode 'stationName=한강대교'
```
프록시는 내부적으로 `waterlevel/info.json` 으로 관측소를 해석하고, `waterlevel/list/10M/{WLOBSCD}.json` 으로 최신 수위/유량을 조회합니다.
한국 주식 검색 예시:
```bash
curl -fsS --get 'http://127.0.0.1:4020/v1/korean-stock/search' \
--data-urlencode 'q=삼성전자' \
--data-urlencode 'bas_dd=20260404'
```
프록시는 내부적으로 `waterlevel/info.json` 으로 관측소를 해석하고, `waterlevel/list/10M/{WLOBSCD}.json` 으로 최신 수위/유량을 조회합니다. 한국 주식 route는 KRX Open API에 `AUTH_KEY` 헤더를 서버 쪽에서만 주입합니다.
## PM2 실행

View file

@ -9,7 +9,7 @@
"node": ">=18"
},
"scripts": {
"lint": "node --check src/airkorea.js && node --check src/bluer.js && node --check src/hrfco.js && node --check src/molit.js && node --check src/region-lookup.js && node --check src/server.js && node --check test/airkorea.test.js && node --check test/hrfco.test.js && node --check test/molit.test.js && node --check test/region-lookup.test.js && node --check test/server.test.js",
"lint": "node --check src/airkorea.js && node --check src/bluer.js && node --check src/hrfco.js && node --check src/krx-stock.js && node --check src/molit.js && node --check src/region-lookup.js && node --check src/server.js && node --check test/airkorea.test.js && node --check test/hrfco.test.js && node --check test/molit.test.js && node --check test/region-lookup.test.js && node --check test/server.test.js",
"test": "node --test"
},
"dependencies": {

View file

@ -0,0 +1,300 @@
const KRX_MARKETS = ["KOSPI", "KOSDAQ", "KONEX"];
const KRX_BASE_INFO_URLS = {
KOSPI: "https://data-dbg.krx.co.kr/svc/apis/sto/stk_isu_base_info",
KOSDAQ: "https://data-dbg.krx.co.kr/svc/apis/sto/ksq_isu_base_info",
KONEX: "https://data-dbg.krx.co.kr/svc/apis/sto/knx_isu_base_info"
};
const KRX_TRADE_INFO_URLS = {
KOSPI: "https://data-dbg.krx.co.kr/svc/apis/sto/stk_bydd_trd",
KOSDAQ: "https://data-dbg.krx.co.kr/svc/apis/sto/ksq_bydd_trd",
KONEX: "https://data-dbg.krx.co.kr/svc/apis/sto/knx_bydd_trd"
};
function buildUrl(baseUrl, params) {
const url = new URL(baseUrl);
for (const [key, value] of Object.entries(params)) {
if (value === undefined || value === null || value === "") {
continue;
}
url.searchParams.set(key, String(value));
}
return url;
}
function parseNumber(value) {
if (value === undefined || value === null || value === "") {
return null;
}
const normalized = String(value).replace(/,/g, "").trim();
if (!normalized) {
return null;
}
const parsed = Number(normalized);
return Number.isFinite(parsed) ? parsed : null;
}
function formatYmd(value) {
const raw = String(value || "").trim();
if (!/^\d{8}$/.test(raw)) {
return raw || null;
}
return `${raw.slice(0, 4)}-${raw.slice(4, 6)}-${raw.slice(6, 8)}`;
}
function getCurrentKstDate() {
const formatter = new Intl.DateTimeFormat("en-CA", {
timeZone: "Asia/Seoul",
year: "numeric",
month: "2-digit",
day: "2-digit"
});
return formatter.format(new Date()).replace(/-/g, "");
}
function normalizeBaseItem(item, market) {
const normalized = {
market,
code: item.ISU_SRT_CD || item.ISU_CD || null,
standard_code: item.ISU_CD || null,
short_code: item.ISU_SRT_CD || item.ISU_CD || null,
name: item.ISU_NM || null,
name_ko: item.ISU_NM || null,
short_name: item.ISU_ABBRV || null,
name_abbr: item.ISU_ABBRV || null,
english_name: item.ISU_ENG_NM || null,
name_en: item.ISU_ENG_NM || null,
listed_at: formatYmd(item.LIST_DD),
market_name: item.MKT_TP_NM || market,
security_group: item.SECUGRP_NM || null,
section_type: item.SECT_TP_NM || null,
sector_type: item.SECT_TP_NM || null,
stock_certificate_type: item.KIND_STKCERT_TP_NM || null,
stock_kind: item.KIND_STKCERT_TP_NM || null,
par_value: parseNumber(item.PARVAL),
listed_shares: parseNumber(item.LIST_SHRS)
};
return normalized;
}
function normalizeTradeItem(item, market, baseItem = null) {
const normalized = {
market,
code: baseItem?.code || item.ISU_SRT_CD || item.ISU_CD || null,
short_code: baseItem?.code || item.ISU_SRT_CD || item.ISU_CD || null,
standard_code: item.ISU_CD || baseItem?.standard_code || null,
base_date: item.BAS_DD || null,
date: formatYmd(item.BAS_DD),
name: item.ISU_NM || baseItem?.name || null,
name_ko: item.ISU_NM || baseItem?.name || null,
market_name: item.MKT_NM || baseItem?.market_name || market,
section_type: item.SECT_TP_NM || baseItem?.section_type || null,
sector_type: item.SECT_TP_NM || baseItem?.section_type || null,
close_price: parseNumber(item.TDD_CLSPRC),
change_price: parseNumber(item.CMPPREVDD_PRC),
fluctuation_rate: parseNumber(item.FLUC_RT),
change_rate: parseNumber(item.FLUC_RT),
open_price: parseNumber(item.TDD_OPNPRC),
high_price: parseNumber(item.TDD_HGPRC),
low_price: parseNumber(item.TDD_LWPRC),
trading_volume: parseNumber(item.ACC_TRDVOL),
volume: parseNumber(item.ACC_TRDVOL),
trading_value: parseNumber(item.ACC_TRDVAL),
traded_value: parseNumber(item.ACC_TRDVAL),
market_cap: parseNumber(item.MKTCAP),
listed_shares: parseNumber(item.LIST_SHRS || baseItem?.listed_shares)
};
return normalized;
}
function matchesCodes(item, codes) {
if (!codes?.length) {
return true;
}
return codes.some((code) => code === item.ISU_CD || code === item.ISU_SRT_CD);
}
async function krxRequest(url, apiKey, fetchImpl = global.fetch) {
if (!apiKey) {
const error = new Error("KRX_API_KEY is not configured on the proxy server.");
error.code = "upstream_not_configured";
error.statusCode = 503;
throw error;
}
const response = await fetchImpl(url, {
headers: {
"content-type": "application/json",
AUTH_KEY: apiKey
},
signal: AbortSignal.timeout(20000)
});
if (!response.ok) {
const error = new Error(`KRX API HTTP 오류 (status: ${response.status}): ${response.statusText}`);
error.code = "upstream_error";
error.statusCode = 502;
throw error;
}
const payload = await response.json();
if (!Array.isArray(payload.OutBlock_1)) {
const error = new Error("KRX API 오류: 응답에 OutBlock_1 배열이 없습니다.");
error.code = "krx_api_error";
error.statusCode = 502;
throw error;
}
return payload.OutBlock_1;
}
async function fetchBaseInfo({ market, basDd = getCurrentKstDate(), codeList = [], apiKey, fetchImpl = global.fetch }) {
const items = await krxRequest(buildUrl(KRX_BASE_INFO_URLS[market], { basDd }), apiKey, fetchImpl);
return items
.filter((item) => matchesCodes(item, codeList))
.map((item) => normalizeBaseItem(item, market));
}
async function fetchTradeInfo({ market, basDd = getCurrentKstDate(), codeList, apiKey, fetchImpl = global.fetch }) {
const tradeItems = await krxRequest(buildUrl(KRX_TRADE_INFO_URLS[market], { basDd }), apiKey, fetchImpl);
const directlyMatched = tradeItems.filter((item) => matchesCodes(item, codeList));
if (directlyMatched.length > 0) {
return directlyMatched.map((item) => normalizeTradeItem(item, market));
}
const baseItems = await fetchBaseInfo({ market, basDd, codeList, apiKey, fetchImpl });
if (baseItems.length === 0) {
return [];
}
const standardCodes = new Set(baseItems.map((item) => item.standard_code).filter(Boolean));
const baseByStandardCode = new Map(baseItems.map((item) => [item.standard_code, item]));
return tradeItems
.filter((item) => standardCodes.has(item.ISU_CD) || baseByStandardCode.has(item.ISU_CD))
.map((item) => normalizeTradeItem(item, market, baseByStandardCode.get(item.ISU_CD)));
}
function tokenize(query) {
return String(query)
.trim()
.toLowerCase()
.split(/\s+/)
.filter(Boolean);
}
function scoreSearchMatch(item, query) {
const raw = String(query).trim().toLowerCase();
if (!raw) {
return -1;
}
const haystacks = [
item.code,
item.standard_code,
item.name,
item.short_name,
item.english_name
].map((value) => String(value || "").toLowerCase());
if (haystacks.some((value) => value === raw)) {
return 100;
}
const tokens = tokenize(raw);
if (tokens.length > 0 && tokens.every((token) => haystacks.some((value) => value.includes(token)))) {
return 50;
}
return -1;
}
function buildBaseInfoSnapshotCacheKey({ market, basDd }) {
return `krx-base-info:${market}:${basDd}`;
}
async function fetchBaseInfoSnapshot({
market,
basDd,
apiKey,
fetchImpl = global.fetch,
cache = null,
cacheTtlMs = 0
}) {
const cacheKey = cache ? buildBaseInfoSnapshotCacheKey({ market, basDd }) : null;
if (cacheKey) {
const cached = cache.get(cacheKey);
if (cached) {
return cached;
}
}
const items = await fetchBaseInfo({ market, basDd, apiKey, fetchImpl });
if (cacheKey) {
cache.set(cacheKey, items, cacheTtlMs);
}
return items;
}
async function searchStocks({
query,
basDd = getCurrentKstDate(),
market = null,
limit = 10,
apiKey,
fetchImpl = global.fetch,
cache = null,
cacheTtlMs = 0
}) {
const markets = market ? [market] : KRX_MARKETS;
const settledResults = await Promise.allSettled(markets.map(async (entryMarket) => ({
market: entryMarket,
items: await fetchBaseInfoSnapshot({
market: entryMarket,
basDd,
apiKey,
fetchImpl,
cache,
cacheTtlMs
})
})));
const successfulResults = settledResults
.filter((result) => result.status === "fulfilled")
.map((result) => result.value);
if (successfulResults.length === 0) {
const firstFailure = settledResults.find((result) => result.status === "rejected");
throw firstFailure?.reason || new Error("KRX search failed for every market.");
}
return {
items: successfulResults
.flatMap(({ market: entryMarket, items }) =>
items
.map((item) => ({ ...item, market: item.market || entryMarket, score: scoreSearchMatch(item, query) }))
.filter((item) => item.score >= 0)
)
.sort((left, right) => right.score - left.score || left.name.localeCompare(right.name, "ko"))
.slice(0, limit)
.map(({ score, ...item }) => item)
};
}
module.exports = {
KRX_MARKETS,
fetchBaseInfo,
fetchTradeInfo,
getCurrentKstDate,
searchStocks
};

View file

@ -3,6 +3,7 @@ const Fastify = require("fastify");
const { fetchFineDustReport } = require("./airkorea");
const { proxyBlueRibbonNearbyRequest } = require("./bluer");
const { fetchWaterLevelReport } = require("./hrfco");
const { KRX_MARKETS, fetchBaseInfo, fetchTradeInfo, getCurrentKstDate, searchStocks } = require("./krx-stock");
const { fetchTransactions, VALID_ASSET_TYPES, VALID_DEAL_TYPES } = require("./molit");
const { searchRegionCode } = require("./region-lookup");
const AIR_KOREA_UPSTREAM_BASE_URL = "http://apis.data.go.kr";
@ -51,6 +52,7 @@ function buildConfig(env = process.env) {
opinetApiKey: trimOrNull(env.OPINET_API_KEY),
blueRibbonSessionId: trimOrNull(env.BLUE_RIBBON_SESSION_ID),
molitApiKey: trimOrNull(env.DATA_GO_KR_API_KEY),
krxApiKey: trimOrNull(env.KRX_API_KEY),
cacheTtlMs: parseInteger(env.KSKILL_PROXY_CACHE_TTL_MS, 300000),
rateLimitWindowMs: parseInteger(env.KSKILL_PROXY_RATE_LIMIT_WINDOW_MS, 60000),
rateLimitMax: parseInteger(env.KSKILL_PROXY_RATE_LIMIT_MAX, 60)
@ -91,7 +93,7 @@ function buildRateLimiter(config) {
const state = new Map();
return function rateLimit(request, reply) {
const key = trimOrNull(request.headers["cf-connecting-ip"]) || request.ip || "unknown";
const key = request.ip || "unknown";
const now = Date.now();
const current = state.get(key);
@ -236,6 +238,68 @@ function normalizeHanRiverWaterLevelQuery(query) {
};
}
function normalizeKrxMarket(value) {
const normalized = trimOrNull(value)?.toUpperCase();
if (!normalized || !KRX_MARKETS.includes(normalized)) {
throw new Error(`Provide market as one of: ${KRX_MARKETS.join(", ")}.`);
}
return normalized;
}
function normalizeKoreanStockDate(value) {
const normalized = trimOrNull(value) || getCurrentKstDate();
if (!/^\d{8}$/.test(normalized)) {
throw new Error("Provide bas_dd/date as YYYYMMDD.");
}
return normalized;
}
function normalizeKoreanStockCodes(value) {
const values = Array.isArray(value) ? value : [value];
const codes = values
.flatMap((entry) => String(entry || "").split(","))
.map((entry) => entry.trim())
.filter(Boolean);
if (codes.length === 0) {
throw new Error("Provide code/stockCode/codes.");
}
return [...new Set(codes)];
}
function normalizeKoreanStockSearchQuery(query) {
const q = trimOrNull(query.q ?? query.query);
if (!q) {
throw new Error("Provide q/query.");
}
const basDd = normalizeKoreanStockDate(query.bas_dd ?? query.basDd ?? query.date);
const marketValue = trimOrNull(query.market);
const limit = parseInteger(query.limit, 10);
if (limit < 1 || limit > 20) {
throw new Error("limit must be between 1 and 20.");
}
return {
q,
basDd,
market: marketValue ? normalizeKrxMarket(marketValue) : null,
limit
};
}
function normalizeKoreanStockLookupQuery(query) {
return {
market: normalizeKrxMarket(query.market),
code: normalizeKoreanStockCodes(query.code ?? query.codes ?? query.codeList ?? query.stockCode ?? query.stock_code)[0],
basDd: normalizeKoreanStockDate(query.bas_dd ?? query.basDd ?? query.date)
};
}
function isAllowedAirKoreaRoute(service, operation) {
return ALLOWED_AIRKOREA_ROUTES.get(service)?.has(operation) || false;
@ -437,7 +501,8 @@ function buildServer({ env = process.env, provider = null } = {}) {
seoulOpenApiConfigured: Boolean(config.seoulOpenApiKey),
hrfcoConfigured: Boolean(config.hrfcoApiKey),
opinetConfigured: Boolean(config.opinetApiKey),
molitConfigured: Boolean(config.molitApiKey)
molitConfigured: Boolean(config.molitApiKey),
krxConfigured: Boolean(config.krxApiKey)
},
auth: {
tokenRequired: false
@ -993,6 +1058,283 @@ function buildServer({ env = process.env, provider = null } = {}) {
return payload;
});
app.get("/v1/korean-stock/search", async (request, reply) => {
let normalized;
try {
normalized = normalizeKoreanStockSearchQuery(request.query || {});
} catch (error) {
reply.code(400);
return {
error: "bad_request",
message: error.message
};
}
const cacheKey = makeCacheKey({
route: "korean-stock-search",
q: normalized.q.toLowerCase(),
basDd: normalized.basDd,
market: normalized.market,
limit: normalized.limit
});
const cached = cache.get(cacheKey);
if (cached) {
return {
...cached,
proxy: {
...cached.proxy,
cache: {
hit: true,
ttl_ms: config.cacheTtlMs
}
}
};
}
if (!config.krxApiKey) {
reply.code(503);
return {
error: "upstream_not_configured",
message: "KRX_API_KEY is not configured on the proxy server.",
proxy: {
name: config.proxyName,
cache: {
hit: false,
ttl_ms: config.cacheTtlMs
}
}
};
}
let items;
try {
const result = await searchStocks({
query: normalized.q,
basDd: normalized.basDd,
market: normalized.market,
limit: normalized.limit,
apiKey: config.krxApiKey,
cache,
cacheTtlMs: config.cacheTtlMs
});
items = result.items;
} catch (error) {
reply.code(error.statusCode && error.statusCode >= 400 ? error.statusCode : 502);
return {
error: error.code || "proxy_error",
message: error.message
};
}
const payload = {
items,
query: {
q: normalized.q,
bas_dd: normalized.basDd,
market: normalized.market,
limit: normalized.limit
},
proxy: {
name: config.proxyName,
cache: {
hit: false,
ttl_ms: config.cacheTtlMs
},
requested_at: new Date().toISOString()
}
};
cache.set(cacheKey, payload, config.cacheTtlMs);
return payload;
});
app.get("/v1/korean-stock/base-info", async (request, reply) => {
let normalized;
try {
normalized = normalizeKoreanStockLookupQuery(request.query || {});
} catch (error) {
reply.code(400);
return {
error: "bad_request",
message: error.message
};
}
const cacheKey = makeCacheKey({
route: "korean-stock-base-info",
...normalized
});
const cached = cache.get(cacheKey);
if (cached) {
return {
...cached,
proxy: {
...cached.proxy,
cache: {
hit: true,
ttl_ms: config.cacheTtlMs
}
}
};
}
if (!config.krxApiKey) {
reply.code(503);
return {
error: "upstream_not_configured",
message: "KRX_API_KEY is not configured on the proxy server.",
proxy: {
name: config.proxyName,
cache: {
hit: false,
ttl_ms: config.cacheTtlMs
}
}
};
}
let items;
try {
items = await fetchBaseInfo({
market: normalized.market,
basDd: normalized.basDd,
codeList: [normalized.code],
apiKey: config.krxApiKey
});
} catch (error) {
reply.code(error.statusCode && error.statusCode >= 400 ? error.statusCode : 502);
return {
error: error.code || "proxy_error",
message: error.message
};
}
if (items.length === 0) {
reply.code(404);
return {
error: "not_found",
message: `기준일 ${normalized.basDd}${normalized.market} 시장 종목 ${normalized.code} 을(를) 찾지 못했습니다.`
};
}
const payload = {
items,
item: items[0],
query: {
market: normalized.market,
code: normalized.code,
codes: [normalized.code],
bas_dd: normalized.basDd
},
proxy: {
name: config.proxyName,
cache: {
hit: false,
ttl_ms: config.cacheTtlMs
},
requested_at: new Date().toISOString()
}
};
cache.set(cacheKey, payload, config.cacheTtlMs);
return payload;
});
app.get("/v1/korean-stock/trade-info", async (request, reply) => {
let normalized;
try {
normalized = normalizeKoreanStockLookupQuery(request.query || {});
} catch (error) {
reply.code(400);
return {
error: "bad_request",
message: error.message
};
}
const cacheKey = makeCacheKey({
route: "korean-stock-trade-info",
...normalized
});
const cached = cache.get(cacheKey);
if (cached) {
return {
...cached,
proxy: {
...cached.proxy,
cache: {
hit: true,
ttl_ms: config.cacheTtlMs
}
}
};
}
if (!config.krxApiKey) {
reply.code(503);
return {
error: "upstream_not_configured",
message: "KRX_API_KEY is not configured on the proxy server.",
proxy: {
name: config.proxyName,
cache: {
hit: false,
ttl_ms: config.cacheTtlMs
}
}
};
}
let items;
try {
items = await fetchTradeInfo({
market: normalized.market,
basDd: normalized.basDd,
codeList: [normalized.code],
apiKey: config.krxApiKey
});
} catch (error) {
reply.code(error.statusCode && error.statusCode >= 400 ? error.statusCode : 502);
return {
error: error.code || "proxy_error",
message: error.message
};
}
if (items.length === 0) {
reply.code(404);
return {
error: "not_found",
message: `기준일 ${normalized.basDd}${normalized.market} 시장 종목 ${normalized.code} 의 일별 시세를 찾지 못했습니다.`
};
}
const payload = {
items,
item: items[0],
query: {
market: normalized.market,
code: normalized.code,
codes: [normalized.code],
bas_dd: normalized.basDd
},
proxy: {
name: config.proxyName,
cache: {
hit: false,
ttl_ms: config.cacheTtlMs
},
requested_at: new Date().toISOString()
}
};
cache.set(cacheKey, payload, config.cacheTtlMs);
return payload;
});
app.setErrorHandler((error, request, reply) => {
request.log.error(error);
const statusCode = error.statusCode && error.statusCode >= 400 ? error.statusCode : 500;
@ -1035,6 +1377,8 @@ module.exports = {
normalizeBlueRibbonNearbyQuery,
normalizeFineDustQuery,
normalizeHanRiverWaterLevelQuery,
normalizeKoreanStockLookupQuery,
normalizeKoreanStockSearchQuery,
normalizeOpinetAroundQuery,
normalizeOpinetDetailQuery,
normalizeRealEstateQuery,

View file

@ -29,9 +29,511 @@ test("health endpoint stays public and reports auth/upstream status", async (t)
assert.equal(body.ok, true);
assert.equal(body.auth.tokenRequired, false);
assert.equal(body.upstreams.airKoreaConfigured, false);
assert.equal(body.upstreams.krxConfigured, false);
assert.equal(body.upstreams.seoulOpenApiConfigured, false);
});
test("health endpoint reports KRX upstream status when configured", async (t) => {
const app = buildServer({
env: {
KRX_API_KEY: "krx-key"
}
});
t.after(async () => {
await app.close();
});
const response = await app.inject({
method: "GET",
url: "/health"
});
assert.equal(response.statusCode, 200);
assert.equal(response.json().upstreams.krxConfigured, true);
});
test("korean stock search endpoint stays public and caches normalized search queries", async (t) => {
const originalFetch = global.fetch;
const fetchCalls = [];
global.fetch = async (url, options = {}) => {
const text = String(url);
fetchCalls.push({ url: text, headers: options.headers });
if (text.includes("stk_isu_base_info")) {
return new Response(
JSON.stringify({
OutBlock_1: [
{
ISU_CD: "KR7005930003",
ISU_SRT_CD: "005930",
ISU_NM: "삼성전자",
ISU_ABBRV: "삼성전자",
ISU_ENG_NM: "Samsung Electronics",
LIST_DD: "19750611",
MKT_TP_NM: "KOSPI",
SECUGRP_NM: "주권",
SECT_TP_NM: "대형주",
KIND_STKCERT_TP_NM: "보통주",
PARVAL: "100",
LIST_SHRS: "5969782550"
}
]
}),
{
status: 200,
headers: { "content-type": "application/json;charset=UTF-8" }
}
);
}
if (text.includes("ksq_isu_base_info") || text.includes("knx_isu_base_info")) {
return new Response(
JSON.stringify({
OutBlock_1: []
}),
{
status: 200,
headers: { "content-type": "application/json;charset=UTF-8" }
}
);
}
throw new Error(`unexpected URL: ${url}`);
};
const app = buildServer({
env: {
KRX_API_KEY: "krx-key",
KSKILL_PROXY_CACHE_TTL_MS: "60000"
}
});
t.after(async () => {
global.fetch = originalFetch;
await app.close();
});
const first = await app.inject({
method: "GET",
url: "/v1/korean-stock/search?q=%20%EC%82%BC%EC%84%B1%EC%A0%84%EC%9E%90%20&bas_dd=20260404"
});
const second = await app.inject({
method: "GET",
url: "/v1/korean-stock/search?query=%20%EC%82%BC%EC%84%B1%EC%A0%84%EC%9E%90%20&date=20260404&limit=10"
});
assert.equal(first.statusCode, 200);
assert.equal(second.statusCode, 200);
assert.equal(fetchCalls.length, 3);
assert.equal(first.json().proxy.cache.hit, false);
assert.equal(second.json().proxy.cache.hit, true);
assert.equal(first.json().items[0].market, "KOSPI");
assert.equal(first.json().items[0].code, "005930");
assert.equal(first.json().items[0].name, "삼성전자");
assert.ok(fetchCalls.every((entry) => entry.url.startsWith("https://data-dbg.krx.co.kr/")));
assert.match(fetchCalls[0].url, /basDd=20260404/);
assert.equal(fetchCalls[0].headers.AUTH_KEY, "krx-key");
});
test("korean stock search rate limit does not trust spoofed cf-connecting-ip on direct requests", async (t) => {
const app = buildServer({
env: {
KSKILL_PROXY_RATE_LIMIT_MAX: "1"
}
});
t.after(async () => {
await app.close();
});
const first = await app.inject({
method: "GET",
url: "/v1/korean-stock/search?q=%EC%82%BC%EC%84%B1%EC%A0%84%EC%9E%90&bas_dd=20260404",
headers: {
"cf-connecting-ip": "1.1.1.1"
}
});
const second = await app.inject({
method: "GET",
url: "/v1/korean-stock/search?q=%EC%82%BC%EC%84%B1%EC%A0%84%EC%9E%90&bas_dd=20260404",
headers: {
"cf-connecting-ip": "2.2.2.2"
}
});
assert.equal(first.statusCode, 503);
assert.equal(first.json().error, "upstream_not_configured");
assert.equal(second.statusCode, 429);
assert.equal(second.json().error, "rate_limited");
});
test("korean stock search returns healthy market results when another market upstream fails", async (t) => {
const originalFetch = global.fetch;
const fetchCalls = [];
global.fetch = async (url, options = {}) => {
const text = String(url);
fetchCalls.push({ url: text, headers: options.headers });
if (text.includes("stk_isu_base_info")) {
return new Response(
JSON.stringify({
OutBlock_1: [
{
ISU_CD: "KR7005930003",
ISU_SRT_CD: "005930",
ISU_NM: "삼성전자",
ISU_ABBRV: "삼성전자",
ISU_ENG_NM: "Samsung Electronics",
LIST_DD: "19750611",
MKT_TP_NM: "KOSPI",
SECUGRP_NM: "주권",
SECT_TP_NM: "대형주",
KIND_STKCERT_TP_NM: "보통주",
PARVAL: "100",
LIST_SHRS: "5969782550"
}
]
}),
{
status: 200,
headers: { "content-type": "application/json;charset=UTF-8" }
}
);
}
if (text.includes("ksq_isu_base_info")) {
return new Response("boom", {
status: 500,
statusText: "Internal Server Error"
});
}
if (text.includes("knx_isu_base_info")) {
return new Response(
JSON.stringify({
OutBlock_1: []
}),
{
status: 200,
headers: { "content-type": "application/json;charset=UTF-8" }
}
);
}
throw new Error(`unexpected URL: ${url}`);
};
const app = buildServer({
env: {
KRX_API_KEY: "krx-key"
}
});
t.after(async () => {
global.fetch = originalFetch;
await app.close();
});
const response = await app.inject({
method: "GET",
url: "/v1/korean-stock/search?q=%EC%82%BC%EC%84%B1%EC%A0%84%EC%9E%90&bas_dd=20260404"
});
assert.equal(response.statusCode, 200);
assert.equal(response.json().items.length, 1);
assert.equal(response.json().items[0].market, "KOSPI");
assert.equal(response.json().items[0].code, "005930");
assert.equal(response.json().items[0].name, "삼성전자");
assert.equal(fetchCalls.length, 3);
assert.ok(fetchCalls.every((entry) => entry.url.startsWith("https://data-dbg.krx.co.kr/")));
});
test("korean stock search reuses per-market base snapshots across different queries for the same date", async (t) => {
const originalFetch = global.fetch;
const fetchCalls = [];
global.fetch = async (url, options = {}) => {
const text = String(url);
fetchCalls.push({ url: text, headers: options.headers });
if (text.includes("stk_isu_base_info")) {
return new Response(
JSON.stringify({
OutBlock_1: [
{
ISU_CD: "KR7005930003",
ISU_SRT_CD: "005930",
ISU_NM: "삼성전자",
ISU_ABBRV: "삼성전자",
ISU_ENG_NM: "Samsung Electronics",
LIST_DD: "19750611",
MKT_TP_NM: "KOSPI",
SECUGRP_NM: "주권",
SECT_TP_NM: "대형주",
KIND_STKCERT_TP_NM: "보통주",
PARVAL: "100",
LIST_SHRS: "5969782550"
}
]
}),
{
status: 200,
headers: { "content-type": "application/json;charset=UTF-8" }
}
);
}
if (text.includes("ksq_isu_base_info") || text.includes("knx_isu_base_info")) {
return new Response(
JSON.stringify({
OutBlock_1: []
}),
{
status: 200,
headers: { "content-type": "application/json;charset=UTF-8" }
}
);
}
throw new Error(`unexpected URL: ${url}`);
};
const app = buildServer({
env: {
KRX_API_KEY: "krx-key",
KSKILL_PROXY_CACHE_TTL_MS: "60000"
}
});
t.after(async () => {
global.fetch = originalFetch;
await app.close();
});
const byKoreanName = await app.inject({
method: "GET",
url: "/v1/korean-stock/search?q=%EC%82%BC%EC%84%B1%EC%A0%84%EC%9E%90&bas_dd=20260404"
});
const byEnglishName = await app.inject({
method: "GET",
url: "/v1/korean-stock/search?q=Samsung&bas_dd=20260404"
});
assert.equal(byKoreanName.statusCode, 200);
assert.equal(byEnglishName.statusCode, 200);
assert.equal(byKoreanName.json().items[0].code, "005930");
assert.equal(byEnglishName.json().items[0].code, "005930");
assert.equal(fetchCalls.length, 3);
});
test("korean stock base-info endpoint returns 503 when proxy server lacks KRX API key", async (t) => {
const app = buildServer();
t.after(async () => {
await app.close();
});
const response = await app.inject({
method: "GET",
url: "/v1/korean-stock/base-info?market=KOSPI&code=005930&bas_dd=20260404"
});
assert.equal(response.statusCode, 503);
assert.equal(response.json().error, "upstream_not_configured");
});
test("korean stock base-info endpoint normalizes upstream KRX fields", async (t) => {
const originalFetch = global.fetch;
let calledUrl;
let calledHeaders;
global.fetch = async (url, options = {}) => {
calledUrl = String(url);
calledHeaders = options.headers;
return new Response(
JSON.stringify({
OutBlock_1: [
{
ISU_CD: "KR7005930003",
ISU_SRT_CD: "005930",
ISU_NM: "삼성전자",
ISU_ABBRV: "삼성전자",
ISU_ENG_NM: "Samsung Electronics",
LIST_DD: "19750611",
MKT_TP_NM: "KOSPI",
SECUGRP_NM: "주권",
SECT_TP_NM: "대형주",
KIND_STKCERT_TP_NM: "보통주",
PARVAL: "100",
LIST_SHRS: "5969782550"
}
]
}),
{
status: 200,
headers: { "content-type": "application/json;charset=UTF-8" }
}
);
};
const app = buildServer({
env: {
KRX_API_KEY: "krx-key"
}
});
t.after(async () => {
global.fetch = originalFetch;
await app.close();
});
const response = await app.inject({
method: "GET",
url: "/v1/korean-stock/base-info?market=KOSPI&code=005930&bas_dd=20260404"
});
assert.equal(response.statusCode, 200);
assert.ok(calledUrl.startsWith("https://data-dbg.krx.co.kr/"));
assert.match(calledUrl, /stk_isu_base_info/);
assert.match(calledUrl, /basDd=20260404/);
assert.equal(calledHeaders.AUTH_KEY, "krx-key");
assert.equal(response.json().item.code, "005930");
assert.equal(response.json().item.name, "삼성전자");
assert.equal(response.json().item.listed_shares, 5969782550);
});
test("korean stock trade-info endpoint caches successful responses", async (t) => {
const originalFetch = global.fetch;
let fetchCalls = 0;
let calledUrl;
global.fetch = async (url) => {
fetchCalls += 1;
calledUrl = String(url);
return new Response(
JSON.stringify({
OutBlock_1: [
{
BAS_DD: "20260404",
ISU_CD: "KR7005930003",
ISU_SRT_CD: "005930",
ISU_NM: "삼성전자",
MKT_NM: "KOSPI",
SECT_TP_NM: "대형주",
TDD_CLSPRC: "84000",
CMPPREVDD_PRC: "1000",
FLUC_RT: "1.20",
TDD_OPNPRC: "83000",
TDD_HGPRC: "84500",
TDD_LWPRC: "82800",
ACC_TRDVOL: "12345678",
ACC_TRDVAL: "1030000000000",
MKTCAP: "500000000000000",
LIST_SHRS: "5969782550"
}
]
}),
{
status: 200,
headers: { "content-type": "application/json;charset=UTF-8" }
}
);
};
const app = buildServer({
env: {
KRX_API_KEY: "krx-key",
KSKILL_PROXY_CACHE_TTL_MS: "60000"
}
});
t.after(async () => {
global.fetch = originalFetch;
await app.close();
});
const first = await app.inject({
method: "GET",
url: "/v1/korean-stock/trade-info?market=KOSPI&code=005930&bas_dd=20260404"
});
const second = await app.inject({
method: "GET",
url: "/v1/korean-stock/trade-info?market=KOSPI&stockCode=005930&date=20260404"
});
assert.equal(first.statusCode, 200);
assert.equal(second.statusCode, 200);
assert.equal(fetchCalls, 1);
assert.ok(calledUrl.startsWith("https://data-dbg.krx.co.kr/"));
assert.equal(first.json().proxy.cache.hit, false);
assert.equal(second.json().proxy.cache.hit, true);
assert.equal(first.json().item.close_price, 84000);
assert.equal(first.json().item.trading_value, 1030000000000);
});
test("korean stock trade-info endpoint does not relabel an unmatched single-row upstream response", async (t) => {
const originalFetch = global.fetch;
const fetchCalls = [];
global.fetch = async (url) => {
const text = String(url);
fetchCalls.push(text);
if (text.includes("stk_bydd_trd")) {
return new Response(
JSON.stringify({
OutBlock_1: [
{
BAS_DD: "20260404",
ISU_CD: "KR7000660001",
ISU_NM: "하이트진로",
MKT_NM: "KOSPI",
SECT_TP_NM: "중형주",
TDD_CLSPRC: "21000"
}
]
}),
{
status: 200,
headers: { "content-type": "application/json;charset=UTF-8" }
}
);
}
if (text.includes("stk_isu_base_info")) {
return new Response(
JSON.stringify({
OutBlock_1: []
}),
{
status: 200,
headers: { "content-type": "application/json;charset=UTF-8" }
}
);
}
throw new Error(`unexpected URL: ${url}`);
};
const app = buildServer({
env: {
KRX_API_KEY: "krx-key"
}
});
t.after(async () => {
global.fetch = originalFetch;
await app.close();
});
const response = await app.inject({
method: "GET",
url: "/v1/korean-stock/trade-info?market=KOSPI&code=005930&bas_dd=20260404"
});
assert.equal(response.statusCode, 404);
assert.equal(response.json().error, "not_found");
assert.equal(fetchCalls.length, 2);
assert.ok(fetchCalls.every((entry) => entry.startsWith("https://data-dbg.krx.co.kr/")));
});
test("fine dust endpoint stays publicly callable without proxy auth", async (t) => {
let providerCalls = 0;
const app = buildServer({
@ -603,6 +1105,48 @@ const SAMPLE_APT_TRADE_XML = `<?xml version="1.0" encoding="UTF-8"?>
</body>
</response>`;
const SAMPLE_KRX_BASE_INFO = {
OutBlock_1: [
{
ISU_CD: "KR7005930003",
ISU_SRT_CD: "005930",
ISU_NM: "삼성전자",
ISU_ABBRV: "삼성전자",
ISU_ENG_NM: "Samsung Electronics",
LIST_DD: "19750611",
MKT_TP_NM: "KOSPI",
SECUGRP_NM: "주권",
SECT_TP_NM: "전기전자",
KIND_STKCERT_TP_NM: "보통주",
PARVAL: "100",
LIST_SHRS: "5,919,638,922"
}
]
};
const SAMPLE_KRX_TRADE_INFO = {
OutBlock_1: [
{
BAS_DD: "20260404",
ISU_CD: "KR7005930003",
ISU_SRT_CD: "005930",
ISU_NM: "삼성전자",
MKT_NM: "KOSPI",
SECT_TP_NM: "전기전자",
TDD_CLSPRC: "85,000",
CMPPREVDD_PRC: "1,200",
FLUC_RT: "1.43",
TDD_OPNPRC: "84,100",
TDD_HGPRC: "85,400",
TDD_LWPRC: "83,900",
ACC_TRDVOL: "12,345,678",
ACC_TRDVAL: "1,045,678,900,000",
MKTCAP: "503,169,308,370,000",
LIST_SHRS: "5,919,638,922"
}
]
};
test("real estate region-code endpoint returns matching codes", async (t) => {
const app = buildServer();

View file

@ -1467,6 +1467,75 @@ test("real-estate-search skill uses proxy endpoints not MCP self-host", () => {
}
});
test("repository docs advertise the korean-stock-search skill and proxy-backed KRX approach", () => {
const readme = read("README.md");
const install = read(path.join("docs", "install.md"));
const setup = read(path.join("docs", "setup.md"));
const security = read(path.join("docs", "security-and-secrets.md"));
const setupSkill = read(path.join("k-skill-setup", "SKILL.md"));
const featureDocPath = path.join(repoRoot, "docs", "features", "korean-stock-search.md");
const featureDoc = read(path.join("docs", "features", "korean-stock-search.md"));
const skillPath = path.join(repoRoot, "korean-stock-search", "SKILL.md");
const skill = read(path.join("korean-stock-search", "SKILL.md"));
const sources = read(path.join("docs", "sources.md"));
const roadmap = read(path.join("docs", "roadmap.md"));
const proxyReadme = read(path.join("packages", "k-skill-proxy", "README.md"));
const proxyDoc = read(path.join("docs", "features", "k-skill-proxy.md"));
const packageJson = readJson("package.json");
assert.ok(fs.existsSync(featureDocPath), "expected docs/features/korean-stock-search.md to exist");
assert.ok(fs.existsSync(skillPath), "expected korean-stock-search/SKILL.md to exist");
assert.match(readme, /\| 한국 주식 정보 조회 \|/);
assert.match(readme, /\[한국 주식 정보 조회 가이드\]\(docs\/features\/korean-stock-search\.md\)/);
assert.match(install, /--skill korean-stock-search/);
for (const doc of [skill, featureDoc]) {
assert.match(doc, /https:\/\/github\.com\/jjlabsio\/korea-stock-mcp/);
assert.match(doc, /k-skill-proxy\.nomadamas\.org/);
assert.match(doc, /\/v1\/korean-stock\/search/);
assert.match(doc, /\/v1\/korean-stock\/base-info/);
assert.match(doc, /\/v1\/korean-stock\/trade-info/);
assert.match(doc, /KRX_API_KEY/);
assert.match(doc, /사용자.*KRX_API_KEY.*(불필요|준비할 필요가 없)/u);
assert.doesNotMatch(doc, /packages\/korean-stock-search/);
assert.doesNotMatch(doc, /python-packages\/korean-stock-search/);
}
for (const doc of [setup, security, setupSkill]) {
assert.match(doc, /KRX_API_KEY/);
}
for (const doc of [proxyReadme, proxyDoc]) {
assert.match(doc, /\/v1\/korean-stock\/search/);
assert.match(doc, /\/v1\/korean-stock\/base-info/);
assert.match(doc, /\/v1\/korean-stock\/trade-info/);
}
assert.match(sources, /korea-stock-mcp: https:\/\/github\.com\/jjlabsio\/korea-stock-mcp/);
assert.match(roadmap, /한국 주식 정보 조회 스킬 출시/);
assert.ok(
!packageJson.workspaces.some((workspace) => workspace.includes("korean-stock-search")),
"expected no repo workspace to be added for korean-stock-search",
);
assert.equal(fs.existsSync(path.join(repoRoot, "packages", "korean-stock-search")), false);
});
test("korean-stock-search skill stays proxy-first and does not require local MCP install", () => {
const featureDoc = read(path.join("docs", "features", "korean-stock-search.md"));
const skill = read(path.join("korean-stock-search", "SKILL.md"));
for (const doc of [skill, featureDoc]) {
assert.match(doc, /k-skill-proxy\.nomadamas\.org\/v1\/korean-stock/);
assert.match(doc, /curl/);
assert.match(doc, /proxy.*서버.*KRX_API_KEY|KRX_API_KEY.*proxy.*서버/u);
assert.doesNotMatch(doc, /npx\s+(?:-y|--yes)\s+korea-stock-mcp/);
assert.doesNotMatch(doc, /codex mcp add/);
assert.doesNotMatch(doc, /claude_desktop_config\.json/);
assert.doesNotMatch(doc, /DART_API_KEY/);
}
});
test("repository docs advertise the shipped korean-spell-check helper assets", () => {
const readme = read("README.md");
const install = read(path.join("docs", "install.md"));