🤖 센드빌(Sendbill) MCP 서버 초기 구현

- 전자세금계산서 API 전체 커버 (29개 툴)
- 인증서 관리, 계산서 발행(단건/묶음/대량), 상태조회, 회원관리
- 홈택스 연동 (세금계산서/현금영수증 일별/월별 매출/매입)
- 휴폐업 조회, 추가메일 전송
- 에러코드표 내장 (Excel 기반)
- 코드표 참조 툴 (billtype, taxrate, billstat 등)
- 인증: SENDBILL_SBKEY 환경변수

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
thug0bin 2026-03-02 01:51:24 +09:00
commit 65c69f5666
7 changed files with 2092 additions and 0 deletions

6
.gitignore vendored Normal file
View File

@ -0,0 +1,6 @@
node_modules/
dist/
*.js.map
*.d.ts.map
.env
.env.local

1082
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

21
package.json Normal file
View File

@ -0,0 +1,21 @@
{
"name": "sendbill-mcp",
"version": "1.0.0",
"description": "센드빌 전자세금계산서 API MCP 서버",
"type": "module",
"bin": {
"sendbill-mcp": "./dist/index.js"
},
"scripts": {
"build": "tsc",
"dev": "tsc --watch",
"start": "node dist/index.js"
},
"dependencies": {
"@modelcontextprotocol/sdk": "^1.0.0"
},
"devDependencies": {
"typescript": "^5.0.0",
"@types/node": "^20.0.0"
}
}

46
src/client.ts Normal file
View File

@ -0,0 +1,46 @@
// 센드빌 API HTTP 클라이언트
const BASE_URL = "https://api.sendbill.co.kr";
export class SendbillClient {
private sbkey: string;
constructor(sbkey: string) {
this.sbkey = sbkey;
}
async post(endpoint: string, body: Record<string, unknown>): Promise<unknown> {
const url = `${BASE_URL}${endpoint}`;
const response = await fetch(url, {
method: "POST",
headers: {
"SBKEY": this.sbkey,
"Content-Type": "application/json; charset=UTF-8",
"Accept-Charset": "UTF-8",
},
body: JSON.stringify(body),
});
if (!response.ok) {
throw new Error(`HTTP 오류: ${response.status} ${response.statusText}`);
}
const text = await response.text();
try {
return JSON.parse(text);
} catch {
return { raw: text };
}
}
}
export function createClient(): SendbillClient {
const sbkey = process.env.SENDBILL_SBKEY;
if (!sbkey) {
throw new Error(
"SENDBILL_SBKEY 환경변수가 설정되지 않았습니다.\n" +
"사용법: SENDBILL_SBKEY=your-key npx sendbill-mcp"
);
}
return new SendbillClient(sbkey);
}

148
src/error-codes.ts Normal file
View File

@ -0,0 +1,148 @@
// 센드빌 API 에러코드 참조표
// 출처: https://www.sendbill.co.kr/SendbillAPI_Error_Code.xls
export const ERROR_CODES: Record<string, string> = {
// SBKEY 관련
"-10001": "SBKEY Header 데이터가 없습니다.",
"-10002": "SBKEY의 데이터가 옳바르지 않습니다.",
"-10003": "폐기 처리된 SBKEY 입니다.",
"-10004": "SBKEY의 사용 기간이 만료되었습니다.",
// 인증서 관련
"-11001": "실패",
"-11002": "등록된 인증서가 없습니다.",
"-11003": "기타오류, 고객센터로 문의 바랍니다.",
"-11004": "파일 업로드 중 오류가 발생하였습니다.",
"-11101": "올바른 파일을 선택해 주세요.",
"-11102": "[venderno] 필수 항목입니다.",
"-11103": "[venderno] 최대 길이는 10 bytes 입니다.",
"-11104": "[venderno] 숫자만 입력 가능합니다.",
"-11105": "[venderno] 올바른 형태의 사업자번호가 아닙니다.",
"-11106": "[password] 필수 항목입니다.",
"-11107": "[certderfile] 필수 항목입니다.",
"-11108": "[certkeyfile] 필수 항목입니다.",
"-11401": "등록된 인증서의 발급 정보와 사업자 번호가 일치하지 않습니다.",
"-11402": "인증서의 발급 정보와 인증서 비밀번호가 일치하지 않습니다.",
"-11403": "인증서의 사용기간이 만료되었습니다.",
"-11509": "등록된 인증서의 데이터가 옳바르지 않습니다. 인증서 신규 등록 후 다시 시도해주세요.",
"-11701": "URL 유효 시간이 만료되었습니다.",
"-11702": "token 오류, token 재발행 후 재시도해주세요.",
"-11703": "올바르지 않은 토큰입니다.",
// 계산서 발행 관련
"-12001": "실패",
"-12002": "조회된 데이터가 없습니다.",
"-12003": "기타오류, 고객센터로 문의 바랍니다.",
"-66666": "/api/agent/document/registDirect 긴급점검중 입니다.",
"-12501": "충전된 요금이 부족합니다.",
"-12502": "우대권 잔여 건 수 및 충전된 요금이 부족합니다.",
"-12503": "[대납] 충전된 요금이 부족합니다.",
"-12504": "[대납] 우대권 잔여 건 수 및 충전된 요금이 부족합니다.",
"-12631": "[A] 공급자 사업자번호에 해당하는 회원 ID가 없습니다.",
"-12633": "[A] 공급받는자 사업자번호에 해당하는 회원 ID가 없습니다.",
"-12641": "[A] 관리자가 마감하여 전송할 수 없습니다.",
"-12651": "[A] 품목의 합계 금액이 총 금액과 맞지 않습니다.",
"-12654": "[A] 과세기간(반기)이 지난 세금계산서는 발행이 불가능 합니다.",
"-12701": "발행자의 인증서가 등록되어 있지 않습니다.",
"-12703": "발행자의 등록된 인증서 사용기간이 만료되었습니다.",
"-12101": "[Billseq] 데이터가 중복되었습니다.",
"-12102": "[Item(Array)] 필수 항목입니다.",
// 회원 등록 관련
"-13001": "실패",
"-13002": "조회된 데이터가 없습니다.",
"-13003": "기타오류, 고객센터로 문의 바랍니다.",
"-13101": "이미 등록된 아이디 입니다.",
// 계산서 관리 관련
"-14001": "실패",
"-14002": "조회된 데이터가 없습니다.",
"-14003": "기타오류, 고객센터로 문의 바랍니다.",
"-14101": "[Orderseq] 데이터 중복 입니다.",
"-14102": "[Billseq] 필수 항목 입니다.",
"-14104": "[cmd_div] 필수 항목 입니다.",
"-14110": "[cmd_div] 올바른 코드 값을 입력 해주세요. [ 6, E ]",
// 계산서 상태조회 관련
"-15001": "실패",
"-15002": "조회된 데이터가 없습니다.",
"-15101": "[Billseq] 필수 항목 입니다.",
// 홈택스 연동 관련
"-18001": "실패",
"-18002": "조회된 데이터가 없습니다.",
"-18004": "홈택스에서 조회된 데이터가 없습니다.",
"-18101": "요청 사업자번호의 인증서가 등록되어 있지 않습니다.",
"-18403": "센드빌에 등록된 홈택스 계정 또는 인증서 정보가 없습니다.",
"-18401": "홈택스연동 사용 사업자가 아닙니다. 고객센터에 문의 바랍니다.",
};
export function getErrorMessage(code: number | string): string {
const key = String(Math.round(Number(code)));
return ERROR_CODES[key] ?? `알 수 없는 오류 코드: ${code}`;
}
// 코드표
export const CODE_TABLES = {
billtype: {
"10": "세금계산서",
"11": "비회원 세금계산서",
"20": "(면세)계산서",
"21": "비회원(면세)계산서",
"30": "거래명세서",
"31": "비회원 거래명세서",
"40": "위수탁 세금계산서",
"41": "비회원 위수탁 세금계산서",
"50": "위수탁 (면세)계산서",
"51": "비회원 위수탁 (면세)계산서",
"61": "개인 매출 세금계산서",
"62": "개인 매출 계산서",
"Y1": "외부 매출 세금계산서",
"Z1": "외부 매입 세금계산서",
},
cmd_div: {
"6": "삭제요청",
"E": "EMAIL 전송",
},
taxrate: {
"0": "과세율",
"1": "영세율",
"2": "면세율",
"3": "매입세액불공제",
"4": "의제매입",
},
report_stat: {
"N": "미신고",
"A": "접수대기",
"B": "접수완료",
"R": "신고상태",
"F": "신고실패",
},
report_amend_cd: {
"1": "기재사항 착오/정정",
"2": "공급가액 변동",
"3": "환입",
"4": "계약의 해제",
"5": "내국신용장 사후 개설",
"6": "착오에 의한 이중발행",
},
billstat: {
"5": "미전송",
"0": "미개봉",
"3": "개봉",
"1": "승인",
"2": "반려",
"4": "승인취소",
"9": "종이문서저장",
"6": "삭제",
},
gubun: {
"1": "영수",
"2": "청구",
},
etc01: {
"S": "SENDID: 센드빌 ID / RECVID: 센드빌 ID",
"I": "SENDID: 센드빌 ID / RECVID: 거래처 ID",
"X": "SENDID: 거래처 ID / RECVID: 거래처 ID",
},
};

772
src/index.ts Normal file
View File

@ -0,0 +1,772 @@
#!/usr/bin/env node
/**
* (Sendbill) MCP
* API Model Context Protocol
*
* Base URL: https://api.sendbill.co.kr
* 인증: SBKEY ( SENDBILL_SBKEY)
*
* :
* - (, , , )
* - () (//)
* -
* -
* - (/ )
* -
* -
*/
import { Server } from "@modelcontextprotocol/sdk/server/index.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import {
CallToolRequestSchema,
ListToolsRequestSchema,
} from "@modelcontextprotocol/sdk/types.js";
import { createClient } from "./client.js";
import { getErrorMessage, CODE_TABLES } from "./error-codes.js";
const server = new Server(
{ name: "sendbill-mcp", version: "1.0.0" },
{ capabilities: { tools: {} } }
);
// ─────────────────────────────────────────────
// 툴 목록 정의
// ─────────────────────────────────────────────
server.setRequestHandler(ListToolsRequestSchema, async () => ({
tools: [
// ── 인증서 관리 ──────────────────────────
{
name: "sendbill_get_token",
description:
"인증서 등록에 사용할 휘발성 토큰을 생성합니다. (유효시간: 5분)\n" +
"POST /api/agent/certificate/gettoken",
inputSchema: {
type: "object",
properties: {
venderno: {
type: "string",
description: "센드빌 회원사 사업자번호 (하이픈 제외 10자리)",
},
},
required: ["venderno"],
},
},
{
name: "sendbill_upload_cert",
description:
"계산서 발행을 위해 공인인증서를 센드빌에 등록합니다.\n" +
"POST /api/agent/certificate/uploadcert\n" +
"certderfile, certkeyfile은 각각 signCert.der, signPri.key 파일을 Base64 인코딩한 값입니다.",
inputSchema: {
type: "object",
properties: {
jwt: { type: "string", description: "sendbill_get_token으로 발급받은 토큰" },
password: { type: "string", description: "인증서 비밀번호" },
certderfile: { type: "string", description: "signCert.der 파일 Base64 인코딩값" },
certkeyfile: { type: "string", description: "signPri.key 파일 Base64 인코딩값" },
},
required: ["jwt", "password", "certderfile", "certkeyfile"],
},
},
{
name: "sendbill_inquire_cert",
description:
"센드빌에 등록된 인증서의 만료일자를 조회합니다.\n" +
"POST /api/agent/certificate/inquirecert",
inputSchema: {
type: "object",
properties: {
jwt: { type: "string", description: "sendbill_get_token으로 발급받은 토큰" },
},
required: ["jwt"],
},
},
{
name: "sendbill_delete_cert",
description:
"센드빌에 등록된 인증서를 삭제합니다.\n" +
"POST /api/agent/certificate/deletecert",
inputSchema: {
type: "object",
properties: {
jwt: { type: "string", description: "sendbill_get_token으로 발급받은 토큰" },
password: { type: "string", description: "등록된 인증서 비밀번호" },
},
required: ["jwt", "password"],
},
},
{
name: "sendbill_get_cert_upload_view",
description:
"타사가 직접 인증서를 등록할 수 있는 폼 URL을 생성합니다. (유효시간: 5분)\n" +
"POST /api/agent/certificate/getuploadview",
inputSchema: {
type: "object",
properties: {
venderno: { type: "string", description: "사업자번호 (하이픈 제외 10자리)" },
},
required: ["venderno"],
},
},
// ── 계산서 발행 ──────────────────────────
{
name: "sendbill_register_invoice",
description:
"전자세금계산서(또는 계산서)를 단건 발행합니다.\n" +
"POST /api/agent/document/registDirect\n" +
"billtype 코드: 10=세금계산서, 20=(면세)계산서, 30=거래명세서, 40=위수탁세금계산서 등\n" +
"gubun: 1=영수, 2=청구\n" +
"taxrate: 0=과세, 1=영세, 2=면세",
inputSchema: {
type: "object",
properties: {
billseq: { type: "string", description: "세금계산서 고유번호 (최대 25자, 중복불가)" },
svenderno: { type: "string", description: "공급자 사업자번호 (하이픈 제외 10자리)" },
rvenderno: { type: "string", description: "공급받는자 사업자번호 또는 주민등록번호 (13자리 이내)" },
dt: { type: "string", description: "작성일 (YYYY-MM-DD, 미래일자 불가)" },
supmoney: { type: "string", description: "공급가액 총액 (숫자, 최대 13자리)" },
taxmoney: { type: "string", description: "세액 총액 (숫자, 최대 13자리)" },
billtype: { type: "string", description: "계산서 종류 코드 (10/11/20/21/30/31/40/41/50/51/61/62/Y1/Z1)" },
gubun: { type: "string", description: "청구구분 (1=영수, 2=청구)" },
taxrate: { type: "string", description: "과세구분 (0=과세, 1=영세, 2=면세, 3=매입세액불공제, 4=의제매입)" },
scompany: { type: "string", description: "공급자 업체명" },
sceoname: { type: "string", description: "공급자 대표자명" },
suptae: { type: "string", description: "공급자 업태" },
supjong: { type: "string", description: "공급자 업종" },
saddress: { type: "string", description: "공급자 주소" },
suser: { type: "string", description: "공급자 담당자명" },
stelno: { type: "string", description: "공급자 전화번호 (000-0000-0000)" },
semail: { type: "string", description: "공급자 이메일" },
rcompany: { type: "string", description: "공급받는자 업체명" },
rceoname: { type: "string", description: "공급받는자 대표자명" },
ruptae: { type: "string", description: "공급받는자 업태" },
rupjong: { type: "string", description: "공급받는자 업종" },
raddress: { type: "string", description: "공급받는자 주소" },
ruser: { type: "string", description: "공급받는자 담당자명" },
rtelno: { type: "string", description: "공급받는자 전화번호" },
remail: { type: "string", description: "공급받는자 이메일" },
report_except_yn: { type: "string", description: "신고 제외 여부 (Y/N, 기본값 N)" },
reverseyn: { type: "string", description: "역발행 여부 (Y/N, 기본값 N)" },
transyn: { type: "string", description: "전송 여부 (Y/N, 기본값 N)" },
test_yn: { type: "string", description: "테스트 여부 (Y/N, 기본값 N)" },
bigo: { type: "string", description: "비고 (최대 150자)" },
cash: { type: "string", description: "현금 지급액" },
checks: { type: "string", description: "수표 지급액" },
note: { type: "string", description: "어음 지급액" },
credit: { type: "string", description: "외상미수금" },
sendid: { type: "string", description: "공급자 센드빌 ID (etc01 코드에 따라 결정)" },
recvid: { type: "string", description: "공급받는자 센드빌 ID (etc01 코드에 따라 결정)" },
etc01: { type: "string", description: "ID 구분 (X=거래처ID, I=공급자ID+거래처ID, S=양쪽 센드빌ID, 기본값 X)" },
etc03: { type: "string", description: "비회원 자동가입 여부 (Y/N, 기본값 N)" },
file_url: { type: "string", description: "첨부파일 URL (최대 250자)" },
upbillseq: { type: "string", description: "수정세금계산서의 원본 고유번호" },
report_amend_cd: { type: "string", description: "수정사유 코드 (1=착오정정, 2=공급가액변동, 3=환입, 4=계약해제, 5=내국신용장, 6=이중발행)" },
item: {
type: "array",
description: "품목 목록 (필수)",
items: {
type: "object",
properties: {
tax: { type: "string", description: "품목별 세액" },
sup: { type: "string", description: "품목별 공급가액" },
danga: { type: "string", description: "단가" },
vlm: { type: "string", description: "수량" },
unit: { type: "string", description: "규격" },
obj: { type: "string", description: "품목명" },
dt: { type: "string", description: "품목별 거래일자 (YYYY-MM-DD)" },
remark: { type: "string", description: "비고" },
},
required: ["tax", "sup", "dt"],
},
},
broker: {
type: "object",
description: "수탁자 정보 (billtype 40/41/50/51인 경우만 사용)",
properties: {
venderno: { type: "string" },
company: { type: "string" },
ceoname: { type: "string" },
uptae: { type: "string" },
upjong: { type: "string" },
address: { type: "string" },
email: { type: "string" },
nid: { type: "string", description: "연동ID" },
},
},
},
required: [
"billseq", "svenderno", "rvenderno", "dt", "supmoney", "taxmoney",
"billtype", "gubun", "scompany", "sceoname", "rcompany", "rceoname",
"report_except_yn", "reverseyn", "item"
],
},
},
{
name: "sendbill_register_invoice_multi",
description:
"수정세금계산서를 최대 2건 동시 발행합니다. (묶음 발행)\n" +
"POST /api/agent/document/registMultiDirect\n" +
"주로 기재사항 착오/정정, 내국신용장 사후 개설 시 원본+수정본을 한 번에 발행할 때 사용.\n" +
"1건이라도 오류 시 전체 취소됩니다.",
inputSchema: {
type: "object",
properties: {
documents: {
type: "array",
description: "발행할 계산서 목록 (최대 2건). 각 항목은 sendbill_register_invoice와 동일한 형식.",
items: { type: "object" },
maxItems: 2,
},
},
required: ["documents"],
},
},
{
name: "sendbill_register_invoice_bulk",
description:
"전자세금계산서를 최대 100건 대량 발행합니다.\n" +
"POST /api/agent/document/registBulkDirect\n" +
"일부 실패 시에도 성공건은 발행됩니다. 응답에 성공/실패 건수와 오류 목록이 포함됩니다.",
inputSchema: {
type: "object",
properties: {
documents: {
type: "array",
description: "발행할 계산서 목록 (최대 100건). 각 항목은 sendbill_register_invoice와 동일한 형식.",
items: { type: "object" },
maxItems: 100,
},
},
required: ["documents"],
},
},
// ── 계산서 조회/관리 ─────────────────────
{
name: "sendbill_get_invoice_status",
description:
"세금계산서의 상태를 단건 조회합니다.\n" +
"POST /api/agent/status/detail\n" +
"billstat 코드: 0=미개봉, 1=승인, 2=반려, 3=개봉, 4=승인취소, 5=미전송, 6=삭제, 9=종이문서저장\n" +
"transyn: Y=전송성공, N=전송대기, E=에러",
inputSchema: {
type: "object",
properties: {
billseq: { type: "string", description: "세금계산서 고유번호" },
},
required: ["billseq"],
},
},
{
name: "sendbill_get_invoice_status_list",
description:
"세금계산서 상태를 최대 1000건 다중 조회합니다.\n" +
"POST /api/agent/status/list",
inputSchema: {
type: "object",
properties: {
billseqs: {
type: "array",
description: "조회할 세금계산서 고유번호 목록 (최대 1000건)",
items: { type: "string" },
maxItems: 1000,
},
},
required: ["billseqs"],
},
},
{
name: "sendbill_manage_invoice",
description:
"세금계산서에 대한 관리 요청을 처리합니다. (삭제 또는 이메일 재전송)\n" +
"POST /api/agent/manager/registDirect\n" +
"cmd_div: 6=삭제요청, E=EMAIL 전송\n" +
"EMAIL 전송 시 remark에 전송할 이메일 주소를 입력하세요.",
inputSchema: {
type: "object",
properties: {
billseq: { type: "string", description: "세금계산서 고유번호" },
cmd_div: { type: "string", description: "요청코드 (6=삭제, E=이메일전송)" },
remark: { type: "string", description: "이메일 전송 시 수신자 이메일 주소 (최대 500자)" },
etc1: { type: "string", description: "기타1 (최대 200자)" },
etc2: { type: "string", description: "기타2 (최대 200자)" },
etc3: { type: "string", description: "기타3 (최대 200자)" },
etc4: { type: "string", description: "기타4 (최대 200자)" },
etc5: { type: "string", description: "기타5 (최대 200자)" },
},
required: ["billseq", "cmd_div"],
},
},
{
name: "sendbill_get_invoice_error_log",
description:
"세금계산서 발행 시 발생한 에러 로그를 조회합니다.\n" +
"POST /api/agent/log/bill",
inputSchema: {
type: "object",
properties: {
billseq: { type: "string", description: "세금계산서 고유번호" },
},
required: ["billseq"],
},
},
{
name: "sendbill_get_manage_error_log",
description:
"계산서 관리 요청(삭제/이메일 전송 등) 시 발생한 에러 로그를 조회합니다.\n" +
"POST /api/agent/log/manager",
inputSchema: {
type: "object",
properties: {
billseq: { type: "string", description: "세금계산서 고유번호" },
},
required: ["billseq"],
},
},
// ── 회원 관리 ────────────────────────────
{
name: "sendbill_register_member",
description:
"센드빌에 거래처(비회원)를 임시 회원으로 등록합니다.\n" +
"POST /api/agent/member/registDirect\n" +
"비회원 세금계산서를 사용하지 않는 경우, 반드시 거래처가 센드빌에 등록되어 있어야 합니다.",
inputSchema: {
type: "object",
properties: {
entcode: { type: "string", description: "제휴업체 코드 (4자리)" },
id: { type: "string", description: "회원 아이디 (최대 20자)" },
company: { type: "string", description: "업체명 (최대 200자)" },
venderno: { type: "string", description: "사업자 등록번호 (하이픈 제외 10자리)" },
ceoname: { type: "string", description: "대표자명 (최대 100자)" },
uptae: { type: "string", description: "업태 (최대 100자)" },
upjong: { type: "string", description: "업종 (최대 100자)" },
address: { type: "string", description: "주소 (최대 150자)" },
mgmt_yn: { type: "string", description: "관리자 구분 (Y/N)" },
email_yn: { type: "string", description: "이메일 수신 여부 (Y/N)" },
sms_yn: { type: "string", description: "SMS 수신 여부 (Y/N)" },
zipcode: { type: "string", description: "우편번호 (6자리)" },
address2: { type: "string", description: "상세주소" },
email: { type: "string", description: "이메일" },
users: { type: "string", description: "담당자명" },
fax: { type: "string", description: "팩스번호" },
tel: { type: "string", description: "전화번호" },
division: { type: "string", description: "부서명" },
resno: { type: "string", description: "주민등록번호 (13자리)" },
smsno: { type: "string", description: "핸드폰번호" },
sign_yn: { type: "string", description: "담당자 동의 여부 (Y/N)" },
},
required: ["entcode", "id", "company", "venderno", "ceoname", "uptae", "upjong", "address", "mgmt_yn", "email_yn", "sms_yn"],
},
},
{
name: "sendbill_get_member_error_log",
description:
"회원 등록 요청 시 발생한 에러 로그를 조회합니다.\n" +
"POST /api/agent/log/member",
inputSchema: {
type: "object",
properties: {
entcode: { type: "string", description: "제휴업체 코드 (최대 6자리)" },
userid: { type: "string", description: "회원 아이디(임시가입) (최대 30자)" },
},
required: ["entcode", "userid"],
},
},
// ── 홈택스 연동 ──────────────────────────
{
name: "sendbill_hometax_query_tax_invoice",
description:
"홈택스에서 세금계산서 매출/매입 내역을 조회합니다.\n" +
"direction: sales=매출, purchase=매입\n" +
"period: daily=일별(basedate), monthly=월별(basemonth)\n" +
"taxtype: '01'=과세+영세, '03'=면세\n" +
"datetype: '01'=작성일자, '02'=발행일자\n" +
"orderdirection: '01'=내림차순, '02'=오름차순\n" +
"⚠️ 홈택스 접근제어로 30건당 5초 딜레이가 있습니다.",
inputSchema: {
type: "object",
properties: {
direction: { type: "string", enum: ["sales", "purchase"], description: "조회 방향 (sales=매출, purchase=매입)" },
period: { type: "string", enum: ["daily", "monthly"], description: "조회 주기 (daily=일별, monthly=월별)" },
venderno: { type: "string", description: "사업자번호 (10자리)" },
taxtype: { type: "string", description: "과세구분 ('01'=과세+영세, '03'=면세)" },
datetype: { type: "string", description: "기준일자 유형 ('01'=작성일자, '02'=발행일자)" },
basedate: { type: "string", description: "기준일자 (YYYYMMDD, period=daily일 때)" },
basemonth: { type: "string", description: "기준월 (YYYYMM, period=monthly일 때)" },
orderdirection: { type: "string", description: "정렬방향 ('01'=내림차순, '02'=오름차순)" },
},
required: ["direction", "period", "venderno", "taxtype", "datetype", "orderdirection"],
},
},
{
name: "sendbill_hometax_query_cash_receipt",
description:
"홈택스에서 현금영수증 매출/매입 내역을 조회합니다.\n" +
"direction: sales=매출, purchase=매입\n" +
"period: daily=일별(basedate), monthly=월별(basemonth)\n" +
"orderdirection: '01'=내림차순, '02'=오름차순\n" +
"⚠️ 홈택스 접근제어로 30건당 5초 딜레이가 있습니다.",
inputSchema: {
type: "object",
properties: {
direction: { type: "string", enum: ["sales", "purchase"], description: "조회 방향 (sales=매출, purchase=매입)" },
period: { type: "string", enum: ["daily", "monthly"], description: "조회 주기 (daily=일별, monthly=월별)" },
venderno: { type: "string", description: "사업자번호 (10자리)" },
basedate: { type: "string", description: "기준일자 (YYYYMMDD, period=daily일 때)" },
basemonth: { type: "string", description: "기준월 (YYYYMM, period=monthly일 때)" },
orderdirection: { type: "string", description: "정렬방향 ('01'=내림차순, '02'=오름차순)" },
},
required: ["direction", "period", "venderno", "orderdirection"],
},
},
{
name: "sendbill_hometax_register_account",
description:
"홈택스 연동을 위한 홈택스 계정을 센드빌에 등록합니다.\n" +
"POST /api/agent/hometax/account/regist\n" +
"세금계산서용과 현금영수증용 계정을 별도로 사용합니다.",
inputSchema: {
type: "object",
properties: {
jwt: { type: "string", description: "sendbill_get_token으로 발급받은 토큰" },
billhtid: { type: "string", description: "세금계산서용 홈택스 아이디" },
billhtpw: { type: "string", description: "세금계산서용 홈택스 패스워드" },
cashhtid: { type: "string", description: "현금영수증용 홈택스 아이디" },
cashhtpw: { type: "string", description: "현금영수증용 홈택스 패스워드" },
},
required: ["jwt", "billhtid", "billhtpw", "cashhtid", "cashhtpw"],
},
},
{
name: "sendbill_hometax_delete_account",
description:
"홈택스 연동계정을 센드빌에서 삭제합니다.\n" +
"POST /api/agent/hometax/account/delete\n" +
"계정 존재 유무에 대한 결과는 반환되지 않습니다.",
inputSchema: {
type: "object",
properties: {
jwt: { type: "string", description: "sendbill_get_token으로 발급받은 토큰" },
},
required: ["jwt"],
},
},
{
name: "sendbill_hometax_get_register_view",
description:
"타사가 직접 홈택스 연동계정을 등록할 수 있는 폼 URL을 생성합니다. (유효시간: 10분)\n" +
"POST /api/agent/hometax/account/getRegistView",
inputSchema: {
type: "object",
properties: {
venderno: { type: "string", description: "사업자번호 (하이픈 제외 10자리)" },
},
required: ["venderno"],
},
},
// ── 휴폐업 조회 ──────────────────────────
{
name: "sendbill_company_close_search",
description:
"사업자의 휴업/폐업 상태를 최대 100건 조회합니다.\n" +
"POST /api/agent/companyclosesearch\n" +
"국세청 데이터를 실시간으로 조회합니다.",
inputSchema: {
type: "object",
properties: {
venderno: {
type: "array",
description: "조회할 사업자번호 목록 (하이픈 제외 10자리, 최대 100건)",
items: { type: "string" },
maxItems: 100,
},
},
required: ["venderno"],
},
},
// ── 추가메일 전송 ─────────────────────────
{
name: "sendbill_send_additional_mail",
description:
"이미 발행한 계산서의 이메일을 추가 주소로 재전송합니다.\n" +
"POST /api/agent/additionalMail/sendAdditionalMail",
inputSchema: {
type: "object",
properties: {
billseq: { type: "string", description: "발행 문서 고유번호 (최대 19자)" },
sendType: { type: "string", description: "전송타입 코드" },
newMailAddress: { type: "string", description: "신규 전송 메일 주소 (최대 30자)" },
},
required: ["billseq", "sendType", "newMailAddress"],
},
},
{
name: "sendbill_get_mail_send_list",
description:
"API로 발행한 계산서의 이메일 전송 내역과 수신 여부를 조회합니다.\n" +
"POST /api/agent/additionalMail/sendMailList",
inputSchema: {
type: "object",
properties: {
billseq: { type: "string", description: "API 발행 문서 고유번호 (최대 19자)" },
},
required: ["billseq"],
},
},
// ── 참조 도구 ─────────────────────────────
{
name: "sendbill_get_error_message",
description:
"센드빌 API 에러코드에 해당하는 한국어 메시지를 조회합니다.\n" +
"API 응답에서 음수 Result 코드를 받았을 때 사용하세요.",
inputSchema: {
type: "object",
properties: {
code: { type: "number", description: "에러코드 (음수, 예: -10001)" },
},
required: ["code"],
},
},
{
name: "sendbill_get_code_tables",
description:
"센드빌 API에서 사용하는 코드표 전체를 반환합니다.\n" +
"billtype, cmd_div, taxrate, billstat, gubun, etc01, report_stat, report_amend_cd 코드를 확인할 수 있습니다.",
inputSchema: {
type: "object",
properties: {
table: {
type: "string",
enum: ["billtype", "cmd_div", "taxrate", "billstat", "gubun", "etc01", "report_stat", "report_amend_cd", "all"],
description: "조회할 코드표 이름 (all=전체 조회)",
},
},
required: ["table"],
},
},
],
}));
// ─────────────────────────────────────────────
// 툴 실행 핸들러
// ─────────────────────────────────────────────
server.setRequestHandler(CallToolRequestSchema, async (request) => {
const { name, arguments: args } = request.params;
// 참조 도구는 API 호출 없음
if (name === "sendbill_get_error_message") {
const code = (args as { code: number }).code;
const message = getErrorMessage(code);
return {
content: [{ type: "text", text: `에러코드 ${code}: ${message}` }],
};
}
if (name === "sendbill_get_code_tables") {
const table = (args as { table: string }).table;
if (table === "all") {
return {
content: [{ type: "text", text: JSON.stringify(CODE_TABLES, null, 2) }],
};
}
const tableData = CODE_TABLES[table as keyof typeof CODE_TABLES];
if (!tableData) {
return {
content: [{ type: "text", text: `코드표 '${table}'를 찾을 수 없습니다.` }],
isError: true,
};
}
return {
content: [{ type: "text", text: JSON.stringify({ [table]: tableData }, null, 2) }],
};
}
// API 호출 도구
const client = createClient();
type EndpointMap = Record<string, [string, (a: Record<string, unknown>) => Record<string, unknown>]>;
const endpoints: EndpointMap = {
// 인증서 관리
sendbill_get_token: ["/api/agent/certificate/gettoken", (a) => ({ venderno: a.venderno })],
sendbill_upload_cert: ["/api/agent/certificate/uploadcert", (a) => ({
jwt: a.jwt, password: a.password, certderfile: a.certderfile, certkeyfile: a.certkeyfile
})],
sendbill_inquire_cert: ["/api/agent/certificate/inquirecert", (a) => ({ jwt: a.jwt })],
sendbill_delete_cert: ["/api/agent/certificate/deletecert", (a) => ({ jwt: a.jwt, password: a.password })],
sendbill_get_cert_upload_view: ["/api/agent/certificate/getuploadview", (a) => ({ venderno: a.venderno })],
// 계산서 발행
sendbill_register_invoice: ["/api/agent/document/registDirect", (a) => a],
sendbill_register_invoice_multi: ["/api/agent/document/registMultiDirect", (a) => a],
sendbill_register_invoice_bulk: ["/api/agent/document/registBulkDirect", (a) => a],
// 계산서 조회/관리
sendbill_get_invoice_status: ["/api/agent/status/detail", (a) => ({ billseq: a.billseq })],
sendbill_get_invoice_status_list: ["/api/agent/status/list", (a) => ({ billseqs: a.billseqs })],
sendbill_manage_invoice: ["/api/agent/manager/registDirect", (a) => a],
sendbill_get_invoice_error_log: ["/api/agent/log/bill", (a) => ({ billseq: a.billseq })],
sendbill_get_manage_error_log: ["/api/agent/log/manager", (a) => ({ billseq: a.billseq })],
// 회원 관리
sendbill_register_member: ["/api/agent/member/registDirect", (a) => a],
sendbill_get_member_error_log: ["/api/agent/log/member", (a) => ({ entcode: a.entcode, userid: a.userid })],
// 홈택스 연동
sendbill_hometax_register_account: ["/api/agent/hometax/account/regist", (a) => a],
sendbill_hometax_delete_account: ["/api/agent/hometax/account/delete", (a) => ({ jwt: a.jwt })],
sendbill_hometax_get_register_view: ["/api/agent/hometax/account/getRegistView", (a) => ({ venderno: a.venderno })],
// 휴폐업 조회
sendbill_company_close_search: ["/api/agent/companyclosesearch", (a) => ({ venderno: a.venderno })],
// 추가메일
sendbill_send_additional_mail: ["/api/agent/additionalMail/sendAdditionalMail", (a) => a],
sendbill_get_mail_send_list: ["/api/agent/additionalMail/sendMailList", (a) => ({ billseq: a.billseq })],
};
// 홈택스 조회는 방향+주기에 따라 엔드포인트가 다름
if (name === "sendbill_hometax_query_tax_invoice") {
const a = args as Record<string, unknown>;
const direction = a.direction as string;
const period = a.period as string;
const endpointMap: Record<string, string> = {
"sales-daily": "/api/agent/hometax/listDailyTaxInvoiceSales",
"sales-monthly": "/api/agent/hometax/listMonthlyTaxInvoiceSales",
"purchase-daily": "/api/agent/hometax/listDailyTaxInvoicePurchase",
"purchase-monthly": "/api/agent/hometax/listMonthlyTaxInvoicePurchase",
};
const endpoint = endpointMap[`${direction}-${period}`];
if (!endpoint) {
return { content: [{ type: "text", text: "잘못된 direction 또는 period 값입니다." }], isError: true };
}
const body: Record<string, unknown> = {
venderno: a.venderno,
taxtype: a.taxtype,
datetype: a.datetype,
orderdirection: a.orderdirection,
};
if (period === "daily") body.basedate = a.basedate;
else body.basemonth = a.basemonth;
try {
const result = await client.post(endpoint, body);
const text = formatResult(result);
return { content: [{ type: "text", text }] };
} catch (err: unknown) {
const message = err instanceof Error ? err.message : String(err);
return { content: [{ type: "text", text: `API 오류: ${message}` }], isError: true };
}
}
if (name === "sendbill_hometax_query_cash_receipt") {
const a = args as Record<string, unknown>;
const direction = a.direction as string;
const period = a.period as string;
const endpointMap: Record<string, string> = {
"sales-daily": "/api/agent/hometax/listDailyCashBillSales",
"sales-monthly": "/api/agent/hometax/listMonthlyCashBillSales",
"purchase-daily": "/api/agent/hometax/listDailyCashBillPurchase",
"purchase-monthly": "/api/agent/hometax/listMonthlyCashBillPurchase",
};
const endpoint = endpointMap[`${direction}-${period}`];
if (!endpoint) {
return { content: [{ type: "text", text: "잘못된 direction 또는 period 값입니다." }], isError: true };
}
const body: Record<string, unknown> = {
venderno: a.venderno,
orderdirection: a.orderdirection,
};
if (period === "daily") body.basedate = a.basedate;
else body.basemonth = a.basemonth;
try {
const result = await client.post(endpoint, body);
const text = formatResult(result);
return { content: [{ type: "text", text }] };
} catch (err: unknown) {
const message = err instanceof Error ? err.message : String(err);
return { content: [{ type: "text", text: `API 오류: ${message}` }], isError: true };
}
}
// 일반 엔드포인트 처리
const entry = endpoints[name];
if (!entry) {
return {
content: [{ type: "text", text: `알 수 없는 툴: ${name}` }],
isError: true,
};
}
const [endpoint, buildBody] = entry;
const body = buildBody(args as Record<string, unknown>);
try {
const result = await client.post(endpoint, body);
const text = formatResult(result);
return { content: [{ type: "text", text }] };
} catch (err: unknown) {
const message = err instanceof Error ? err.message : String(err);
return { content: [{ type: "text", text: `API 오류: ${message}` }], isError: true };
}
});
function formatResult(result: unknown): string {
if (typeof result !== "object" || result === null) {
return String(result);
}
const obj = result as Record<string, unknown>;
const lines: string[] = [];
// Result 코드 해석
if ("Result" in obj || "result" in obj) {
const code = (obj.Result ?? obj.result) as number;
const msg = (obj.Message ?? obj.message) as string | undefined;
if (code < 0) {
lines.push(`❌ 실패 (Result: ${code})`);
lines.push(` 오류: ${msg ?? getErrorMessage(code)}`);
} else {
lines.push(`✅ 성공 (Result: ${code})`);
if (msg) lines.push(` 메시지: ${msg}`);
}
}
// 나머지 데이터
const skip = new Set(["Result", "result", "Message", "message"]);
const rest = Object.fromEntries(Object.entries(obj).filter(([k]) => !skip.has(k)));
if (Object.keys(rest).length > 0) {
lines.push("\n데이터:");
lines.push(JSON.stringify(rest, null, 2));
}
return lines.join("\n") || JSON.stringify(result, null, 2);
}
// ─────────────────────────────────────────────
// 서버 시작
// ─────────────────────────────────────────────
async function main() {
const transport = new StdioServerTransport();
await server.connect(transport);
// stderr에만 출력 (stdout은 MCP 프로토콜 전용)
process.stderr.write("센드빌 MCP 서버 시작됨\n");
}
main().catch((err) => {
process.stderr.write(`치명적 오류: ${err}\n`);
process.exit(1);
});

17
tsconfig.json Normal file
View File

@ -0,0 +1,17 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "ES2022",
"moduleResolution": "node",
"outDir": "./dist",
"rootDir": "./src",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"declaration": true,
"declarationMap": true,
"sourceMap": true
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist"]
}