#
tokens: 3309/50000 9/9 files
lines: off (toggle) GitHub
raw markdown copy
# 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:
--------------------------------------------------------------------------------

```
v18.19.0 

```

--------------------------------------------------------------------------------
/.gitattributes:
--------------------------------------------------------------------------------

```
pnpm-lock.yaml linguist-generated=true
package-lock.json linguist-generated=true

```

--------------------------------------------------------------------------------
/.editorconfig:
--------------------------------------------------------------------------------

```
# EditorConfig is awesome: https://EditorConfig.org

# top-most EditorConfig file
root = true

# Unix-style newlines with a newline ending every file
[*]
end_of_line = lf
insert_final_newline = true
charset = utf-8
trim_trailing_whitespace = true

# TypeScript/JavaScript files
[*.{ts,tsx,js,jsx,json}]
indent_style = space
indent_size = 2
quote_type = double

# Markdown files
[*.md]
trim_trailing_whitespace = false 

```

--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------

```
# .gitignore file created for free with Tower (https://www.git-tower.com/)
# For suggestions or improvements please contact us using [email protected]
# Generated on 2025-04-06
# Includes: typescript, javascript, node, vscode, macos
.vscode/

### Universal ###
# Common files that should be ignored in all projects

# Logs
*.log
# Temporary files
*.tmp
*~

*.bak
# Environment files (containing secrets, API keys, credentials)
.env
*.env
.env.*

# Local configuration that shouldn't be shared
*.local

### Typescript ###
# typescript specific files

*.tsbuildinfo
node_modules/
dist/

### Javascript ###
# javascript specific files

node_modules/
*.log
npm-debug.log*
yarn-debug.log*
.cache/

dist/
coverage/

### Node ###
# Node.js dependency directory, logs, and environment files

# Dependencies
node_modules/
jspm_packages/
bower_components/
web_modules/

# Logs
logs
*.log
*.launch
connect.lock/

libpeerconnection.log
.history/
npm-debug.log*
yarn-debug.log*
yarn-error.log*

lerna-debug.log*
.pnpm-debug.log*
# Diagnostic reports
report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json
# Runtime data

pids
*.pid
*.seed
*.pid.lock
# Environment

.env.development.local
.env.test.local
.env.production.local
.env.local
# Package management

.npm
yarn.lock
.yarn-integrity
.yarn/cache

.yarn/unplugged
.yarn/build-state.yml
.yarn/install-state.gz
.pnp.*
# Coverage & Test

coverage/
lib-cov
*.lcov
.nyc_output
# Build output

dist/
build/
.next/
out/
.nuxt

.output
# Cache & Temporary
.cache/
.temp/
.grunt

.lock-wscript
.fusebox/
.dynamodb/
.tern-port
.vscode-test

.node_repl_history
.webpack/
# TypeScript
*.tsbuildinfo
# Optional REPL history

.node_repl_history
# Additional caches
.eslintcache
.stylelintcache
.parcel-cache

.rpt2_cache/
.rts2_cache_cjs/
.rts2_cache_es/
.rts2_cache_umd/
# Misc

*.tgz
.serverless/
.vuepress/dist
.temp
.docusaurus

.svelte-kit

### Vscode ###
# Visual Studio Code editor settings and workspace files

# Visual Studio Code
.vscode/*
!.vscode/settings.json
!.vscode/tasks.json
!.vscode/launch.json

!.vscode/extensions.json
!.vscode/*.code-snippets
.history/
*.vsix
.ionide

.vs/
*.code-workspace
.vscode-test
.vscodeignore
.vscode/chrome

.vscode-server/
.vscode/sftp.json
.vscode/tags
.devcontainer/

### Macos ###
# macOS operating system specific files

.DS_Store
.AppleDouble
.LSOverride
Icon
._*

.DocumentRevisions-V100
.fseventsd
.Spotlight-V100
.TemporaryItems
.Trashes

.VolumeIcon.icns
.com.apple.timemachine.donotpresent
.AppleDB
.AppleDesktop
Network Trash Folder

Temporary Items
.apdisk
*.icloud
.AppleDB/
.AppleDesktop/

*.AppleDouble
*.AppleDB
.Spotlight-V100/
.Trashes/
*.swp

Network Trash Folder/
.DS_Store?
.Spotlight-V100
.Trashes

```

--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------

```markdown
# mcp-korean-spell

`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.

![mcp-korean-spell result example in cursor chat](images/example.png)

## Tools

- `fix_korean_spell`: Analyzes and corrects Korean text for spelling and grammar errors

## How to add MCP config to your client

### Using npm

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`)

```javascript
{
  "mcpServers": {
    "korean-spell-checker": {
      "command": "npx",
      "args": [
        "-y",
        "@winterjung/mcp-korean-spell"
      ]
    }
  }
}
```

## Disclaimer

- This tool is based on the [네이버(NAVER) 맞춤법 검사기](https://m.search.naver.com/search.naver?query=맞춤법+검사기).
- This tool is not officially provided and is not affiliated with the company.
- If company stops, changes or blocks providing the service, this tool may not function correctly.

## License

This project is licensed under the Apache License 2.0. See the [LICENSE](LICENSE) file for details.

```

--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------

```json
{
  "compilerOptions": {
    "target": "es2022",
    "module": "Node16",
    "moduleResolution": "Node16",
    "outDir": "./dist",
    "rootDir": "./src",
    "declaration": true,
    "strict": true,
    "esModuleInterop": true,
    "skipLibCheck": true,
    "forceConsistentCasingInFileNames": true
  },
  "include": ["src/**/*"],
  "exclude": ["node_modules", "**/*.test.ts", "dist"]
}

```

--------------------------------------------------------------------------------
/biome.json:
--------------------------------------------------------------------------------

```json
{
  "$schema": "https://biomejs.dev/schemas/1.9.4/schema.json",
  "vcs": {
    "enabled": false,
    "clientKind": "git",
    "useIgnoreFile": false
  },
  "files": {
    "ignoreUnknown": false,
    "ignore": []
  },
  "formatter": {
    "enabled": true,
    "indentStyle": "space",
    "indentWidth": 2,
    "ignore": ["dist/**/*"]
  },
  "organizeImports": {
    "enabled": true
  },
  "linter": {
    "enabled": true,
    "rules": {
      "recommended": true
    },
    "ignore": ["dist/**/*"]
  },
  "javascript": {
    "formatter": {
      "quoteStyle": "double",
      "semicolons": "always"
    }
  }
}

```

--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------

```json
{
  "name": "@winterjung/mcp-korean-spell",
  "version": "1.0.1",
  "description": "Korean spell checker MCP(Model Context Protocol) server",
  "type": "module",
  "bin": {
    "mcp-korean-spell": "./dist/index.js"
  },
  "files": ["dist", "README.md", "LICENSE"],
  "engines": {
    "node": ">=18.0.0",
    "pnpm": "v10.7.1"
  },
  "scripts": {
    "test": "ts-node src/naver_speller_test.ts",
    "start": "ts-node src/index.ts",
    "build": "tsc && chmod +x dist/index.js",
    "dev": "ts-node src/index.ts",
    "watch": "tsc --watch",
    "format": "biome format . --write",
    "check": "biome check ."
  },
  "keywords": ["korean", "spell checker"],
  "author": "winterjung",
  "license": "Apache-2.0",
  "repository": {
    "type": "git",
    "url": "git+https://github.com/winterjung/mcp-korean-spell.git"
  },
  "bugs": {
    "url": "https://github.com/winterjung/mcp-korean-spell/issues"
  },
  "homepage": "https://github.com/winterjung/mcp-korean-spell#readme",
  "dependencies": {
    "@modelcontextprotocol/sdk": "^1.8.0",
    "cheerio": "^1.0.0",
    "zod": "^3.24.2"
  },
  "devDependencies": {
    "@biomejs/biome": "1.9.4",
    "@types/cheerio": "^0.22.35",
    "@types/node": "^22.14.0",
    "ts-node": "^10.9.2",
    "typescript": "^5.8.3"
  }
}

```

--------------------------------------------------------------------------------
/src/naver_speller.ts:
--------------------------------------------------------------------------------

```typescript
import { load as loadCheerio } from "cheerio";

const SPELLER_PROVIDER_URL =
  "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";
const PASSPORT_KEY_REGEX = /SpellerProxy\?passportKey=([a-zA-Z0-9]+)/;
const SPELLER_API_URL_BASE =
  "https://m.search.naver.com/p/csearch/ocontent/util/SpellerProxy?passportKey=";
const MAX_CHUNK_LENGTH = 300;

interface NaverSpellerResponse {
  message: {
    result: {
      html: string;
      errata_count: number;
      origin_html: string;
      notag_html: string;
    };
    error?: string;
  };
}

function simpleHtmlUnescape(text: string): string {
  return text
    .replace(/&lt;/g, "<")
    .replace(/&gt;/g, ">")
    .replace(/<br>/g, "\n")
    .replace(/&amp;/g, "&")
    .replace(/&quot;/g, '"')
    .replace(/&#39;/g, "'")
    .replace(/&nbsp;/g, " ");
}

export class NaverSpellChecker {
  private spellerApiUrl: string | null = null;

  private async fetchPassportKey(): Promise<string> {
    const response = await fetch(SPELLER_PROVIDER_URL);
    const html = await response.text();
    if (!response.ok) {
      throw new Error(
        `HTTP error! status: ${response.status}, response: ${html}`,
      );
    }

    let passportKey: string | undefined;
    const $ = loadCheerio(html);
    $("script").each((_: any, element: any) => {
      const scriptContent = $(element).html();
      const match = scriptContent?.match(PASSPORT_KEY_REGEX);
      if (match?.[1]) {
        passportKey = match[1];
        return false;
      }
    });

    if (!passportKey) {
      throw new Error("Passport key not found in the HTML content.");
    }
    return passportKey;
  }

  private async updateSpellerApiUrl(): Promise<void> {
    const passportKey = await this.fetchPassportKey();
    this.spellerApiUrl = `${SPELLER_API_URL_BASE}${passportKey}&color_blindness=0&q=`;
  }

  async correctText(text: string): Promise<string> {
    const chunks = this.chunkText(text);
    let corrected = "";
    for (const chunk of chunks) {
      const correctedChunk = await this.correctChunk(chunk);
      if (corrected.endsWith(" ")) {
        corrected += correctedChunk;
      } else {
        corrected += ` ${correctedChunk}`;
      }
    }
    return corrected;
  }

  async correctChunk(text: string): Promise<string> {
    if (!this.spellerApiUrl) {
      await this.updateSpellerApiUrl();
      if (!this.spellerApiUrl) {
        throw new Error(
          "Failed to initialize Speller API URL. Cannot proceed.",
        );
      }
    }

    const encodedText = encodeURIComponent(text);
    const url = this.spellerApiUrl + encodedText;

    const response = await fetch(url);
    const responseText = await response.text();
    if (!response.ok) {
      throw new Error(
        `HTTP error! status: ${response.status}, response: ${responseText}`,
      );
    }

    const data: NaverSpellerResponse = JSON.parse(responseText);

    if (data.message.error) {
      if (data.message.error === "유효한 키가 아닙니다.") {
        this.spellerApiUrl = null;
        throw new Error("Try again with a new passport key.");
      }
      throw new Error(`failed to check spelling: ${data.message.error}`);
    }

    return simpleHtmlUnescape(data.message.result.notag_html);
  }

  private chunkText(text: string): string[] {
    if (text.length <= MAX_CHUNK_LENGTH) {
      return [text];
    }

    const chunks: string[] = [];
    let currentChunk = "";
    const words = text.split(/(\s+)/);

    for (const word of words) {
      if (!word) continue;

      if (word.length > MAX_CHUNK_LENGTH) {
        // If a single "word" is too long, split it forcefully
        if (currentChunk) {
          chunks.push(currentChunk);
          currentChunk = "";
        }
        for (let i = 0; i < word.length; i += MAX_CHUNK_LENGTH) {
          chunks.push(word.substring(i, i + MAX_CHUNK_LENGTH));
        }
        continue;
      }
      if (currentChunk.length + word.length > MAX_CHUNK_LENGTH) {
        chunks.push(currentChunk);
        currentChunk = word.trimStart();
        continue;
      }
      currentChunk += word;
    }

    if (currentChunk) {
      chunks.push(currentChunk);
    }

    return chunks.filter((chunk) => chunk.length > 0);
  }
}

```