# Directory Structure
```
├── .editorconfig
├── .gitattributes
├── .gitignore
├── .nvmrc
├── biome.json
├── images
│ └── example.png
├── LICENSE
├── package-lock.json
├── package.json
├── pnpm-lock.yaml
├── README.md
├── src
│ ├── index.ts
│ ├── naver_speller.test.ts
│ └── naver_speller.ts
└── tsconfig.json
```
# Files
--------------------------------------------------------------------------------
/.nvmrc:
--------------------------------------------------------------------------------
```
1 | v18.19.0
2 |
```
--------------------------------------------------------------------------------
/.gitattributes:
--------------------------------------------------------------------------------
```
1 | pnpm-lock.yaml linguist-generated=true
2 | package-lock.json linguist-generated=true
3 |
```
--------------------------------------------------------------------------------
/.editorconfig:
--------------------------------------------------------------------------------
```
1 | # EditorConfig is awesome: https://EditorConfig.org
2 |
3 | # top-most EditorConfig file
4 | root = true
5 |
6 | # Unix-style newlines with a newline ending every file
7 | [*]
8 | end_of_line = lf
9 | insert_final_newline = true
10 | charset = utf-8
11 | trim_trailing_whitespace = true
12 |
13 | # TypeScript/JavaScript files
14 | [*.{ts,tsx,js,jsx,json}]
15 | indent_style = space
16 | indent_size = 2
17 | quote_type = double
18 |
19 | # Markdown files
20 | [*.md]
21 | trim_trailing_whitespace = false
22 |
```
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
```
1 | # .gitignore file created for free with Tower (https://www.git-tower.com/)
2 | # For suggestions or improvements please contact us using [email protected]
3 | # Generated on 2025-04-06
4 | # Includes: typescript, javascript, node, vscode, macos
5 | .vscode/
6 |
7 | ### Universal ###
8 | # Common files that should be ignored in all projects
9 |
10 | # Logs
11 | *.log
12 | # Temporary files
13 | *.tmp
14 | *~
15 |
16 | *.bak
17 | # Environment files (containing secrets, API keys, credentials)
18 | .env
19 | *.env
20 | .env.*
21 |
22 | # Local configuration that shouldn't be shared
23 | *.local
24 |
25 | ### Typescript ###
26 | # typescript specific files
27 |
28 | *.tsbuildinfo
29 | node_modules/
30 | dist/
31 |
32 | ### Javascript ###
33 | # javascript specific files
34 |
35 | node_modules/
36 | *.log
37 | npm-debug.log*
38 | yarn-debug.log*
39 | .cache/
40 |
41 | dist/
42 | coverage/
43 |
44 | ### Node ###
45 | # Node.js dependency directory, logs, and environment files
46 |
47 | # Dependencies
48 | node_modules/
49 | jspm_packages/
50 | bower_components/
51 | web_modules/
52 |
53 | # Logs
54 | logs
55 | *.log
56 | *.launch
57 | connect.lock/
58 |
59 | libpeerconnection.log
60 | .history/
61 | npm-debug.log*
62 | yarn-debug.log*
63 | yarn-error.log*
64 |
65 | lerna-debug.log*
66 | .pnpm-debug.log*
67 | # Diagnostic reports
68 | report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json
69 | # Runtime data
70 |
71 | pids
72 | *.pid
73 | *.seed
74 | *.pid.lock
75 | # Environment
76 |
77 | .env.development.local
78 | .env.test.local
79 | .env.production.local
80 | .env.local
81 | # Package management
82 |
83 | .npm
84 | yarn.lock
85 | .yarn-integrity
86 | .yarn/cache
87 |
88 | .yarn/unplugged
89 | .yarn/build-state.yml
90 | .yarn/install-state.gz
91 | .pnp.*
92 | # Coverage & Test
93 |
94 | coverage/
95 | lib-cov
96 | *.lcov
97 | .nyc_output
98 | # Build output
99 |
100 | dist/
101 | build/
102 | .next/
103 | out/
104 | .nuxt
105 |
106 | .output
107 | # Cache & Temporary
108 | .cache/
109 | .temp/
110 | .grunt
111 |
112 | .lock-wscript
113 | .fusebox/
114 | .dynamodb/
115 | .tern-port
116 | .vscode-test
117 |
118 | .node_repl_history
119 | .webpack/
120 | # TypeScript
121 | *.tsbuildinfo
122 | # Optional REPL history
123 |
124 | .node_repl_history
125 | # Additional caches
126 | .eslintcache
127 | .stylelintcache
128 | .parcel-cache
129 |
130 | .rpt2_cache/
131 | .rts2_cache_cjs/
132 | .rts2_cache_es/
133 | .rts2_cache_umd/
134 | # Misc
135 |
136 | *.tgz
137 | .serverless/
138 | .vuepress/dist
139 | .temp
140 | .docusaurus
141 |
142 | .svelte-kit
143 |
144 | ### Vscode ###
145 | # Visual Studio Code editor settings and workspace files
146 |
147 | # Visual Studio Code
148 | .vscode/*
149 | !.vscode/settings.json
150 | !.vscode/tasks.json
151 | !.vscode/launch.json
152 |
153 | !.vscode/extensions.json
154 | !.vscode/*.code-snippets
155 | .history/
156 | *.vsix
157 | .ionide
158 |
159 | .vs/
160 | *.code-workspace
161 | .vscode-test
162 | .vscodeignore
163 | .vscode/chrome
164 |
165 | .vscode-server/
166 | .vscode/sftp.json
167 | .vscode/tags
168 | .devcontainer/
169 |
170 | ### Macos ###
171 | # macOS operating system specific files
172 |
173 | .DS_Store
174 | .AppleDouble
175 | .LSOverride
176 | Icon
177 | ._*
178 |
179 | .DocumentRevisions-V100
180 | .fseventsd
181 | .Spotlight-V100
182 | .TemporaryItems
183 | .Trashes
184 |
185 | .VolumeIcon.icns
186 | .com.apple.timemachine.donotpresent
187 | .AppleDB
188 | .AppleDesktop
189 | Network Trash Folder
190 |
191 | Temporary Items
192 | .apdisk
193 | *.icloud
194 | .AppleDB/
195 | .AppleDesktop/
196 |
197 | *.AppleDouble
198 | *.AppleDB
199 | .Spotlight-V100/
200 | .Trashes/
201 | *.swp
202 |
203 | Network Trash Folder/
204 | .DS_Store?
205 | .Spotlight-V100
206 | .Trashes
207 |
```
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
```markdown
1 | # mcp-korean-spell
2 |
3 | `mcp-korean-spell` is a MCP(Model Context Protocol) server designed for Korean spell checking, providing a reliable tool for writers to integrate spell checking capabilities into their documents and texts.
4 |
5 | 
6 |
7 | ## Tools
8 |
9 | - `fix_korean_spell`: Analyzes and corrects Korean text for spelling and grammar errors
10 |
11 | ## How to add MCP config to your client
12 |
13 | ### Using npm
14 |
15 | To configure this spell checker in your MCP client, add the following to your [`~/.cursor/mcp.json`](cursor://settings/) or `claude_desktop_config.json` (MacOS: `~/Library/Application\ Support/Claude/claude_desktop_config.json`)
16 |
17 | ```javascript
18 | {
19 | "mcpServers": {
20 | "korean-spell-checker": {
21 | "command": "npx",
22 | "args": [
23 | "-y",
24 | "@winterjung/mcp-korean-spell"
25 | ]
26 | }
27 | }
28 | }
29 | ```
30 |
31 | ## Disclaimer
32 |
33 | - This tool is based on the [네이버(NAVER) 맞춤법 검사기](https://m.search.naver.com/search.naver?query=맞춤법+검사기).
34 | - This tool is not officially provided and is not affiliated with the company.
35 | - If company stops, changes or blocks providing the service, this tool may not function correctly.
36 |
37 | ## License
38 |
39 | This project is licensed under the Apache License 2.0. See the [LICENSE](LICENSE) file for details.
40 |
```
--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------
```json
1 | {
2 | "compilerOptions": {
3 | "target": "es2022",
4 | "module": "Node16",
5 | "moduleResolution": "Node16",
6 | "outDir": "./dist",
7 | "rootDir": "./src",
8 | "declaration": true,
9 | "strict": true,
10 | "esModuleInterop": true,
11 | "skipLibCheck": true,
12 | "forceConsistentCasingInFileNames": true
13 | },
14 | "include": ["src/**/*"],
15 | "exclude": ["node_modules", "**/*.test.ts", "dist"]
16 | }
17 |
```
--------------------------------------------------------------------------------
/biome.json:
--------------------------------------------------------------------------------
```json
1 | {
2 | "$schema": "https://biomejs.dev/schemas/1.9.4/schema.json",
3 | "vcs": {
4 | "enabled": false,
5 | "clientKind": "git",
6 | "useIgnoreFile": false
7 | },
8 | "files": {
9 | "ignoreUnknown": false,
10 | "ignore": []
11 | },
12 | "formatter": {
13 | "enabled": true,
14 | "indentStyle": "space",
15 | "indentWidth": 2,
16 | "ignore": ["dist/**/*"]
17 | },
18 | "organizeImports": {
19 | "enabled": true
20 | },
21 | "linter": {
22 | "enabled": true,
23 | "rules": {
24 | "recommended": true
25 | },
26 | "ignore": ["dist/**/*"]
27 | },
28 | "javascript": {
29 | "formatter": {
30 | "quoteStyle": "double",
31 | "semicolons": "always"
32 | }
33 | }
34 | }
35 |
```
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
```json
1 | {
2 | "name": "@winterjung/mcp-korean-spell",
3 | "version": "1.0.1",
4 | "description": "Korean spell checker MCP(Model Context Protocol) server",
5 | "type": "module",
6 | "bin": {
7 | "mcp-korean-spell": "./dist/index.js"
8 | },
9 | "files": ["dist", "README.md", "LICENSE"],
10 | "engines": {
11 | "node": ">=18.0.0",
12 | "pnpm": "v10.7.1"
13 | },
14 | "scripts": {
15 | "test": "ts-node src/naver_speller_test.ts",
16 | "start": "ts-node src/index.ts",
17 | "build": "tsc && chmod +x dist/index.js",
18 | "dev": "ts-node src/index.ts",
19 | "watch": "tsc --watch",
20 | "format": "biome format . --write",
21 | "check": "biome check ."
22 | },
23 | "keywords": ["korean", "spell checker"],
24 | "author": "winterjung",
25 | "license": "Apache-2.0",
26 | "repository": {
27 | "type": "git",
28 | "url": "git+https://github.com/winterjung/mcp-korean-spell.git"
29 | },
30 | "bugs": {
31 | "url": "https://github.com/winterjung/mcp-korean-spell/issues"
32 | },
33 | "homepage": "https://github.com/winterjung/mcp-korean-spell#readme",
34 | "dependencies": {
35 | "@modelcontextprotocol/sdk": "^1.8.0",
36 | "cheerio": "^1.0.0",
37 | "zod": "^3.24.2"
38 | },
39 | "devDependencies": {
40 | "@biomejs/biome": "1.9.4",
41 | "@types/cheerio": "^0.22.35",
42 | "@types/node": "^22.14.0",
43 | "ts-node": "^10.9.2",
44 | "typescript": "^5.8.3"
45 | }
46 | }
47 |
```
--------------------------------------------------------------------------------
/src/naver_speller.ts:
--------------------------------------------------------------------------------
```typescript
1 | import { load as loadCheerio } from "cheerio";
2 |
3 | const SPELLER_PROVIDER_URL =
4 | "https://m.search.naver.com/search.naver?query=%EB%A7%9E%EC%B6%A4%EB%B2%95%EA%B2%80%EC%82%AC%EA%B8%B0";
5 | const PASSPORT_KEY_REGEX = /SpellerProxy\?passportKey=([a-zA-Z0-9]+)/;
6 | const SPELLER_API_URL_BASE =
7 | "https://m.search.naver.com/p/csearch/ocontent/util/SpellerProxy?passportKey=";
8 | const MAX_CHUNK_LENGTH = 300;
9 |
10 | interface NaverSpellerResponse {
11 | message: {
12 | result: {
13 | html: string;
14 | errata_count: number;
15 | origin_html: string;
16 | notag_html: string;
17 | };
18 | error?: string;
19 | };
20 | }
21 |
22 | function simpleHtmlUnescape(text: string): string {
23 | return text
24 | .replace(/</g, "<")
25 | .replace(/>/g, ">")
26 | .replace(/<br>/g, "\n")
27 | .replace(/&/g, "&")
28 | .replace(/"/g, '"')
29 | .replace(/'/g, "'")
30 | .replace(/ /g, " ");
31 | }
32 |
33 | export class NaverSpellChecker {
34 | private spellerApiUrl: string | null = null;
35 |
36 | private async fetchPassportKey(): Promise<string> {
37 | const response = await fetch(SPELLER_PROVIDER_URL);
38 | const html = await response.text();
39 | if (!response.ok) {
40 | throw new Error(
41 | `HTTP error! status: ${response.status}, response: ${html}`,
42 | );
43 | }
44 |
45 | let passportKey: string | undefined;
46 | const $ = loadCheerio(html);
47 | $("script").each((_: any, element: any) => {
48 | const scriptContent = $(element).html();
49 | const match = scriptContent?.match(PASSPORT_KEY_REGEX);
50 | if (match?.[1]) {
51 | passportKey = match[1];
52 | return false;
53 | }
54 | });
55 |
56 | if (!passportKey) {
57 | throw new Error("Passport key not found in the HTML content.");
58 | }
59 | return passportKey;
60 | }
61 |
62 | private async updateSpellerApiUrl(): Promise<void> {
63 | const passportKey = await this.fetchPassportKey();
64 | this.spellerApiUrl = `${SPELLER_API_URL_BASE}${passportKey}&color_blindness=0&q=`;
65 | }
66 |
67 | async correctText(text: string): Promise<string> {
68 | const chunks = this.chunkText(text);
69 | let corrected = "";
70 | for (const chunk of chunks) {
71 | const correctedChunk = await this.correctChunk(chunk);
72 | if (corrected.endsWith(" ")) {
73 | corrected += correctedChunk;
74 | } else {
75 | corrected += ` ${correctedChunk}`;
76 | }
77 | }
78 | return corrected;
79 | }
80 |
81 | async correctChunk(text: string): Promise<string> {
82 | if (!this.spellerApiUrl) {
83 | await this.updateSpellerApiUrl();
84 | if (!this.spellerApiUrl) {
85 | throw new Error(
86 | "Failed to initialize Speller API URL. Cannot proceed.",
87 | );
88 | }
89 | }
90 |
91 | const encodedText = encodeURIComponent(text);
92 | const url = this.spellerApiUrl + encodedText;
93 |
94 | const response = await fetch(url);
95 | const responseText = await response.text();
96 | if (!response.ok) {
97 | throw new Error(
98 | `HTTP error! status: ${response.status}, response: ${responseText}`,
99 | );
100 | }
101 |
102 | const data: NaverSpellerResponse = JSON.parse(responseText);
103 |
104 | if (data.message.error) {
105 | if (data.message.error === "유효한 키가 아닙니다.") {
106 | this.spellerApiUrl = null;
107 | throw new Error("Try again with a new passport key.");
108 | }
109 | throw new Error(`failed to check spelling: ${data.message.error}`);
110 | }
111 |
112 | return simpleHtmlUnescape(data.message.result.notag_html);
113 | }
114 |
115 | private chunkText(text: string): string[] {
116 | if (text.length <= MAX_CHUNK_LENGTH) {
117 | return [text];
118 | }
119 |
120 | const chunks: string[] = [];
121 | let currentChunk = "";
122 | const words = text.split(/(\s+)/);
123 |
124 | for (const word of words) {
125 | if (!word) continue;
126 |
127 | if (word.length > MAX_CHUNK_LENGTH) {
128 | // If a single "word" is too long, split it forcefully
129 | if (currentChunk) {
130 | chunks.push(currentChunk);
131 | currentChunk = "";
132 | }
133 | for (let i = 0; i < word.length; i += MAX_CHUNK_LENGTH) {
134 | chunks.push(word.substring(i, i + MAX_CHUNK_LENGTH));
135 | }
136 | continue;
137 | }
138 | if (currentChunk.length + word.length > MAX_CHUNK_LENGTH) {
139 | chunks.push(currentChunk);
140 | currentChunk = word.trimStart();
141 | continue;
142 | }
143 | currentChunk += word;
144 | }
145 |
146 | if (currentChunk) {
147 | chunks.push(currentChunk);
148 | }
149 |
150 | return chunks.filter((chunk) => chunk.length > 0);
151 | }
152 | }
153 |
```